From e64f1d68156a2f4f959582c20f01e6bb09bf5e06 Mon Sep 17 00:00:00 2001 From: Ishan Bhanuka Date: Mon, 30 Jan 2023 09:46:03 +0800 Subject: [PATCH 01/81] Add pre-commit hooks for Rust (#984) --- .pre-commit-config.yaml | 34 +++++++++++++++++++++++ nautilus_core/common/src/clock.rs | 3 ++ nautilus_core/model/src/data/bar.rs | 5 ---- nautilus_core/model/src/types/price.rs | 5 ---- nautilus_core/model/src/types/quantity.rs | 5 ---- nautilus_trader/core/includes/model.h | 6 ---- nautilus_trader/core/rust/model.pxd | 6 ---- nautilus_trader/model/data/bar.pyx | 5 ---- nautilus_trader/model/objects.pyx | 10 ------- 9 files changed, 37 insertions(+), 42 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 573a21acb1c6..3cbd981f9232 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -116,6 +116,40 @@ repos: hooks: - id: nbstripout + ############################################################################## + # Rust formatting and linting + ############################################################################## + + - repo: local + hooks: + - id: fmt + name: cargo fmt + description: Format files with cargo fmt. + entry: cargo fmt + language: system + types: [rust] + args: ["--manifest-path", "nautilus_core/Cargo.toml", "--all"] + files: \.rs$ + pass_filenames: false + - id: cargo-clippy + name: cargo clippy + description: Run the Clippy linter on the package. + entry: cargo clippy + language: system + types: [rust] + args: ["--manifest-path", "nautilus_core/Cargo.toml", "--", "-D", "warnings"] + files: \.rs$ + pass_filenames: false + - id: cargo-check + name: cargo check + description: Check the package for errors. + entry: cargo check + language: system + types: [rust] + args: ["--manifest-path", "nautilus_core/Cargo.toml"] + files: \.rs$ + pass_filenames: false + # pydocstyle ignore reasons # ------------------------- # D100: Missing docstring in public module **fix** diff --git a/nautilus_core/common/src/clock.rs b/nautilus_core/common/src/clock.rs index 9e5c7213e6e1..b70f4046d609 100644 --- a/nautilus_core/common/src/clock.rs +++ b/nautilus_core/common/src/clock.rs @@ -356,6 +356,9 @@ pub unsafe extern "C" fn test_clock_advance_time( } } +// TODO: This struct implementation potentially leaks memory +// TODO: Skip clippy check for now since it requires large modification +#[allow(clippy::drop_non_drop)] #[no_mangle] pub extern "C" fn vec_time_events_drop(v: Vec_TimeEvent) { drop(v); // Memory freed here diff --git a/nautilus_core/model/src/data/bar.rs b/nautilus_core/model/src/data/bar.rs index aef6673617ae..97ab833c6322 100644 --- a/nautilus_core/model/src/data/bar.rs +++ b/nautilus_core/model/src/data/bar.rs @@ -69,11 +69,6 @@ pub extern "C" fn bar_specification_to_cstr(bar_spec: &BarSpecification) -> *con string_to_cstr(&bar_spec.to_string()) } -#[no_mangle] -pub extern "C" fn bar_specification_free(bar_spec: BarSpecification) { - drop(bar_spec); // Memory freed here -} - #[no_mangle] pub extern "C" fn bar_specification_hash(bar_spec: &BarSpecification) -> u64 { let mut h = DefaultHasher::new(); diff --git a/nautilus_core/model/src/types/price.rs b/nautilus_core/model/src/types/price.rs index 1fe0071c7676..3968f89dce06 100644 --- a/nautilus_core/model/src/types/price.rs +++ b/nautilus_core/model/src/types/price.rs @@ -219,11 +219,6 @@ pub extern "C" fn price_from_raw(raw: i64, precision: u8) -> Price { Price::from_raw(raw, precision) } -#[no_mangle] -pub extern "C" fn price_free(price: Price) { - drop(price); // Memory freed here -} - #[no_mangle] pub extern "C" fn price_as_f64(price: &Price) -> f64 { price.as_f64() diff --git a/nautilus_core/model/src/types/quantity.rs b/nautilus_core/model/src/types/quantity.rs index 8fb59996f58e..eb47b5087e93 100644 --- a/nautilus_core/model/src/types/quantity.rs +++ b/nautilus_core/model/src/types/quantity.rs @@ -205,11 +205,6 @@ pub extern "C" fn quantity_from_raw(raw: u64, precision: u8) -> Quantity { Quantity::from_raw(raw, precision) } -#[no_mangle] -pub extern "C" fn quantity_free(qty: Quantity) { - drop(qty); // Memory freed here -} - #[no_mangle] pub extern "C" fn quantity_as_f64(qty: &Quantity) -> f64 { qty.as_f64() diff --git a/nautilus_trader/core/includes/model.h b/nautilus_trader/core/includes/model.h index 845990b78d57..2336bf2caaed 100644 --- a/nautilus_trader/core/includes/model.h +++ b/nautilus_trader/core/includes/model.h @@ -368,8 +368,6 @@ typedef struct Money_t { */ const char *bar_specification_to_cstr(const struct BarSpecification_t *bar_spec); -void bar_specification_free(struct BarSpecification_t bar_spec); - uint64_t bar_specification_hash(const struct BarSpecification_t *bar_spec); struct BarSpecification_t bar_specification_new(uint64_t step, @@ -1120,8 +1118,6 @@ struct Price_t price_new(double value, uint8_t precision); struct Price_t price_from_raw(int64_t raw, uint8_t precision); -void price_free(struct Price_t price); - double price_as_f64(const struct Price_t *price); void price_add_assign(struct Price_t a, struct Price_t b); @@ -1132,8 +1128,6 @@ struct Quantity_t quantity_new(double value, uint8_t precision); struct Quantity_t quantity_from_raw(uint64_t raw, uint8_t precision); -void quantity_free(struct Quantity_t qty); - double quantity_as_f64(const struct Quantity_t *qty); void quantity_add_assign(struct Quantity_t a, struct Quantity_t b); diff --git a/nautilus_trader/core/rust/model.pxd b/nautilus_trader/core/rust/model.pxd index 429599072e84..b3d2cb2f1936 100644 --- a/nautilus_trader/core/rust/model.pxd +++ b/nautilus_trader/core/rust/model.pxd @@ -309,8 +309,6 @@ cdef extern from "../includes/model.h": # Returns a [`BarSpecification`] as a C string pointer. const char *bar_specification_to_cstr(const BarSpecification_t *bar_spec); - void bar_specification_free(BarSpecification_t bar_spec); - uint64_t bar_specification_hash(const BarSpecification_t *bar_spec); BarSpecification_t bar_specification_new(uint64_t step, @@ -913,8 +911,6 @@ cdef extern from "../includes/model.h": Price_t price_from_raw(int64_t raw, uint8_t precision); - void price_free(Price_t price); - double price_as_f64(const Price_t *price); void price_add_assign(Price_t a, Price_t b); @@ -925,8 +921,6 @@ cdef extern from "../includes/model.h": Quantity_t quantity_from_raw(uint64_t raw, uint8_t precision); - void quantity_free(Quantity_t qty); - double quantity_as_f64(const Quantity_t *qty); void quantity_add_assign(Quantity_t a, Quantity_t b); diff --git a/nautilus_trader/model/data/bar.pyx b/nautilus_trader/model/data/bar.pyx index 366cb16834ec..a6f8fe5bcdc6 100644 --- a/nautilus_trader/model/data/bar.pyx +++ b/nautilus_trader/model/data/bar.pyx @@ -26,7 +26,6 @@ from nautilus_trader.core.rust.model cimport bar_hash from nautilus_trader.core.rust.model cimport bar_new from nautilus_trader.core.rust.model cimport bar_new_from_raw from nautilus_trader.core.rust.model cimport bar_specification_eq -from nautilus_trader.core.rust.model cimport bar_specification_free from nautilus_trader.core.rust.model cimport bar_specification_ge from nautilus_trader.core.rust.model cimport bar_specification_gt from nautilus_trader.core.rust.model cimport bar_specification_hash @@ -109,10 +108,6 @@ cdef class BarSpecification: state[2] ) - def __del__(self) -> None: - # Never allocation heap memory - bar_specification_free(self._mem) # `self._mem` moved to Rust (then dropped) - cdef str to_str(self): return cstr_to_pystr(bar_specification_to_cstr(&self._mem)) diff --git a/nautilus_trader/model/objects.pyx b/nautilus_trader/model/objects.pyx index caa9b0113801..8ba8966a6309 100644 --- a/nautilus_trader/model/objects.pyx +++ b/nautilus_trader/model/objects.pyx @@ -46,10 +46,8 @@ from nautilus_trader.core.rust.model cimport currency_eq from nautilus_trader.core.rust.model cimport money_free from nautilus_trader.core.rust.model cimport money_from_raw from nautilus_trader.core.rust.model cimport money_new -from nautilus_trader.core.rust.model cimport price_free from nautilus_trader.core.rust.model cimport price_from_raw from nautilus_trader.core.rust.model cimport price_new -from nautilus_trader.core.rust.model cimport quantity_free from nautilus_trader.core.rust.model cimport quantity_from_raw from nautilus_trader.core.rust.model cimport quantity_new from nautilus_trader.core.string cimport cstr_to_pystr @@ -121,10 +119,6 @@ cdef class Quantity: self._mem = quantity_new(value, precision) - def __del__(self) -> None: - # Never allocating heap memory - quantity_free(self._mem) # `self._mem` moved to Rust (then dropped) - def __getstate__(self): return self._mem.raw, self._mem.precision @@ -511,10 +505,6 @@ cdef class Price: self._mem = price_new(value, precision) - def __del__(self) -> None: - # Never allocating heap memory - price_free(self._mem) # `self._mem` moved to Rust (then dropped) - def __getstate__(self): return self._mem.raw, self._mem.precision From 9bd051682eeb07efced8f82160deb4cfd4f4f3ff Mon Sep 17 00:00:00 2001 From: Chris Sellers Date: Mon, 30 Jan 2023 12:50:12 +1100 Subject: [PATCH 02/81] Cleanup --- .pre-commit-config.yaml | 67 ++++++++++++++++++++--------------------- Makefile | 1 - 2 files changed, 33 insertions(+), 35 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 3cbd981f9232..1bcc60370a5e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -32,6 +32,39 @@ repos: types_or: [python, cython, rst, markdown] args: ["-L", "ot,zar,warmup"] + ############################################################################## + # Rust formatting and linting + ############################################################################## + - repo: local + hooks: + - id: fmt + name: cargo fmt + description: Format files with cargo fmt. + entry: cargo fmt + language: system + types: [rust] + args: ["--manifest-path", "nautilus_core/Cargo.toml", "--all"] + files: \.rs$ + pass_filenames: false + - id: cargo-clippy + name: cargo clippy + description: Run the Clippy linter on the package. + entry: cargo clippy + language: system + types: [rust] + args: ["--manifest-path", "nautilus_core/Cargo.toml", "--", "-D", "warnings"] + files: \.rs$ + pass_filenames: false + - id: cargo-check + name: cargo check + description: Check the package for errors. + entry: cargo check + language: system + types: [rust] + args: ["--manifest-path", "nautilus_core/Cargo.toml"] + files: \.rs$ + pass_filenames: false + ############################################################################## # Python/Cython formatting and linting ############################################################################## @@ -116,40 +149,6 @@ repos: hooks: - id: nbstripout - ############################################################################## - # Rust formatting and linting - ############################################################################## - - - repo: local - hooks: - - id: fmt - name: cargo fmt - description: Format files with cargo fmt. - entry: cargo fmt - language: system - types: [rust] - args: ["--manifest-path", "nautilus_core/Cargo.toml", "--all"] - files: \.rs$ - pass_filenames: false - - id: cargo-clippy - name: cargo clippy - description: Run the Clippy linter on the package. - entry: cargo clippy - language: system - types: [rust] - args: ["--manifest-path", "nautilus_core/Cargo.toml", "--", "-D", "warnings"] - files: \.rs$ - pass_filenames: false - - id: cargo-check - name: cargo check - description: Check the package for errors. - entry: cargo check - language: system - types: [rust] - args: ["--manifest-path", "nautilus_core/Cargo.toml"] - files: \.rs$ - pass_filenames: false - # pydocstyle ignore reasons # ------------------------- # D100: Missing docstring in public module **fix** diff --git a/Makefile b/Makefile index 5e80fa12958e..d87b019a19ce 100644 --- a/Makefile +++ b/Makefile @@ -28,7 +28,6 @@ format: (cd nautilus_core && cargo fmt) pre-commit: format - (cd nautilus_core && cargo fmt --all -- --check && cargo check -q && cargo clippy --all-targets --all-features -- -D warnings) pre-commit run --all-files update: From cbd09018e619e7791fead37b104601ee584a4a50 Mon Sep 17 00:00:00 2001 From: Chris Sellers Date: Mon, 30 Jan 2023 12:51:16 +1100 Subject: [PATCH 03/81] Bump version --- nautilus_core/Cargo.lock | 4 ++-- poetry.lock | 8 ++++---- pyproject.toml | 4 ++-- version.json | 2 +- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/nautilus_core/Cargo.lock b/nautilus_core/Cargo.lock index e1425f082c24..9bcd37d218e4 100644 --- a/nautilus_core/Cargo.lock +++ b/nautilus_core/Cargo.lock @@ -774,9 +774,9 @@ dependencies = [ [[package]] name = "indoc" -version = "1.0.8" +version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da2d6f23ffea9d7e76c53eee25dfb67bcd8fde7f1198b0855350698c9f07c780" +checksum = "bfa799dd5ed20a7e349f3b4639aa80d74549c81716d9ec4f994c9b5815598306" [[package]] name = "instant" diff --git a/poetry.lock b/poetry.lock index c30120f393ce..f8220b891618 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1720,14 +1720,14 @@ testing = ["pytest", "pytest-benchmark"] [[package]] name = "pre-commit" -version = "3.0.1" +version = "3.0.2" description = "A framework for managing and maintaining multi-language pre-commit hooks." category = "dev" optional = false python-versions = ">=3.8" files = [ - {file = "pre_commit-3.0.1-py2.py3-none-any.whl", hash = "sha256:61ecb75e0e99939cc30c79181c0394545657855e9172c42ff98ebecb0e02fcb7"}, - {file = "pre_commit-3.0.1.tar.gz", hash = "sha256:3a3f9229e8c19a626a7f91be25b3c8c135e52de1a678da98eb015c0d0baea7a5"}, + {file = "pre_commit-3.0.2-py2.py3-none-any.whl", hash = "sha256:f448d5224c70e196a6c6f87961d2333dfdc49988ebbf660477f9efe991c03597"}, + {file = "pre_commit-3.0.2.tar.gz", hash = "sha256:aa97fa71e7ab48225538e1e91a6b26e483029e6de64824f04760c32557bc91d7"}, ] [package.dependencies] @@ -2942,4 +2942,4 @@ redis = ["hiredis", "redis"] [metadata] lock-version = "2.0" python-versions = ">=3.9,<3.12" -content-hash = "2aefd8f46e5047c677f66f4e7ab191f44bb5b882ea261d468ba84153237d494f" +content-hash = "c1f3e24ee56f7f86b4b3375bf61152800f3def784300e96a185f24237829014f" diff --git a/pyproject.toml b/pyproject.toml index 874cd2aa27ae..ebe1526f4441 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "nautilus_trader" -version = "1.168.0" +version = "1.169.0" description = "A high-performance algorithmic trading platform and event-driven backtester" authors = ["Nautech Systems "] license = "LGPL-3.0-or-later" @@ -83,7 +83,7 @@ black = "^22.12.0" flake8 = "^6.0.0" isort = "^5.12.0" mypy = "^0.991" -pre-commit = "^3.0.0" +pre-commit = "^3.0.2" pyproject-flake8 = "^6.0.0" types-pytz = "^2022.6.0" types-redis = "^4.3.21" diff --git a/version.json b/version.json index f1967d421c89..2c63319bf177 100644 --- a/version.json +++ b/version.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, "label": "", - "message": "v1.168.0", + "message": "v1.169.0", "color": "orange" } From 2fe8772c419f724700d3b01a540b5f8d161b04b7 Mon Sep 17 00:00:00 2001 From: Chris Sellers Date: Mon, 30 Jan 2023 17:26:39 +1100 Subject: [PATCH 04/81] Small cleanup --- nautilus_trader/persistence/catalog/parquet.py | 8 ++++---- tests/unit_tests/persistence/test_catalog.py | 10 ++++++++-- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/nautilus_trader/persistence/catalog/parquet.py b/nautilus_trader/persistence/catalog/parquet.py index d6cd9513a7e8..aa1c24ee181e 100644 --- a/nautilus_trader/persistence/catalog/parquet.py +++ b/nautilus_trader/persistence/catalog/parquet.py @@ -152,9 +152,9 @@ def _query( # noqa (too complex) instrument_ids = list(set(map(clean_key, instrument_ids))) filters.append(ds.field(instrument_id_column).cast("string").isin(instrument_ids)) if start is not None: - filters.append(ds.field(ts_column) >= int(pd.Timestamp(start).to_datetime64())) + filters.append(ds.field(ts_column) >= pd.Timestamp(start).value) if end is not None: - filters.append(ds.field(ts_column) <= int(pd.Timestamp(end).to_datetime64())) + filters.append(ds.field(ts_column) <= pd.Timestamp(end).value) full_path = self._make_path(cls=cls) @@ -191,7 +191,7 @@ def _query( # noqa (too complex) elif cls == TradeTick: parquet_type = ParquetType.TradeTick else: - RuntimeError() + raise RuntimeError() ticks = [] for file in dataset.files: @@ -210,7 +210,7 @@ def _query( # noqa (too complex) elif cls == TradeTick: data = map(TradeTick.list_from_capsule, reader) else: - RuntimeError() + raise RuntimeError() ticks.extend(list(itertools.chain.from_iterable(data))) return ticks diff --git a/tests/unit_tests/persistence/test_catalog.py b/tests/unit_tests/persistence/test_catalog.py index 738051fd1cc8..01dafe384330 100644 --- a/tests/unit_tests/persistence/test_catalog.py +++ b/tests/unit_tests/persistence/test_catalog.py @@ -367,20 +367,26 @@ def test_data_catalog_filter(self): assert len(filtered_deltas) == 351 def test_data_catalog_generic_data(self): - + # Arrange TestPersistenceStubs.setup_news_event_persistence() process_files( glob_path=f"{TEST_DATA_DIR}/news_events.csv", reader=CSVReader(block_parser=TestPersistenceStubs.news_event_parser), catalog=self.catalog, ) + + # Act df = self.catalog.generic_data(cls=NewsEventData, filter_expr=ds.field("currency") == "USD") - assert len(df) == 22925 data = self.catalog.generic_data( cls=NewsEventData, filter_expr=ds.field("currency") == "CHF", as_nautilus=True, ) + + # Assert + assert df + assert data + assert len(df) == 22925 assert len(data) == 2745 and isinstance(data[0], GenericData) def test_data_catalog_bars(self): From a9d7f84fc0320b0bcb8d9e0f44b28f687c33ffd8 Mon Sep 17 00:00:00 2001 From: Chris Sellers Date: Mon, 30 Jan 2023 17:34:07 +1100 Subject: [PATCH 05/81] Small cleanup --- tests/unit_tests/persistence/test_catalog.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/unit_tests/persistence/test_catalog.py b/tests/unit_tests/persistence/test_catalog.py index 01dafe384330..21f1c76c1079 100644 --- a/tests/unit_tests/persistence/test_catalog.py +++ b/tests/unit_tests/persistence/test_catalog.py @@ -384,8 +384,8 @@ def test_data_catalog_generic_data(self): ) # Assert - assert df - assert data + assert df is not None + assert data is not None assert len(df) == 22925 assert len(data) == 2745 and isinstance(data[0], GenericData) From f8555f4eefbdb1eb6f8b68d806b36e590be1d732 Mon Sep 17 00:00:00 2001 From: Chris Sellers Date: Tue, 31 Jan 2023 18:26:13 +1100 Subject: [PATCH 06/81] Expose Exchange API for matching engine interaction - This functionality supports `SimulationModule` implementations --- nautilus_trader/backtest/exchange.pxd | 2 ++ nautilus_trader/backtest/exchange.pyx | 24 +++++++++++++++++++ nautilus_trader/backtest/matching_engine.pxd | 4 ++-- nautilus_trader/backtest/matching_engine.pyx | 12 +++++----- .../backtest/test_backtest_exchange.py | 16 +++++++++++++ 5 files changed, 50 insertions(+), 8 deletions(-) diff --git a/nautilus_trader/backtest/exchange.pxd b/nautilus_trader/backtest/exchange.pxd index e93416a1986a..40488b9708c3 100644 --- a/nautilus_trader/backtest/exchange.pxd +++ b/nautilus_trader/backtest/exchange.pxd @@ -17,6 +17,7 @@ from libc.stdint cimport uint64_t from nautilus_trader.accounting.accounts.base cimport Account from nautilus_trader.backtest.execution_client cimport BacktestExecClient +from nautilus_trader.backtest.matching_engine cimport OrderMatchingEngine from nautilus_trader.backtest.models cimport FillModel from nautilus_trader.backtest.models cimport LatencyModel from nautilus_trader.cache.cache cimport Cache @@ -103,6 +104,7 @@ cdef class SimulatedExchange: cpdef Price best_bid_price(self, InstrumentId instrument_id) cpdef Price best_ask_price(self, InstrumentId instrument_id) cpdef OrderBook get_book(self, InstrumentId instrument_id) + cpdef OrderMatchingEngine get_matching_engine(self, InstrumentId instrument_id) cpdef dict get_matching_engines(self) cpdef dict get_books(self) cpdef list get_open_orders(self, InstrumentId instrument_id=*) diff --git a/nautilus_trader/backtest/exchange.pyx b/nautilus_trader/backtest/exchange.pyx index 2a6c876c5c05..bad1ecd9ab12 100644 --- a/nautilus_trader/backtest/exchange.pyx +++ b/nautilus_trader/backtest/exchange.pyx @@ -387,7 +387,31 @@ cdef class SimulatedExchange: return matching_engine.get_book() + cpdef OrderMatchingEngine get_matching_engine(self, InstrumentId instrument_id): + """ + Return the matching engine for the given instrument ID (if found). + + Parameters + ---------- + instrument_id : InstrumentId + The instrument ID for the matching engine. + + Returns + ------- + OrderMatchingEngine or ``None`` + + """ + return self._matching_engines.get(instrument_id) + cpdef dict get_matching_engines(self): + """ + Return all matching engines for the exchange (for every instrument). + + Returns + ------- + dict[InstrumentId, OrderMatchingEngine] + + """ return self._matching_engines.copy() cpdef dict get_books(self): diff --git a/nautilus_trader/backtest/matching_engine.pxd b/nautilus_trader/backtest/matching_engine.pxd index 80c7b07d900d..144c300bb43c 100644 --- a/nautilus_trader/backtest/matching_engine.pxd +++ b/nautilus_trader/backtest/matching_engine.pxd @@ -162,14 +162,14 @@ cdef class OrderMatchingEngine: cpdef void fill_market_order(self, Order order) except * cpdef void fill_limit_order(self, Order order) except * - cpdef void _apply_fills( + cpdef void apply_fills( self, Order order, list fills, PositionId venue_position_id, Position position, ) except * - cpdef void _fill_order( + cpdef void fill_order( self, Order order, PositionId venue_position_id, diff --git a/nautilus_trader/backtest/matching_engine.pyx b/nautilus_trader/backtest/matching_engine.pyx index 2318b753e86b..cd934e64e659 100644 --- a/nautilus_trader/backtest/matching_engine.pyx +++ b/nautilus_trader/backtest/matching_engine.pyx @@ -1275,7 +1275,7 @@ cdef class OrderMatchingEngine: order.liquidity_side = LiquiditySide.TAKER - self._apply_fills( + self.apply_fills( order=order, fills=self.determine_market_price_and_volume(order), venue_position_id=venue_position_id, @@ -1303,14 +1303,14 @@ cdef class OrderMatchingEngine: self.cancel_order(order) return # Order canceled - self._apply_fills( + self.apply_fills( order=order, fills=self.determine_limit_price_and_volume(order), venue_position_id=venue_position_id, position=position, ) - cpdef void _apply_fills( + cpdef void apply_fills( self, Order order, list fills, @@ -1386,7 +1386,7 @@ cdef class OrderMatchingEngine: ) if not fill_qty._mem.raw > 0: return # Done - self._fill_order( + self.fill_order( order=order, venue_position_id=venue_position_id, position=position, @@ -1421,7 +1421,7 @@ cdef class OrderMatchingEngine: f"invalid `OrderSide`, was {order.side}", # pragma: no cover (design-time error) ) - self._fill_order( + self.fill_order( order=order, venue_position_id=venue_position_id, position=position, @@ -1429,7 +1429,7 @@ cdef class OrderMatchingEngine: last_px=fill_px, ) - cpdef void _fill_order( + cpdef void fill_order( self, Order order, PositionId venue_position_id, # Can be None diff --git a/tests/unit_tests/backtest/test_backtest_exchange.py b/tests/unit_tests/backtest/test_backtest_exchange.py index cefaddbc9250..5df4b1fe0ac0 100644 --- a/tests/unit_tests/backtest/test_backtest_exchange.py +++ b/tests/unit_tests/backtest/test_backtest_exchange.py @@ -186,6 +186,22 @@ def test_set_fill_model(self): # Assert assert self.exchange.fill_model == fill_model + def test_get_matching_engines_when_engine_returns_expected_dict(self): + # Arrange, Act + matching_engines = self.exchange.get_matching_engines() + + # Assert + assert isinstance(matching_engines, dict) + assert len(matching_engines) == 1 + assert list(matching_engines.keys()) == [USDJPY_SIM.id] + + def test_get_matching_engine_when_no_engine_for_instrument_returns_none(self): + # Arrange, Act + matching_engine = self.exchange.get_matching_engine(USDJPY_SIM.id) + + # Assert + assert matching_engine.instrument == USDJPY_SIM + def test_get_books_with_one_instrument_returns_one_book(self): # Arrange, Act books = self.exchange.get_books() From 670063f1c3d30c269e1ac6d2b2c77fd75ef4a40e Mon Sep 17 00:00:00 2001 From: Chris Sellers Date: Wed, 1 Feb 2023 18:39:31 +1100 Subject: [PATCH 07/81] Improve public matching engine API --- nautilus_trader/backtest/matching_engine.pxd | 12 +- nautilus_trader/backtest/matching_engine.pyx | 168 +++++++++++++++++-- 2 files changed, 165 insertions(+), 15 deletions(-) diff --git a/nautilus_trader/backtest/matching_engine.pxd b/nautilus_trader/backtest/matching_engine.pxd index 144c300bb43c..4523a3cdf80a 100644 --- a/nautilus_trader/backtest/matching_engine.pxd +++ b/nautilus_trader/backtest/matching_engine.pxd @@ -166,16 +166,18 @@ cdef class OrderMatchingEngine: self, Order order, list fills, - PositionId venue_position_id, - Position position, + LiquiditySide liquidity_side, + PositionId venue_position_id=*, + Position position=*, ) except * cpdef void fill_order( self, Order order, - PositionId venue_position_id, - Position position, - Quantity last_qty, Price last_px, + Quantity last_qty, + LiquiditySide liquidity_side, + PositionId venue_position_id=*, + Position position=*, ) except * # -- IDENTIFIER GENERATORS ------------------------------------------------------------------------ diff --git a/nautilus_trader/backtest/matching_engine.pyx b/nautilus_trader/backtest/matching_engine.pyx index cd934e64e659..a6653e5d529b 100644 --- a/nautilus_trader/backtest/matching_engine.pyx +++ b/nautilus_trader/backtest/matching_engine.pyx @@ -1077,6 +1077,16 @@ cdef class OrderMatchingEngine: # -- ORDER PROCESSING ----------------------------------------------------------------------------- cpdef void iterate(self, uint64_t timestamp_ns) except *: + """ + Iterate the matching engine by processing the bid and ask order sides + and advancing time up to the given UNIX `timestamp_ns`. + + Parameters + ---------- + timestamp_ns : uint64_t + The UNIX timestamp to advance the matching engine time to. + + """ self._clock.set_time(timestamp_ns) # TODO: Convert order book to use ints rather than doubles @@ -1120,6 +1130,29 @@ cdef class OrderMatchingEngine: self._has_targets = False cpdef list determine_limit_price_and_volume(self, Order order): + """ + Return the projected fills for the given *limit* order filling passively + from its limit price. + + The list may be empty if no fills. + + Parameters + ---------- + order : Order + The order to determine fills for. + + Returns + ------- + list[tuple[Price, Quantity]] + + Raises + ------ + ValueError + If the `order` does not have a LIMIT `price`. + + """ + Condition.true(order.has_price_c(), "order has no limit `price`") + cdef list fills cdef BookOrder submit_order = BookOrder(price=order.price, size=order.leaves_qty, side=order.side) if order.side == OrderSide.BUY: @@ -1203,6 +1236,22 @@ cdef class OrderMatchingEngine: return fills cpdef list determine_market_price_and_volume(self, Order order): + """ + Return the projected fills for the given *marketable* order filling + aggressively into its order side. + + The list may be empty if no fills. + + Parameters + ---------- + order : Order + The order to determine fills for. + + Returns + ------- + list[tuple[Price, Quantity]] + + """ cdef list fills cdef Price price = Price.from_int_c(INT_MAX if order.side == OrderSide.BUY else INT_MIN) cdef BookOrder submit_order = BookOrder(price=price, size=order.leaves_qty, side=order.side) @@ -1261,6 +1310,15 @@ cdef class OrderMatchingEngine: return fills cpdef void fill_market_order(self, Order order) except *: + """ + Fill the given *marketable* order. + + Parameters + ---------- + order : Order + The order to fill. + + """ cdef PositionId venue_position_id = self._get_position_id(order) cdef Position position = None if venue_position_id is not None: @@ -1278,12 +1336,28 @@ cdef class OrderMatchingEngine: self.apply_fills( order=order, fills=self.determine_market_price_and_volume(order), + liquidity_side=order.liquidity_side, venue_position_id=venue_position_id, position=position, ) cpdef void fill_limit_order(self, Order order) except *: - assert order.has_price_c(), f"{order.type_string_c()} has no LIMIT price" + """ + Fill the given limit order. + + Parameters + ---------- + order : Order + The order to fill. + + Raises + ------ + ValueError + If the `order` does not have a LIMIT `price`. + + """ + Condition.true(order.has_price_c(), "order has no limit `price`") + cdef Price price = order.price if order.liquidity_side == LiquiditySide.MAKER and self._fill_model: if order.side == OrderSide.BUY and self._core.bid_raw == price._mem.raw and not self._fill_model.is_limit_filled(): @@ -1306,6 +1380,7 @@ cdef class OrderMatchingEngine: self.apply_fills( order=order, fills=self.determine_limit_price_and_volume(order), + liquidity_side=order.liquidity_side, venue_position_id=venue_position_id, position=position, ) @@ -1314,9 +1389,43 @@ cdef class OrderMatchingEngine: self, Order order, list fills, - PositionId venue_position_id, # Can be None - Position position, # Can be None + LiquiditySide liquidity_side, + PositionId venue_position_id: Optional[PositionId] = None, + Position position: Optional[Position] = None, ) except *: + """ + Apply the given list of fills to the given order. Optionally provide + existing position details. + + Parameters + ---------- + order : Order + The order to fill. + fills : list[tuple[Price, Quantity]] + The fills to apply to the order. + liquidity_side : LiquiditySide + The liquidity side for the fill(s). + venue_position_id : PositionId, optional + The current venue position ID related to the order (if assigned). + position : Position, optional + The current position related to the order (if any). + + Raises + ------ + ValueError + If `liquidity_side` is ``NO_LIQUIDITY_SIDE``. + + Warnings + -------- + The `liquidity_side` will override anything previously set on the order. + + """ + Condition.not_none(order, "order") + Condition.not_none(fills, "fills") + Condition.not_equal(liquidity_side, LiquiditySide.NO_LIQUIDITY_SIDE, "liquidity_side", "NO_LIQUIDITY_SIDE") + + order.liquidity_side = liquidity_side + if not fills: return # No fills @@ -1388,10 +1497,11 @@ cdef class OrderMatchingEngine: return # Done self.fill_order( order=order, + last_px=fill_px, + last_qty=fill_qty, + liquidity_side=order.liquidity_side, venue_position_id=venue_position_id, position=position, - last_qty=fill_qty, - last_px=fill_px, ) if order.order_type == OrderType.MARKET_TO_LIMIT and initial_market_to_limit_fill: return # Filled initial level @@ -1423,20 +1533,58 @@ cdef class OrderMatchingEngine: self.fill_order( order=order, + last_px=fill_px, + last_qty=order.leaves_qty, + liquidity_side=order.liquidity_side, venue_position_id=venue_position_id, position=position, - last_qty=order.leaves_qty, - last_px=fill_px, ) cpdef void fill_order( self, Order order, - PositionId venue_position_id, # Can be None - Position position: Optional[Position], - Quantity last_qty, Price last_px, + Quantity last_qty, + LiquiditySide liquidity_side, + PositionId venue_position_id: Optional[PositionId] = None, + Position position: Optional[Position] = None, ) except *: + """ + Apply the given list of fills to the given order. Optionally provide + existing position details. + + Parameters + ---------- + order : Order + The order to fill. + last_px : Price + The fill price for the order. + last_qty : Price + The fill quantity for the order. + liquidity_side : LiquiditySide + The liquidity side for the fill. + venue_position_id : PositionId, optional + The current venue position ID related to the order (if assigned). + position : Position, optional + The current position related to the order (if any). + + Raises + ------ + ValueError + If `liquidity_side` is ``NO_LIQUIDITY_SIDE``. + + Warnings + -------- + The `liquidity_side` will override anything previously set on the order. + + """ + Condition.not_none(order, "order") + Condition.not_none(last_px, "last_px") + Condition.not_none(last_qty, "last_qty") + Condition.not_equal(liquidity_side, LiquiditySide.NO_LIQUIDITY_SIDE, "liquidity_side", "NO_LIQUIDITY_SIDE") + + order.liquidity_side = liquidity_side + # Calculate commission cdef double notional = self.instrument.notional_value( quantity=last_qty, From 862bf3a4f5761f71a0ba5b6c7901a11c64bca93f Mon Sep 17 00:00:00 2001 From: Reece Kibble Date: Fri, 3 Feb 2023 04:06:38 +0800 Subject: [PATCH 08/81] Binance consolidation of common classes logical refactor (#973) --- docs/integrations/binance.md | 39 +- .../adapters/binance/common/data.py | 632 ++++++++ .../adapters/binance/common/enums.py | 248 +++- .../adapters/binance/common/execution.py | 787 ++++++++++ .../adapters/binance/common/functions.py | 44 - .../adapters/binance/common/parsing/data.py | 229 --- .../adapters/binance/common/schemas.py | 275 ---- .../common/{parsing => schemas}/__init__.py | 0 .../binance/common/schemas/account.py | 247 ++++ .../adapters/binance/common/schemas/market.py | 635 ++++++++ .../adapters/binance/common/schemas/symbol.py | 60 + .../{http/enums.py => common/schemas/user.py} | 17 +- nautilus_trader/adapters/binance/factories.py | 32 +- .../adapters/binance/futures/data.py | 571 +------ .../adapters/binance/futures/enums.py | 111 +- .../adapters/binance/futures/execution.py | 925 ++---------- .../adapters/binance/futures/http/account.py | 817 ++++------ .../adapters/binance/futures/http/market.py | 490 +----- .../adapters/binance/futures/http/user.py | 111 +- .../adapters/binance/futures/http/wallet.py | 131 +- .../binance/futures/parsing/__init__.py | 14 - .../binance/futures/parsing/account.py | 72 - .../adapters/binance/futures/parsing/data.py | 281 ---- .../binance/futures/parsing/execution.py | 207 --- .../adapters/binance/futures/providers.py | 227 ++- .../adapters/binance/futures/rules.py | 35 - .../binance/futures/schemas/account.py | 126 +- .../binance/futures/schemas/market.py | 146 +- .../adapters/binance/futures/schemas/user.py | 199 ++- .../rules.py => futures/schemas/wallet.py} | 25 +- .../adapters/binance/http/account.py | 660 +++++++++ .../adapters/binance/http/client.py | 2 - .../adapters/binance/http/endpoint.py | 78 + .../adapters/binance/http/market.py | 855 +++++++++++ nautilus_trader/adapters/binance/http/user.py | 206 +++ nautilus_trader/adapters/binance/spot/data.py | 558 +------ .../adapters/binance/spot/enums.py | 97 +- .../adapters/binance/spot/execution.py | 772 +--------- .../adapters/binance/spot/http/account.py | 1317 ++++++++--------- .../adapters/binance/spot/http/market.py | 515 ++----- .../adapters/binance/spot/http/user.py | 210 +-- .../adapters/binance/spot/http/wallet.py | 153 +- .../adapters/binance/spot/parsing/__init__.py | 14 - .../adapters/binance/spot/parsing/account.py | 58 - .../adapters/binance/spot/parsing/data.py | 170 --- .../binance/spot/parsing/execution.py | 185 --- .../adapters/binance/spot/providers.py | 174 ++- .../adapters/binance/spot/schemas/account.py | 43 +- .../adapters/binance/spot/schemas/market.py | 146 +- .../adapters/binance/spot/schemas/user.py | 192 ++- .../adapters/binance/spot/schemas/wallet.py | 4 +- .../adapters/binance/websocket/client.py | 20 +- .../adapters/binance/test_core_functions.py | 31 +- .../adapters/binance/test_data_spot.py | 1 + .../binance/test_execution_futures.py | 7 +- .../adapters/binance/test_execution_spot.py | 1 + .../adapters/binance/test_factories.py | 48 +- .../adapters/binance/test_http_account.py | 141 +- .../adapters/binance/test_http_market.py | 116 +- .../adapters/binance/test_http_user.py | 41 +- .../adapters/binance/test_http_wallet.py | 15 +- .../adapters/binance/test_parsing_common.py | 30 +- .../adapters/binance/test_parsing_http.py | 8 +- .../adapters/binance/test_parsing_ws.py | 8 +- .../adapters/binance/test_providers.py | 7 + 65 files changed, 7344 insertions(+), 7272 deletions(-) create mode 100644 nautilus_trader/adapters/binance/common/data.py create mode 100644 nautilus_trader/adapters/binance/common/execution.py delete mode 100644 nautilus_trader/adapters/binance/common/functions.py delete mode 100644 nautilus_trader/adapters/binance/common/parsing/data.py delete mode 100644 nautilus_trader/adapters/binance/common/schemas.py rename nautilus_trader/adapters/binance/common/{parsing => schemas}/__init__.py (100%) create mode 100644 nautilus_trader/adapters/binance/common/schemas/account.py create mode 100644 nautilus_trader/adapters/binance/common/schemas/market.py create mode 100644 nautilus_trader/adapters/binance/common/schemas/symbol.py rename nautilus_trader/adapters/binance/{http/enums.py => common/schemas/user.py} (72%) delete mode 100644 nautilus_trader/adapters/binance/futures/parsing/__init__.py delete mode 100644 nautilus_trader/adapters/binance/futures/parsing/account.py delete mode 100644 nautilus_trader/adapters/binance/futures/parsing/data.py delete mode 100644 nautilus_trader/adapters/binance/futures/parsing/execution.py delete mode 100644 nautilus_trader/adapters/binance/futures/rules.py rename nautilus_trader/adapters/binance/{spot/rules.py => futures/schemas/wallet.py} (68%) create mode 100644 nautilus_trader/adapters/binance/http/account.py create mode 100644 nautilus_trader/adapters/binance/http/endpoint.py create mode 100644 nautilus_trader/adapters/binance/http/market.py create mode 100644 nautilus_trader/adapters/binance/http/user.py delete mode 100644 nautilus_trader/adapters/binance/spot/parsing/__init__.py delete mode 100644 nautilus_trader/adapters/binance/spot/parsing/account.py delete mode 100644 nautilus_trader/adapters/binance/spot/parsing/data.py delete mode 100644 nautilus_trader/adapters/binance/spot/parsing/execution.py diff --git a/docs/integrations/binance.md b/docs/integrations/binance.md index 760699ec432e..2e8c9494760d 100644 --- a/docs/integrations/binance.md +++ b/docs/integrations/binance.md @@ -1,7 +1,7 @@ # Binance -Founded in 2017, Binance is one of the largest cryptocurrency exchanges in terms -of daily trading volume, and open interest of crypto assets and crypto +Founded in 2017, Binance is one of the largest cryptocurrency exchanges in terms +of daily trading volume, and open interest of crypto assets and crypto derivative products. This integration supports live market data ingest and order execution with Binance. @@ -11,20 +11,20 @@ unstable beta phase and exercise caution. ``` ## Overview -The following documentation assumes a trader is setting up for both live market -data feeds, and trade execution. The full Binance integration consists of an assortment of components, +The following documentation assumes a trader is setting up for both live market +data feeds, and trade execution. The full Binance integration consists of an assortment of components, which can be used together or separately depending on the users needs. - `BinanceHttpClient` provides low-level HTTP API connectivity - `BinanceWebSocketClient` provides low-level WebSocket API connectivity - `BinanceInstrumentProvider` provides instrument parsing and loading functionality -- `BinanceDataClient` provides a market data feed manager -- `BinanceExecutionClient` provides an account management and trade execution gateway +- `BinanceSpotDataClient`/ `BinanceFuturesDataClient` provide a market data feed manager +- `BinanceSpotExecutionClient`/`BinanceFuturesExecutionClient` provide an account management and trade execution gateway - `BinanceLiveDataClientFactory` creation factory for Binance data clients (used by the trading node builder) - `BinanceLiveExecClientFactory` creation factory for Binance execution clients (used by the trading node builder) ```{note} -Most users will simply define a configuration for a live trading node (as below), +Most users will simply define a configuration for a live trading node (as below), and won't need to necessarily work with these lower level components individually. ``` @@ -70,7 +70,7 @@ You must also have at least *one* of the following: - You have subscribed to trade ticks for the instrument you're submitting the order for (used to infer activation price) ## Configuration -The most common use case is to configure a live `TradingNode` to include Binance +The most common use case is to configure a live `TradingNode` to include Binance data and execution clients. To achieve this, add a `BINANCE` section to your client configuration(s): @@ -117,9 +117,9 @@ node.build() ### API credentials There are two options for supplying your credentials to the Binance clients. Either pass the corresponding `api_key` and `api_secret` values to the configuration objects, or -set the following environment variables: +set the following environment variables: -For Binance Spot/Margin live clients, you can set: +For Binance Spot/Margin live clients, you can set: - `BINANCE_API_KEY` - `BINANCE_API_SECRET` @@ -142,13 +142,14 @@ credentials are valid and have trading permissions. All the Binance account types will be supported for live trading. Set the `account_type` using the `BinanceAccountType` enum. The account type options are: - `SPOT` -- `MARGIN` +- `MARGIN_CROSS` (Margin shared between open positions.) +- `MARGIN_ISOLATED` (Margin assigned to a single position.) - `FUTURES_USDT` (USDT or BUSD stablecoins as collateral) - `FUTURES_COIN` (other cryptocurrency as collateral) ### Base URL overrides It's possible to override the default base URLs for both HTTP Rest and -WebSocket APIs. This is useful for configuring API clusters for performance reasons, +WebSocket APIs. This is useful for configuring API clusters for performance reasons, or when Binance has provided you with specialized endpoints. ### Binance US @@ -183,9 +184,9 @@ config = TradingNodeConfig( ``` ### Parser warnings -Some Binance instruments are unable to be parsed into Nautilus objects if they -contain enormous field values beyond what can be handled by the platform. -In these cases, a _warn and continue_ approach is taken (the instrument will not +Some Binance instruments are unable to be parsed into Nautilus objects if they +contain enormous field values beyond what can be handled by the platform. +In these cases, a _warn and continue_ approach is taken (the instrument will not be available). These warnings may cause unnecessary log noise, and so it's possible to @@ -194,7 +195,7 @@ example below: ```python instrument_provider=InstrumentProviderConfig( - load_all=True, + load_all=True, log_warnings=False, ) ``` @@ -210,7 +211,7 @@ methods may eventually become first-class (not requiring custom/generic subscrip ``` ### BinanceFuturesMarkPriceUpdate -You can subscribe to `BinanceFuturesMarkPriceUpdate` (included funding rating info) +You can subscribe to `BinanceFuturesMarkPriceUpdate` (included funding rating info) data streams by subscribing in the following way from your actor or strategy: ```python @@ -221,8 +222,8 @@ self.subscribe_data( ) ``` -This will result in your actor/strategy passing these received `BinanceFuturesMarkPriceUpdate` -objects to your `on_data` method. You will need to check the type, as this +This will result in your actor/strategy passing these received `BinanceFuturesMarkPriceUpdate` +objects to your `on_data` method. You will need to check the type, as this method acts as a flexible handler for all custom/generic data. ```python diff --git a/nautilus_trader/adapters/binance/common/data.py b/nautilus_trader/adapters/binance/common/data.py new file mode 100644 index 000000000000..0e6a376f45aa --- /dev/null +++ b/nautilus_trader/adapters/binance/common/data.py @@ -0,0 +1,632 @@ +# ------------------------------------------------------------------------------------------------- +# Copyright (C) 2015-2023 Nautech Systems Pty Ltd. All rights reserved. +# https://nautechsystems.io +# +# Licensed under the GNU Lesser General Public License Version 3.0 (the "License"); +# You may not use this file except in compliance with the License. +# You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ------------------------------------------------------------------------------------------------- + +import asyncio +from typing import Optional + +import msgspec +import pandas as pd + +from nautilus_trader.adapters.binance.common.constants import BINANCE_VENUE +from nautilus_trader.adapters.binance.common.enums import BinanceAccountType +from nautilus_trader.adapters.binance.common.enums import BinanceEnumParser +from nautilus_trader.adapters.binance.common.enums import BinanceKlineInterval +from nautilus_trader.adapters.binance.common.schemas.market import BinanceCandlestickMsg +from nautilus_trader.adapters.binance.common.schemas.market import BinanceDataMsgWrapper +from nautilus_trader.adapters.binance.common.schemas.market import BinanceOrderBookMsg +from nautilus_trader.adapters.binance.common.schemas.market import BinanceQuoteMsg +from nautilus_trader.adapters.binance.common.schemas.market import BinanceTickerMsg +from nautilus_trader.adapters.binance.common.schemas.symbol import BinanceSymbol +from nautilus_trader.adapters.binance.common.types import BinanceBar +from nautilus_trader.adapters.binance.common.types import BinanceTicker +from nautilus_trader.adapters.binance.http.client import BinanceHttpClient +from nautilus_trader.adapters.binance.http.market import BinanceMarketHttpAPI +from nautilus_trader.adapters.binance.websocket.client import BinanceWebSocketClient +from nautilus_trader.cache.cache import Cache +from nautilus_trader.common.clock import LiveClock +from nautilus_trader.common.enums import LogColor +from nautilus_trader.common.logging import Logger +from nautilus_trader.common.providers import InstrumentProvider +from nautilus_trader.core.correctness import PyCondition +from nautilus_trader.core.datetime import secs_to_millis +from nautilus_trader.core.uuid import UUID4 +from nautilus_trader.live.data_client import LiveMarketDataClient +from nautilus_trader.model.data.bar import BarType +from nautilus_trader.model.data.base import DataType +from nautilus_trader.model.data.tick import QuoteTick +from nautilus_trader.model.enums import BookType +from nautilus_trader.model.enums import PriceType +from nautilus_trader.model.identifiers import ClientId +from nautilus_trader.model.identifiers import InstrumentId +from nautilus_trader.model.identifiers import Symbol +from nautilus_trader.model.instruments.base import Instrument +from nautilus_trader.model.orderbook.data import OrderBookData +from nautilus_trader.model.orderbook.data import OrderBookDeltas +from nautilus_trader.model.orderbook.data import OrderBookSnapshot +from nautilus_trader.msgbus.bus import MessageBus + + +class BinanceCommonDataClient(LiveMarketDataClient): + """ + Provides a data client of common methods for the `Binance` exchange. + + Parameters + ---------- + loop : asyncio.AbstractEventLoop + The event loop for the client. + client : BinanceHttpClient + The binance HTTP client. + market : BinanceMarketHttpAPI + The binance Market HTTP API. + enum_parser : BinanceEnumParser + The parser for Binance enums. + msgbus : MessageBus + The message bus for the client. + cache : Cache + The cache for the client. + clock : LiveClock + The clock for the client. + logger : Logger + The logger for the client. + instrument_provider : InstrumentProvider + The instrument provider. + account_type : BinanceAccountType + The account type for the client. + base_url_ws : str, optional + The base URL for the WebSocket client. + + Warnings + -------- + This class should not be used directly, but through a concrete subclass. + """ + + def __init__( + self, + loop: asyncio.AbstractEventLoop, + client: BinanceHttpClient, + market: BinanceMarketHttpAPI, + enum_parser: BinanceEnumParser, + msgbus: MessageBus, + cache: Cache, + clock: LiveClock, + logger: Logger, + instrument_provider: InstrumentProvider, + account_type: BinanceAccountType, + base_url_ws: Optional[str] = None, + ): + super().__init__( + loop=loop, + client_id=ClientId(BINANCE_VENUE.value), + venue=BINANCE_VENUE, + instrument_provider=instrument_provider, + msgbus=msgbus, + cache=cache, + clock=clock, + logger=logger, + ) + + if account_type not in BinanceAccountType: + raise RuntimeError( # pragma: no cover (design-time error) + f"invalid `BinanceAccountType`, was {account_type}", # pragma: no cover + ) + + self._binance_account_type = account_type + self._log.info(f"Account type: {self._binance_account_type.value}.", LogColor.BLUE) + + self._update_instrument_interval: int = 60 * 60 # Once per hour (hardcode) + self._update_instruments_task: Optional[asyncio.Task] = None + + self._connect_websockets_interval: int = 4 # Retry websocket connection every 4 seconds + self._connect_websockets_task: Optional[asyncio.Task] = None + + # HTTP API + self._http_client = client + self._http_market = market + + # Enum parser + self._enum_parser = enum_parser + + # WebSocket API + self._ws_client = BinanceWebSocketClient( + loop=loop, + clock=clock, + logger=logger, + handler=self._handle_ws_message, + base_url=base_url_ws, + ) + + # Hot caches + self._instrument_ids: dict[str, InstrumentId] = {} + self._book_buffer: dict[InstrumentId, list[OrderBookData]] = {} + + self._log.info(f"Base URL HTTP {self._http_client.base_url}.", LogColor.BLUE) + self._log.info(f"Base URL WebSocket {base_url_ws}.", LogColor.BLUE) + + # Register common websocket message handlers + self._ws_handlers = { + "@bookTicker": self._handle_book_ticker, + "@ticker": self._handle_ticker, + "@kline": self._handle_kline, + "@trade": self._handle_trade, + "@depth@": self._handle_book_diff_update, + "@depth5": self._handle_book_partial_update, + "@depth10": self._handle_book_partial_update, + "@depth20": self._handle_book_partial_update, + } + + # Websocket msgspec decoders + self._decoder_data_msg_wrapper = msgspec.json.Decoder(BinanceDataMsgWrapper) + self._decoder_order_book_msg = msgspec.json.Decoder(BinanceOrderBookMsg) + self._decoder_quote_msg = msgspec.json.Decoder(BinanceQuoteMsg) + self._decoder_ticker_msg = msgspec.json.Decoder(BinanceTickerMsg) + self._decoder_candlestick_msg = msgspec.json.Decoder(BinanceCandlestickMsg) + + async def _connect(self) -> None: + # Connect HTTP client + self._log.info("Connecting client...") + if not self._http_client.connected: + await self._http_client.connect() + + self._log.info("Initialising instruments...") + await self._instrument_provider.initialize() + + self._log.info("Connected!") + self._send_all_instruments_to_data_engine() + self._update_instruments_task = self.create_task(self._update_instruments()) + + # Connect WebSocket clients + self._connect_websockets_task = self.create_task(self._connect_websockets()) + + async def _connect_websockets(self) -> None: + try: + while not self._ws_client.is_connected: + self._log.debug( + f"Scheduled `connect_websockets` to run in " + f"{self._connect_websockets_interval}s.", + ) + await asyncio.sleep(self._connect_websockets_interval) + if self._ws_client.has_subscriptions: + await self._ws_client.connect() + else: + self._log.info("Awaiting subscriptions...") + except asyncio.CancelledError: + self._log.debug("`connect_websockets` task was canceled.") + + async def _update_instruments(self) -> None: + try: + while True: + self._log.debug( + f"Scheduled `update_instruments` to run in " + f"{self._update_instrument_interval}s.", + ) + await asyncio.sleep(self._update_instrument_interval) + await self._instrument_provider.load_all_async() + self._send_all_instruments_to_data_engine() + except asyncio.CancelledError: + self._log.debug("`update_instruments` task was canceled.") + + async def _disconnect(self) -> None: + # Cancel update instruments task + if self._update_instruments_task: + self._log.debug("Canceling `update_instruments` task...") + self._update_instruments_task.cancel() + self._update_instruments_task.done() + + # Cancel WebSocket connect task + if self._connect_websockets_task: + self._log.debug("Canceling `connect_websockets` task...") + self._connect_websockets_task.cancel() + self._connect_websockets_task.done() + # Disconnect WebSocket client + if self._ws_client.is_connected: + await self._ws_client.disconnect() + + # Disconnect HTTP client + if self._http_client.connected: + await self._http_client.disconnect() + + # -- SUBSCRIPTIONS ---------------------------------------------------------------------------- + + async def _subscribe(self, data_type: DataType) -> None: + # Replace method in child class, for exchange specific data types. + raise NotImplementedError("Cannot subscribe to {data_type.type} (not implemented).") + + async def _subscribe_instruments(self) -> None: + pass # Do nothing further + + async def _subscribe_instrument(self, instrument_id: InstrumentId) -> None: + pass # Do nothing further + + async def _subscribe_order_book_deltas( + self, + instrument_id: InstrumentId, + book_type: BookType, + depth: Optional[int] = None, + kwargs: Optional[dict] = None, + ) -> None: + update_speed = None + if kwargs is not None: + update_speed = kwargs.get("update_speed") + await self._subscribe_order_book( + instrument_id=instrument_id, + book_type=book_type, + update_speed=update_speed, + depth=depth, + ) + + async def _subscribe_order_book_snapshots( + self, + instrument_id: InstrumentId, + book_type: BookType, + depth: Optional[int] = None, + kwargs: Optional[dict] = None, + ) -> None: + update_speed = None + if kwargs is not None: + update_speed = kwargs.get("update_speed") + await self._subscribe_order_book( + instrument_id=instrument_id, + book_type=book_type, + update_speed=update_speed, + depth=depth, + ) + + async def _subscribe_order_book( # noqa (too complex) + self, + instrument_id: InstrumentId, + book_type: BookType, + update_speed: Optional[int] = None, + depth: Optional[int] = None, + ) -> None: + if book_type == BookType.L3_MBO: + self._log.error( + "Cannot subscribe to order book deltas: " + "L3_MBO data is not published by Binance. " + "Valid book types are L1_TBBO, L2_MBP.", + ) + return + + valid_speeds = [100, 1000] + if self._binance_account_type.is_futures: + if update_speed is None: + update_speed = 0 # default 0 ms for futures. + valid_speeds = [0, 100, 250, 500] # 0ms option for futures exists but not documented? + elif update_speed is None: + update_speed = 100 # default 100ms for spot + if update_speed not in valid_speeds: + self._log.error( + "Cannot subscribe to order book:" + f"invalid `update_speed`, was {update_speed}. " + f"Valid update speeds are {valid_speeds} ms.", + ) + return + + if depth is None: + depth = 0 + + # Add delta stream buffer + self._book_buffer[instrument_id] = [] + + if 0 < depth <= 20: + if depth not in (5, 10, 20): + self._log.error( + "Cannot subscribe to order book snapshots: " + f"invalid `depth`, was {depth}. " + "Valid depths are 5, 10 or 20.", + ) + return + self._ws_client.subscribe_partial_book_depth( + symbol=instrument_id.symbol.value, + depth=depth, + speed=update_speed, + ) + else: + self._ws_client.subscribe_diff_book_depth( + symbol=instrument_id.symbol.value, + speed=update_speed, + ) + + while not self._ws_client.is_connected: + await asyncio.sleep(self._connect_websockets_interval) + + snapshot: OrderBookSnapshot = await self._http_market.request_order_book_snapshot( + instrument_id=instrument_id, + limit=depth, + ts_init=self._clock.timestamp_ns(), + ) + self._handle_data(snapshot) + + book_buffer = self._book_buffer.pop(instrument_id, []) + for deltas in book_buffer: + if deltas.sequence <= snapshot.sequence: + continue + self._handle_data(deltas) + + async def _subscribe_ticker(self, instrument_id: InstrumentId) -> None: + self._ws_client.subscribe_ticker(instrument_id.symbol.value) + + async def _subscribe_quote_ticks(self, instrument_id: InstrumentId) -> None: + self._ws_client.subscribe_book_ticker(instrument_id.symbol.value) + + async def _subscribe_trade_ticks(self, instrument_id: InstrumentId) -> None: + if self._binance_account_type.is_futures: + self._log.warning( + "Trade ticks have been requested from a `Binance Futures` exchange. " + "This functionality is not officially documented or supported.", + ) + self._ws_client.subscribe_trades(instrument_id.symbol.value) + + async def _subscribe_bars(self, bar_type: BarType) -> None: + PyCondition.true(bar_type.is_externally_aggregated(), "aggregation_source is not EXTERNAL") + + if not bar_type.spec.is_time_aggregated(): + self._log.error( + f"Cannot subscribe to {bar_type}: only time bars are aggregated by Binance.", + ) + return + + resolution = self._enum_parser.parse_internal_bar_agg(bar_type.spec.aggregation) + if not self._binance_account_type.is_spot_or_margin and resolution == "s": + self._log.error( + f"Cannot request {bar_type}.", + "Second interval bars are not aggregated by Binance Futures.", + ) + try: + interval = BinanceKlineInterval(f"{bar_type.spec.step}{resolution}") + except ValueError: + self._log.error( + f"Bar interval {bar_type.spec.step}{resolution} not supported by Binance.", + ) + + self._ws_client.subscribe_bars( + symbol=bar_type.instrument_id.symbol.value, + interval=interval.value, + ) + self._add_subscription_bars(bar_type) + + async def _unsubscribe(self, data_type: DataType): + # Replace method in child class, for exchange specific data types. + raise NotImplementedError(f"Cannot unsubscribe from {data_type.type} (not implemented).") + + async def _unsubscribe_instruments(self) -> None: + pass # Do nothing further + + async def _unsubscribe_instrument(self, instrument_id: InstrumentId) -> None: + pass # Do nothing further + + async def _unsubscribe_order_book_deltas(self, instrument_id: InstrumentId) -> None: + pass # TODO: Unsubscribe from Binance if no other subscriptions + + async def _unsubscribe_order_book_snapshots(self, instrument_id: InstrumentId) -> None: + pass # TODO: Unsubscribe from Binance if no other subscriptions + + async def _unsubscribe_ticker(self, instrument_id: InstrumentId) -> None: + pass # TODO: Unsubscribe from Binance if no other subscriptions + + async def _unsubscribe_quote_ticks(self, instrument_id: InstrumentId) -> None: + pass # TODO: Unsubscribe from Binance if no other subscriptions + + async def _unsubscribe_trade_ticks(self, instrument_id: InstrumentId) -> None: + pass # TODO: Unsubscribe from Binance if no other subscriptions + + async def _unsubscribe_bars(self, bar_type: BarType) -> None: + pass # TODO: Unsubscribe from Binance if no other subscriptions + + # -- REQUESTS --------------------------------------------------------------------------------- + + async def _request_instrument(self, instrument_id: InstrumentId, correlation_id: UUID4) -> None: + instrument: Optional[Instrument] = self._instrument_provider.find(instrument_id) + if instrument is None: + self._log.error(f"Cannot find instrument for {instrument_id}.") + return + + data_type = DataType( + type=Instrument, + metadata={"instrument_id": instrument_id}, + ) + + self._handle_data_response( + data_type=data_type, + data=[instrument], # Data engine handles lists of instruments + correlation_id=correlation_id, + ) + + async def _request_quote_ticks( + self, + instrument_id: InstrumentId, # noqa + limit: int, # noqa + correlation_id: UUID4, # noqa + from_datetime: Optional[pd.Timestamp] = None, # noqa + to_datetime: Optional[pd.Timestamp] = None, # noqa + ) -> None: + self._log.error( + "Cannot request historical quote ticks: not published by Binance.", + ) + + async def _request_trade_ticks( + self, + instrument_id: InstrumentId, + limit: int, + correlation_id: UUID4, + from_datetime: Optional[pd.Timestamp] = None, + to_datetime: Optional[pd.Timestamp] = None, + ) -> None: + if limit == 0 or limit > 1000: + limit = 1000 + + if from_datetime is not None or to_datetime is not None: + self._log.warning( + "Trade ticks have been requested with a from/to time range, " + f"however the request will be for the most recent {limit}.", + ) + + ticks = await self._http_market.request_trade_ticks( + instrument_id=instrument_id, + limit=limit, + ts_init=self._clock.timestamp_ns(), + ) + self._handle_trade_ticks(instrument_id, ticks, correlation_id) + + async def _request_bars( # noqa (too complex) + self, + bar_type: BarType, + limit: int, + correlation_id: UUID4, + from_datetime: Optional[pd.Timestamp] = None, + to_datetime: Optional[pd.Timestamp] = None, + ) -> None: + if limit == 0 or limit > 1000: + limit = 1000 + + if bar_type.is_internally_aggregated(): + self._log.error( + f"Cannot request {bar_type}: " + f"only historical bars with EXTERNAL aggregation available from Binance.", + ) + return + + if not bar_type.spec.is_time_aggregated(): + self._log.error( + f"Cannot request {bar_type}: only time bars are aggregated by Binance.", + ) + return + + resolution = self._enum_parser.parse_internal_bar_agg(bar_type.spec.aggregation) + if not self._binance_account_type.is_spot_or_margin and resolution == "s": + self._log.error( + f"Cannot request {bar_type}.", + "Second interval bars are not aggregated by Binance Futures.", + ) + try: + interval = BinanceKlineInterval(f"{bar_type.spec.step}{resolution}") + except ValueError: + self._log.error( + f"Cannot create Binance Kline interval. {bar_type.spec.step}{resolution} " + "not supported.", + ) + + if bar_type.spec.price_type != PriceType.LAST: + self._log.error( + f"Cannot request {bar_type}: " + f"only historical bars for LAST price type available from Binance.", + ) + return + + start_time_ms = None + if from_datetime is not None: + start_time_ms = secs_to_millis(from_datetime.timestamp()) + + end_time_ms = None + if to_datetime is not None: + end_time_ms = secs_to_millis(to_datetime.timestamp()) + + bars = await self._http_market.request_binance_bars( + bar_type=bar_type, + interval=interval, + start_time=start_time_ms, + end_time=end_time_ms, + limit=limit, + ts_init=self._clock.timestamp_ns(), + ) + + partial: BinanceBar = bars.pop() + self._handle_bars(bar_type, bars, partial, correlation_id) + + def _send_all_instruments_to_data_engine(self) -> None: + for instrument in self._instrument_provider.get_all().values(): + self._handle_data(instrument) + + for currency in self._instrument_provider.currencies().values(): + self._cache.add_currency(currency) + + def _get_cached_instrument_id(self, symbol: str) -> InstrumentId: + # Parse instrument ID + nautilus_symbol: str = BinanceSymbol(symbol).parse_binance_to_internal( + self._binance_account_type, + ) + instrument_id: Optional[InstrumentId] = self._instrument_ids.get(nautilus_symbol) + if not instrument_id: + instrument_id = InstrumentId(Symbol(nautilus_symbol), BINANCE_VENUE) + self._instrument_ids[nautilus_symbol] = instrument_id + return instrument_id + + # -- WEBSOCKET HANDLERS --------------------------------------------------------------------------------- + + def _handle_ws_message(self, raw: bytes) -> None: + # TODO(cs): Uncomment for development + # self._log.info(str(raw), LogColor.CYAN) + + wrapper = self._decoder_data_msg_wrapper.decode(raw) + try: + handled = False + for handler in self._ws_handlers: + if handler in wrapper.stream: + self._ws_handlers[handler](raw) + handled = True + if not handled: + self._log.error( + f"Unrecognized websocket message type: {wrapper.stream}", + ) + except Exception as e: + self._log.error(f"Error handling websocket message, {e}") + + def _handle_book_diff_update(self, raw: bytes) -> None: + msg = self._decoder_order_book_msg.decode(raw) + instrument_id: InstrumentId = self._get_cached_instrument_id(msg.data.s) + book_deltas: OrderBookDeltas = msg.data.parse_to_order_book_deltas( + instrument_id=instrument_id, + ts_init=self._clock.timestamp_ns(), + ) + book_buffer: Optional[list[OrderBookData]] = self._book_buffer.get(instrument_id) + if book_buffer is not None: + book_buffer.append(book_deltas) + else: + self._handle_data(book_deltas) + + def _handle_book_ticker(self, raw: bytes) -> None: + msg = self._decoder_quote_msg.decode(raw) + instrument_id: InstrumentId = self._get_cached_instrument_id(msg.data.s) + quote_tick: QuoteTick = msg.data.parse_to_quote_tick( + instrument_id=instrument_id, + ts_init=self._clock.timestamp_ns(), + ) + self._handle_data(quote_tick) + + def _handle_ticker(self, raw: bytes) -> None: + msg = self._decoder_ticker_msg.decode(raw) + instrument_id: InstrumentId = self._get_cached_instrument_id(msg.data.s) + ticker: BinanceTicker = msg.data.parse_to_binance_ticker( + instrument_id=instrument_id, + ts_init=self._clock.timestamp_ns(), + ) + self._handle_data(ticker) + + def _handle_kline(self, raw: bytes) -> None: + msg = self._decoder_candlestick_msg.decode(raw) + if not msg.data.k.x: + return # Not closed yet + instrument_id = self._get_cached_instrument_id(msg.data.s) + bar: BinanceBar = msg.data.k.parse_to_binance_bar( + instrument_id=instrument_id, + enum_parser=self._enum_parser, + ts_init=self._clock.timestamp_ns(), + ) + self._handle_data(bar) + + def _handle_book_partial_update(self, raw: bytes) -> None: + raise NotImplementedError("Please implement book partial update handling in child class.") + + def _handle_trade(self, raw: bytes) -> None: + raise NotImplementedError("Please implement trade handling in child class.") diff --git a/nautilus_trader/adapters/binance/common/enums.py b/nautilus_trader/adapters/binance/common/enums.py index e386b1150a4c..efc22caacdf2 100644 --- a/nautilus_trader/adapters/binance/common/enums.py +++ b/nautilus_trader/adapters/binance/common/enums.py @@ -16,6 +16,17 @@ from enum import Enum from enum import unique +from nautilus_trader.model.data.bar import BarSpecification +from nautilus_trader.model.enums import BarAggregation +from nautilus_trader.model.enums import OrderSide +from nautilus_trader.model.enums import OrderStatus +from nautilus_trader.model.enums import OrderType +from nautilus_trader.model.enums import PriceType +from nautilus_trader.model.enums import TimeInForce +from nautilus_trader.model.enums import TriggerType +from nautilus_trader.model.enums import bar_aggregation_to_str +from nautilus_trader.model.orders.base import Order + """ Defines `Binance` common enums. @@ -46,6 +57,28 @@ class BinanceRateLimitInterval(Enum): DAY = "DAY" +@unique +class BinanceKlineInterval(Enum): + """Represents a `Binance` kline chart interval.""" + + SECOND_1 = "1s" + MINUTE_1 = "1m" + MINUTE_3 = "3m" + MINUTE_5 = "5m" + MINUTE_15 = "15m" + MINUTE_30 = "30m" + HOUR_1 = "1h" + HOUR_2 = "2h" + HOUR_4 = "4h" + HOUR_6 = "6h" + HOUR_8 = "8h" + HOUR_12 = "12h" + DAY_1 = "1d" + DAY_3 = "3d" + WEEK_1 = "1w" + MONTH_1 = "1M" + + @unique class BinanceExchangeFilterType(Enum): """Represents a `Binance` exchange filter type.""" @@ -77,7 +110,8 @@ class BinanceAccountType(Enum): """Represents a `Binance` account type.""" SPOT = "SPOT" - MARGIN = "MARGIN" + MARGIN_CROSS = "MARGIN_CROSS" + MARGIN_ISOLATED = "MARGIN_ISOLATED" FUTURES_USDT = "FUTURES_USDT" FUTURES_COIN = "FUTURES_COIN" @@ -87,11 +121,25 @@ def is_spot(self): @property def is_margin(self): - return self == BinanceAccountType.MARGIN + return self in ( + BinanceAccountType.MARGIN_CROSS, + BinanceAccountType.MARGIN_ISOLATED, + ) + + @property + def is_spot_or_margin(self): + return self in ( + BinanceAccountType.SPOT, + BinanceAccountType.MARGIN_CROSS, + BinanceAccountType.MARGIN_ISOLATED, + ) @property def is_futures(self) -> bool: - return self in (BinanceAccountType.FUTURES_USDT, BinanceAccountType.FUTURES_COIN) + return self in ( + BinanceAccountType.FUTURES_USDT, + BinanceAccountType.FUTURES_COIN, + ) @unique @@ -127,3 +175,197 @@ class BinanceOrderStatus(Enum): EXPIRED = "EXPIRED" NEW_INSURANCE = "NEW_INSURANCE" # Liquidation with Insurance Fund NEW_ADL = "NEW_ADL" # Counterparty Liquidation + + +@unique +class BinanceTimeInForce(Enum): + """Represents a `Binance` order time in force.""" + + GTC = "GTC" + IOC = "IOC" + FOK = "FOK" + GTX = "GTX" # FUTURES only, Good Till Crossing (Post Only) + + +@unique +class BinanceOrderType(Enum): + """Represents a `Binance` order type.""" + + LIMIT = "LIMIT" + MARKET = "MARKET" + STOP = "STOP" # FUTURES only + STOP_LOSS = "STOP_LOSS" # SPOT/MARGIN only + STOP_LOSS_LIMIT = "STOP_LOSS_LIMIT" # SPOT/MARGIN only + TAKE_PROFIT = "TAKE_PROFIT" + TAKE_PROFIT_LIMIT = "TAKE_PROFIT_LIMIT" # SPOT/MARGIN only + LIMIT_MAKER = "LIMIT_MAKER" # SPOT/MARGIN only + STOP_MARKET = "STOP_MARKET" # FUTURES only + TAKE_PROFIT_MARKET = "TAKE_PROFIT_MARKET" # FUTURES only + TRAILING_STOP_MARKET = "TRAILING_STOP_MARKET" # FUTURES only + + +@unique +class BinanceSecurityType(Enum): + """Represents a `Binance` endpoint security type.""" + + NONE = "NONE" + TRADE = "TRADE" + MARGIN = "MARGIN" # SPOT/MARGIN only + USER_DATA = "USER_DATA" + USER_STREAM = "USER_STREAM" + MARKET_DATA = "MARKET_DATA" + + +@unique +class BinanceMethodType(Enum): + """Represents a `Binance` endpoint method type.""" + + GET = "GET" + POST = "POST" + PUT = "PUT" + DELETE = "DELETE" + + +@unique +class BinanceNewOrderRespType(Enum): + """ + Represents a `Binance` newOrderRespType. + """ + + ACK = "ACK" + RESULT = "RESULT" + FULL = "FULL" + + +class BinanceEnumParser: + """ + Provides common parsing methods for enums used by the 'Binance' exchange. + + Warnings: + -------- + This class should not be used directly, but through a concrete subclass. + """ + + def __init__(self) -> None: + # Construct dictionary hashmaps + self.ext_to_int_status = { + BinanceOrderStatus.NEW: OrderStatus.ACCEPTED, + BinanceOrderStatus.CANCELED: OrderStatus.CANCELED, + BinanceOrderStatus.PARTIALLY_FILLED: OrderStatus.PARTIALLY_FILLED, + BinanceOrderStatus.FILLED: OrderStatus.FILLED, + BinanceOrderStatus.NEW_ADL: OrderStatus.FILLED, + BinanceOrderStatus.NEW_INSURANCE: OrderStatus.FILLED, + BinanceOrderStatus.EXPIRED: OrderStatus.EXPIRED, + } + + self.ext_to_int_order_side = { + BinanceOrderSide.BUY: OrderSide.BUY, + BinanceOrderSide.SELL: OrderSide.SELL, + } + self.int_to_ext_order_side = {b: a for a, b in self.ext_to_int_order_side.items()} + + self.ext_to_int_bar_agg = { + "s": BarAggregation.SECOND, + "m": BarAggregation.MINUTE, + "h": BarAggregation.HOUR, + "d": BarAggregation.DAY, + "w": BarAggregation.WEEK, + "M": BarAggregation.MONTH, + } + self.int_to_ext_bar_agg = {b: a for a, b in self.ext_to_int_bar_agg.items()} + + self.ext_to_int_time_in_force = { + BinanceTimeInForce.FOK: TimeInForce.FOK, + BinanceTimeInForce.GTC: TimeInForce.GTC, + BinanceTimeInForce.GTX: TimeInForce.GTC, # Convert GTX to GTC + BinanceTimeInForce.IOC: TimeInForce.IOC, + } + self.int_to_ext_time_in_force = { + TimeInForce.GTC: BinanceTimeInForce.GTC, + TimeInForce.GTD: BinanceTimeInForce.GTC, # Convert GTD to GTC + TimeInForce.FOK: BinanceTimeInForce.FOK, + TimeInForce.IOC: BinanceTimeInForce.IOC, + } + + def parse_binance_order_side(self, order_side: BinanceOrderSide) -> OrderSide: + try: + return self.ext_to_int_order_side[order_side] + except KeyError: + raise RuntimeError( # pragma: no cover (design-time error) + f"unrecognized binance order side, was {order_side}", # pragma: no cover + ) + + def parse_internal_order_side(self, order_side: OrderSide) -> BinanceOrderSide: + try: + return self.int_to_ext_order_side[order_side] + except KeyError: + raise RuntimeError( # pragma: no cover (design-time error) + f"unrecognized internal order side, was {order_side}", # pragma: no cover + ) + + def parse_binance_time_in_force(self, time_in_force: BinanceTimeInForce) -> TimeInForce: + try: + return self.ext_to_int_time_in_force[time_in_force] + except KeyError: + raise RuntimeError( # pragma: no cover (design-time error) + f"unrecognized binance time in force, was {time_in_force}", # pragma: no cover + ) + + def parse_internal_time_in_force(self, time_in_force: TimeInForce) -> BinanceTimeInForce: + try: + return self.int_to_ext_time_in_force[time_in_force] + except KeyError: + raise RuntimeError( # pragma: no cover (design-time error) + f"unrecognized internal time in force, was {time_in_force}", # pragma: no cover + ) + + def parse_binance_order_status(self, order_status: BinanceOrderStatus) -> OrderStatus: + try: + return self.ext_to_int_status[order_status] + except KeyError: + raise RuntimeError( # pragma: no cover (design-time error) + f"unrecognized binance order status, was {order_status}", # pragma: no cover + ) + + def parse_binance_order_type(self, order_type: BinanceOrderType) -> OrderType: + # Implement in child class + raise NotImplementedError + + def parse_internal_order_type(self, order: Order) -> BinanceOrderType: + # Implement in child class + raise NotImplementedError + + def parse_binance_bar_agg(self, bar_agg: str) -> BarAggregation: + try: + return self.ext_to_int_bar_agg[bar_agg] + except KeyError: + raise RuntimeError( # pragma: no cover (design-time error) + f"unrecognized binance kline resolution, was {bar_agg}", + ) + + def parse_internal_bar_agg(self, bar_agg: BarAggregation) -> str: + try: + return self.int_to_ext_bar_agg[bar_agg] + except KeyError: + raise RuntimeError( # pragma: no cover (design-time error) + "unrecognized or non-supported BarAggregation,", + f"was {bar_aggregation_to_str(bar_agg)}", # pragma: no cover + ) + + def parse_binance_kline_interval_to_bar_spec( + self, + kline_interval: BinanceKlineInterval, + ) -> BarSpecification: + step = kline_interval.value[:-1] + binance_bar_agg = kline_interval.value[-1] + return BarSpecification( + step=int(step), + aggregation=self.parse_binance_bar_agg(binance_bar_agg), + price_type=PriceType.LAST, + ) + + def parse_binance_trigger_type(self, trigger_type: str) -> TriggerType: + # Replace method in child class, if compatible + raise NotImplementedError( # pragma: no cover (design-time error) + "Cannot parse binance trigger type (not implemented).", # pragma: no cover + ) diff --git a/nautilus_trader/adapters/binance/common/execution.py b/nautilus_trader/adapters/binance/common/execution.py new file mode 100644 index 000000000000..72755495d4e9 --- /dev/null +++ b/nautilus_trader/adapters/binance/common/execution.py @@ -0,0 +1,787 @@ +# ------------------------------------------------------------------------------------------------- +# Copyright (C) 2015-2023 Nautech Systems Pty Ltd. All rights reserved. +# https://nautechsystems.io +# +# Licensed under the GNU Lesser General Public License Version 3.0 (the "License"); +# You may not use this file except in compliance with the License. +# You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ------------------------------------------------------------------------------------------------- + +import asyncio +from typing import Optional + +import pandas as pd + +from nautilus_trader.adapters.binance.common.constants import BINANCE_VENUE +from nautilus_trader.adapters.binance.common.enums import BinanceAccountType +from nautilus_trader.adapters.binance.common.enums import BinanceEnumParser +from nautilus_trader.adapters.binance.common.enums import BinanceTimeInForce +from nautilus_trader.adapters.binance.common.schemas.account import BinanceOrder +from nautilus_trader.adapters.binance.common.schemas.account import BinanceUserTrade +from nautilus_trader.adapters.binance.common.schemas.symbol import BinanceSymbol +from nautilus_trader.adapters.binance.common.schemas.user import BinanceListenKey +from nautilus_trader.adapters.binance.http.account import BinanceAccountHttpAPI +from nautilus_trader.adapters.binance.http.client import BinanceHttpClient +from nautilus_trader.adapters.binance.http.error import BinanceError +from nautilus_trader.adapters.binance.http.market import BinanceMarketHttpAPI +from nautilus_trader.adapters.binance.http.user import BinanceUserDataHttpAPI +from nautilus_trader.adapters.binance.websocket.client import BinanceWebSocketClient +from nautilus_trader.cache.cache import Cache +from nautilus_trader.common.clock import LiveClock +from nautilus_trader.common.enums import LogColor +from nautilus_trader.common.logging import Logger +from nautilus_trader.common.providers import InstrumentProvider +from nautilus_trader.core.correctness import PyCondition +from nautilus_trader.core.datetime import secs_to_millis +from nautilus_trader.core.uuid import UUID4 +from nautilus_trader.execution.messages import CancelAllOrders +from nautilus_trader.execution.messages import CancelOrder +from nautilus_trader.execution.messages import ModifyOrder +from nautilus_trader.execution.messages import SubmitOrder +from nautilus_trader.execution.messages import SubmitOrderList +from nautilus_trader.execution.reports import OrderStatusReport +from nautilus_trader.execution.reports import PositionStatusReport +from nautilus_trader.execution.reports import TradeReport +from nautilus_trader.live.execution_client import LiveExecutionClient +from nautilus_trader.model.enums import AccountType +from nautilus_trader.model.enums import OmsType +from nautilus_trader.model.enums import OrderSide +from nautilus_trader.model.enums import OrderType +from nautilus_trader.model.enums import TimeInForce +from nautilus_trader.model.enums import TrailingOffsetType +from nautilus_trader.model.enums import TriggerType +from nautilus_trader.model.enums import trailing_offset_type_to_str +from nautilus_trader.model.enums import trigger_type_to_str +from nautilus_trader.model.identifiers import AccountId +from nautilus_trader.model.identifiers import ClientId +from nautilus_trader.model.identifiers import ClientOrderId +from nautilus_trader.model.identifiers import InstrumentId +from nautilus_trader.model.identifiers import Symbol +from nautilus_trader.model.identifiers import VenueOrderId +from nautilus_trader.model.objects import Price +from nautilus_trader.model.orders.base import Order +from nautilus_trader.model.orders.limit import LimitOrder +from nautilus_trader.model.orders.market import MarketOrder +from nautilus_trader.model.orders.stop_limit import StopLimitOrder +from nautilus_trader.model.orders.stop_market import StopMarketOrder +from nautilus_trader.model.orders.trailing_stop_market import TrailingStopMarketOrder +from nautilus_trader.model.position import Position +from nautilus_trader.msgbus.bus import MessageBus + + +class BinanceCommonExecutionClient(LiveExecutionClient): + """ + Execution client providing common functionality for the `Binance` exchanges. + + Parameters + ---------- + loop : asyncio.AbstractEventLoop + The event loop for the client. + client : BinanceHttpClient + The binance HTTP client. + account : BinanceAccountHttpAPI + The binance Account HTTP API. + market : BinanceMarketHttpAPI + The binance Market HTTP API. + user : BinanceUserHttpAPI + The binance User HTTP API. + enum_parser : BinanceEnumParser + The parser for Binance enums. + msgbus : MessageBus + The message bus for the client. + cache : Cache + The cache for the client. + clock : LiveClock + The clock for the client. + logger : Logger + The logger for the client. + instrument_provider : BinanceSpotInstrumentProvider + The instrument provider. + account_type : BinanceAccountType + The account type for the client. + base_url_ws : str, optional + The base URL for the WebSocket client. + clock_sync_interval_secs : int, default 900 + The interval (seconds) between syncing the Nautilus clock with the Binance server(s) clock. + If zero, then will *not* perform syncing. + warn_gtd_to_gtc : bool, default True + If log warning for GTD time in force transformed to GTC. + + Warnings + -------- + This class should not be used directly, but through a concrete subclass. + """ + + def __init__( + self, + loop: asyncio.AbstractEventLoop, + client: BinanceHttpClient, + account: BinanceAccountHttpAPI, + market: BinanceMarketHttpAPI, + user: BinanceUserDataHttpAPI, + enum_parser: BinanceEnumParser, + msgbus: MessageBus, + cache: Cache, + clock: LiveClock, + logger: Logger, + instrument_provider: InstrumentProvider, + account_type: BinanceAccountType = BinanceAccountType.FUTURES_USDT, + base_url_ws: Optional[str] = None, + clock_sync_interval_secs: int = 900, + warn_gtd_to_gtc: bool = True, + ): + super().__init__( + loop=loop, + client_id=ClientId(BINANCE_VENUE.value), + venue=BINANCE_VENUE, + oms_type=OmsType.HEDGING, + instrument_provider=instrument_provider, + account_type=AccountType.MARGIN, + base_currency=None, + msgbus=msgbus, + cache=cache, + clock=clock, + logger=logger, + ) + + self._binance_account_type = account_type + self._warn_gtd_to_gtc = warn_gtd_to_gtc + self._log.info(f"Account type: {self._binance_account_type.value}.", LogColor.BLUE) + + self._set_account_id(AccountId(f"{BINANCE_VENUE.value}-spot-master")) + + # Clock sync + self._clock_sync_interval_secs = clock_sync_interval_secs + + # Tasks + self._task_clock_sync: Optional[asyncio.Task] = None + + # Enum parser + self._enum_parser = enum_parser + + # Http API + self._http_client = client + self._http_account = account + self._http_market = market + self._http_user = user + + # Listen keys + self._ping_listen_keys_interval: int = 60 * 5 # Once every 5 mins (hardcode) + self._ping_listen_keys_task: Optional[asyncio.Task] = None + self._listen_key: Optional[str] = None + + # WebSocket API + self._ws_client = BinanceWebSocketClient( + loop=loop, + clock=clock, + logger=logger, + handler=self._handle_user_ws_message, + base_url=base_url_ws, + ) + + # Hot caches + self._instrument_ids: dict[str, InstrumentId] = {} + + # Order submission method hashmap + self._submit_order_method = { + OrderType.MARKET: self._submit_market_order, + OrderType.LIMIT: self._submit_limit_order, + OrderType.STOP_LIMIT: self._submit_stop_limit_order, + OrderType.LIMIT_IF_TOUCHED: self._submit_stop_limit_order, + OrderType.STOP_MARKET: self._submit_stop_market_order, + OrderType.MARKET_IF_TOUCHED: self._submit_stop_market_order, + OrderType.TRAILING_STOP_MARKET: self._submit_trailing_stop_market_order, + } + + self._log.info(f"Base URL HTTP {self._http_client.base_url}.", LogColor.BLUE) + self._log.info(f"Base URL WebSocket {base_url_ws}.", LogColor.BLUE) + + async def _connect(self) -> None: + # Connect HTTP client + if not self._http_client.connected: + await self._http_client.connect() + try: + # Initialize instrument provider + await self._instrument_provider.initialize() + # Authenticate API key and update account(s) + await self._update_account_state() + # Get listen keys + response: BinanceListenKey = await self._http_user.create_listen_key() + except BinanceError as e: + self._log.exception(f"Error on connect: {e.message}", e) + return + self._listen_key = response.listenKey + self._log.info(f"Listen key {self._listen_key}") + self._ping_listen_keys_task = self.create_task(self._ping_listen_keys()) + + # Setup clock sync + if self._clock_sync_interval_secs > 0: + self._task_clock_sync = self.create_task(self._sync_clock_with_binance_server()) + + # Connect WebSocket client + self._ws_client.subscribe(key=self._listen_key) + await self._ws_client.connect() + + async def _update_account_state(self) -> None: + # Replace method in child class + raise NotImplementedError + + async def _ping_listen_keys(self) -> None: + try: + while True: + self._log.debug( + f"Scheduled `ping_listen_keys` to run in " + f"{self._ping_listen_keys_interval}s.", + ) + await asyncio.sleep(self._ping_listen_keys_interval) + if self._listen_key: + self._log.debug(f"Pinging WebSocket listen key {self._listen_key}...") + await self._http_user.keepalive_listen_key(listen_key=self._listen_key) + except asyncio.CancelledError: + self._log.debug("`ping_listen_keys` task was canceled.") + + async def _sync_clock_with_binance_server(self) -> None: + try: + while True: + # self._log.info( + # f"Syncing Nautilus clock with Binance server...", + # ) + server_time = await self._http_market.request_server_time() + self._log.info(f"Binance server time {server_time} UNIX (ms).") + + nautilus_time = self._clock.timestamp_ms() + self._log.info(f"Nautilus clock time {nautilus_time} UNIX (ms).") + + # offset_ns = millis_to_nanos(nautilus_time - server_time) + # self._log.info(f"Setting Nautilus clock offset {offset_ns} (ns).") + # self._clock.set_offset(offset_ns) + + await asyncio.sleep(self._clock_sync_interval_secs) + except asyncio.CancelledError: + self._log.debug("`sync_clock_with_binance_server` task was canceled.") + + async def _disconnect(self) -> None: + # Cancel tasks + if self._ping_listen_keys_task: + self._log.debug("Canceling `ping_listen_keys` task...") + self._ping_listen_keys_task.cancel() + self._ping_listen_keys_task.done() + + if self._task_clock_sync: + self._log.debug("Canceling `task_clock_sync` task...") + self._task_clock_sync.cancel() + self._task_clock_sync.done() + + # Disconnect WebSocket clients + if self._ws_client.is_connected: + await self._ws_client.disconnect() + + # Disconnect HTTP client + if self._http_client.connected: + await self._http_client.disconnect() + + # -- EXECUTION REPORTS ------------------------------------------------------------------------ + + async def generate_order_status_report( + self, + instrument_id: InstrumentId, + client_order_id: Optional[ClientOrderId] = None, + venue_order_id: Optional[VenueOrderId] = None, + ) -> Optional[OrderStatusReport]: + PyCondition.false( + client_order_id is None and venue_order_id is None, + "both `client_order_id` and `venue_order_id` were `None`", + ) + + self._log.info( + f"Generating OrderStatusReport for " + f"{repr(client_order_id) if client_order_id else ''} " + f"{repr(venue_order_id) if venue_order_id else ''}...", + ) + + try: + if venue_order_id: + binance_order = await self._http_account.query_order( + symbol=instrument_id.symbol.value, + order_id=venue_order_id.value, + ) + else: + binance_order = await self._http_account.query_order( + symbol=instrument_id.symbol.value, + orig_client_order_id=client_order_id.value + if client_order_id is not None + else None, + ) + except BinanceError as e: + self._log.error( + f"Cannot generate order status report for {repr(client_order_id)}: {e.message}", + ) + return None + if not binance_order: + return None + + report: OrderStatusReport = binance_order.parse_to_order_status_report( + account_id=self.account_id, + instrument_id=self._get_cached_instrument_id(binance_order.symbol), + report_id=UUID4(), + enum_parser=self._enum_parser, + ts_init=self._clock.timestamp_ns(), + ) + + self._log.debug(f"Received {report}.") + return report + + def _get_cache_active_symbols(self) -> list[str]: + # Check cache for all active symbols + open_orders: list[Order] = self._cache.orders_open(venue=self.venue) + open_positions: list[Position] = self._cache.positions_open(venue=self.venue) + active_symbols: list[str] = [] + for o in open_orders: + active_symbols.append(o.instrument_id.symbol.value) + for p in open_positions: + active_symbols.append(p.instrument_id.symbol.value) + return active_symbols + + async def _get_binance_position_status_reports( + self, + symbol: str = None, + ) -> list[str]: + # Implement in child class + raise NotImplementedError + + async def _get_binance_active_position_symbols( + self, + symbol: str = None, + ) -> list[str]: + # Implement in child class + raise NotImplementedError + + async def generate_order_status_reports( + self, + instrument_id: InstrumentId = None, + start: Optional[pd.Timestamp] = None, + end: Optional[pd.Timestamp] = None, + open_only: bool = False, + ) -> list[OrderStatusReport]: + self._log.info(f"Generating OrderStatusReports for {self.id}...") + + try: + # Check Binance for all order active symbols + symbol = instrument_id.symbol.value if instrument_id is not None else None + active_symbols = self._get_cache_active_symbols() + active_symbols.extend(await self._get_binance_active_position_symbols(symbol)) + binance_open_orders = await self._http_account.query_open_orders(symbol) + for order in binance_open_orders: + active_symbols.append(order.symbol) + # Get all orders for those active symbols + binance_orders: list[BinanceOrder] = [] + for symbol in active_symbols: + response = await self._http_account.query_all_orders( + symbol=symbol, + start_time=secs_to_millis(start.timestamp()) if start is not None else None, + end_time=secs_to_millis(end.timestamp()) if end is not None else None, + ) + binance_orders.extend(response) + except BinanceError as e: + self._log.exception(f"Cannot generate order status report: {e.message}", e) + return [] + + reports: list[OrderStatusReport] = [] + for order in binance_orders: + # Apply filter (always report open orders regardless of start, end filter) + # TODO(cs): Time filter is WIP + # timestamp = pd.to_datetime(data["time"], utc=True) + # if data["status"] not in ("NEW", "PARTIALLY_FILLED", "PENDING_CANCEL"): + # if start is not None and timestamp < start: + # continue + # if end is not None and timestamp > end: + # continue + report = order.parse_to_order_status_report( + account_id=self.account_id, + instrument_id=self._get_cached_instrument_id(order.symbol), + report_id=UUID4(), + enum_parser=self._enum_parser, + ts_init=self._clock.timestamp_ns(), + ) + self._log.debug(f"Received {reports}.") + reports.append(report) + + len_reports = len(reports) + plural = "" if len_reports == 1 else "s" + self._log.info(f"Generated {len(reports)} OrderStatusReport{plural}.") + + return reports + + async def generate_trade_reports( + self, + instrument_id: InstrumentId = None, + venue_order_id: VenueOrderId = None, + start: Optional[pd.Timestamp] = None, + end: Optional[pd.Timestamp] = None, + ) -> list[TradeReport]: + self._log.info(f"Generating TradeReports for {self.id}...") + + try: + # Check Binance for all trades on active symbols + symbol = instrument_id.symbol.value if instrument_id is not None else None + active_symbols = self._get_cache_active_symbols() + active_symbols.extend(await self._get_binance_active_position_symbols(symbol)) + binance_trades: list[BinanceUserTrade] = [] + for symbol in active_symbols: + response = await self._http_account.query_user_trades( + symbol=symbol, + start_time=secs_to_millis(start.timestamp()) if start is not None else None, + end_time=secs_to_millis(end.timestamp()) if end is not None else None, + ) + binance_trades.extend(response) + except BinanceError as e: + self._log.exception(f"Cannot generate trade report: {e.message}", e) + return [] + + # Parse all Binance trades + reports: list[TradeReport] = [] + for trade in binance_trades: + # Apply filter + # TODO(cs): Time filter is WIP + # timestamp = pd.to_datetime(data["time"], utc=True) + # if start is not None and timestamp < start: + # continue + # if end is not None and timestamp > end: + # continue + report = trade.parse_to_trade_report( + account_id=self.account_id, + instrument_id=self._get_cached_instrument_id(trade.symbol), + report_id=UUID4(), + ts_init=self._clock.timestamp_ns(), + ) + self._log.debug(f"Received {report}.") + reports.append(report) + + # Confirm sorting in ascending order + reports = sorted(reports, key=lambda x: x.trade_id) + + len_reports = len(reports) + plural = "" if len_reports == 1 else "s" + self._log.info(f"Generated {len(reports)} TradeReport{plural}.") + + return reports + + async def generate_position_status_reports( + self, + instrument_id: InstrumentId = None, + start: Optional[pd.Timestamp] = None, + end: Optional[pd.Timestamp] = None, + ) -> list[PositionStatusReport]: + self._log.info(f"Generating PositionStatusReports for {self.id}...") + + try: + symbol = instrument_id.symbol.value if instrument_id is not None else None + reports = await self._get_binance_position_status_reports(symbol) + except BinanceError as e: + self._log.exception(f"Cannot generate position status report: {e.message}", e) + return [] + + len_reports = len(reports) + plural = "" if len_reports == 1 else "s" + self._log.info(f"Generated {len(reports)} PositionStatusReport{plural}.") + + return reports + + # -- COMMAND HANDLERS ------------------------------------------------------------------------- + + async def _submit_order(self, command: SubmitOrder) -> None: + order: Order = command.order + + # Check validity + self._check_order_validity(order) + self._log.debug(f"Submitting {order}.") + + # Generate event here to ensure correct ordering of events + self.generate_order_submitted( + strategy_id=order.strategy_id, + instrument_id=order.instrument_id, + client_order_id=order.client_order_id, + ts_event=self._clock.timestamp_ns(), + ) + try: + await self._submit_order_method[order.order_type](order) + except BinanceError as e: + self.generate_order_rejected( + strategy_id=order.strategy_id, + instrument_id=order.instrument_id, + client_order_id=order.client_order_id, + reason=e.message, + ts_event=self._clock.timestamp_ns(), + ) + except KeyError: + raise RuntimeError(f"unsupported order type, was {order.order_type}") + + def _check_order_validity(self, order: Order): + # Implement in child class + raise NotImplementedError + + async def _submit_market_order(self, order: MarketOrder) -> None: + await self._http_account.new_order( + symbol=order.instrument_id.symbol.value, + side=self._enum_parser.parse_internal_order_side(order.side), + order_type=self._enum_parser.parse_internal_order_type(order), + quantity=str(order.quantity), + new_client_order_id=order.client_order_id.value, + recv_window=str(5000), + ) + + async def _submit_limit_order(self, order: LimitOrder) -> None: + time_in_force = self._enum_parser.parse_internal_time_in_force(order.time_in_force) + if order.time_in_force == TimeInForce.GTD and time_in_force == BinanceTimeInForce.GTC: + if self._warn_gtd_to_gtc: + self._log.warning("Converted GTD `time_in_force` to GTC.") + if order.is_post_only and self._binance_account_type.is_spot_or_margin: + time_in_force = None + elif order.is_post_only and self._binance_account_type.is_futures: + time_in_force = BinanceTimeInForce.GTX + + await self._http_account.new_order( + symbol=order.instrument_id.symbol.value, + side=self._enum_parser.parse_internal_order_side(order.side), + order_type=self._enum_parser.parse_internal_order_type(order), + time_in_force=time_in_force, + quantity=str(order.quantity), + price=str(order.price), + iceberg_qty=str(order.display_qty) if order.display_qty is not None else None, + reduce_only=str(order.is_reduce_only) if order.is_reduce_only is True else None, + new_client_order_id=order.client_order_id.value, + recv_window=str(5000), + ) + + async def _submit_stop_limit_order(self, order: StopLimitOrder) -> None: + time_in_force = self._enum_parser.parse_internal_time_in_force(order.time_in_force) + + if self._binance_account_type.is_spot_or_margin: + working_type = None + elif order.trigger_type in (TriggerType.DEFAULT, TriggerType.LAST_TRADE): + working_type = "CONTRACT_PRICE" + elif order.trigger_type == TriggerType.MARK_PRICE: + working_type = "MARK_PRICE" + else: + self._log.error( + f"Cannot submit order: invalid `order.trigger_type`, was " + f"{trigger_type_to_str(order.trigger_price)}. {order}", + ) + return + + await self._http_account.new_order( + symbol=order.instrument_id.symbol.value, + side=self._enum_parser.parse_internal_order_side(order.side), + order_type=self._enum_parser.parse_internal_order_type(order), + time_in_force=time_in_force, + quantity=str(order.quantity), + price=str(order.price), + stop_price=str(order.trigger_price), + working_type=working_type, + iceberg_qty=str(order.display_qty) if order.display_qty is not None else None, + reduce_only=str(order.is_reduce_only) if order.is_reduce_only is True else None, + new_client_order_id=order.client_order_id.value, + recv_window=str(5000), + ) + + async def _submit_order_list(self, command: SubmitOrderList) -> None: + for order in command.order_list: + self.generate_order_submitted( + strategy_id=order.strategy_id, + instrument_id=order.instrument_id, + client_order_id=order.client_order_id, + ts_event=self._clock.timestamp_ns(), + ) + + for order in command.order_list: + if order.linked_order_ids: # TODO(cs): Implement + self._log.warning(f"Cannot yet handle OCO conditional orders, {order}.") + await self._submit_order(order) + + async def _submit_stop_market_order(self, order: StopMarketOrder) -> None: + time_in_force = self._enum_parser.parse_internal_time_in_force(order.time_in_force) + + if self._binance_account_type.is_spot_or_margin: + working_type = None + elif order.trigger_type in (TriggerType.DEFAULT, TriggerType.LAST_TRADE): + working_type = "CONTRACT_PRICE" + elif order.trigger_type == TriggerType.MARK_PRICE: + working_type = "MARK_PRICE" + else: + self._log.error( + f"Cannot submit order: invalid `order.trigger_type`, was " + f"{trigger_type_to_str(order.trigger_price)}. {order}", + ) + return + + await self._http_account.new_order( + symbol=order.instrument_id.symbol.value, + side=self._enum_parser.parse_internal_order_side(order.side), + order_type=self._enum_parser.parse_internal_order_type(order), + time_in_force=time_in_force, + quantity=str(order.quantity), + stop_price=str(order.trigger_price), + working_type=working_type, + reduce_only=str(order.is_reduce_only) if order.is_reduce_only is True else None, + new_client_order_id=order.client_order_id.value, + recv_window=str(5000), + ) + + async def _submit_trailing_stop_market_order(self, order: TrailingStopMarketOrder) -> None: + time_in_force = self._enum_parser.parse_internal_time_in_force(order.time_in_force) + + if order.trigger_type in (TriggerType.DEFAULT, TriggerType.LAST_TRADE): + working_type = "CONTRACT_PRICE" + elif order.trigger_type == TriggerType.MARK_PRICE: + working_type = "MARK_PRICE" + else: + self._log.error( + f"Cannot submit order: invalid `order.trigger_type`, was " + f"{trigger_type_to_str(order.trigger_price)}. {order}", + ) + return + + if order.trailing_offset_type != TrailingOffsetType.BASIS_POINTS: + self._log.error( + f"Cannot submit order: invalid `order.trailing_offset_type`, was " + f"{trailing_offset_type_to_str(order.trailing_offset_type)} (use `BASIS_POINTS`). " + f"{order}", + ) + return + + # Ensure activation price + activation_price: Optional[Price] = order.trigger_price + if not activation_price: + quote = self._cache.quote_tick(order.instrument_id) + trade = self._cache.trade_tick(order.instrument_id) + if quote: + if order.side == OrderSide.BUY: + activation_price = quote.ask + elif order.side == OrderSide.SELL: + activation_price = quote.bid + elif trade: + activation_price = trade.price + else: + self._log.error( + "Cannot submit order: no trigger price specified for Binance activation price " + f"and could not find quotes or trades for {order.instrument_id}", + ) + + await self._http_account.new_order( + symbol=order.instrument_id.symbol.value, + side=self._enum_parser.parse_internal_order_side(order.side), + order_type=self._enum_parser.parse_internal_order_type(order), + time_in_force=time_in_force, + quantity=str(order.quantity), + activation_price=str(activation_price), + callback_rate=str(order.trailing_offset / 100), + working_type=working_type, + reduce_only=str(order.is_reduce_only) if order.is_reduce_only is True else None, + new_client_order_id=order.client_order_id.value, + recv_window=str(5000), + ) + + def _get_cached_instrument_id(self, symbol: str) -> InstrumentId: + # Parse instrument ID + nautilus_symbol: str = BinanceSymbol(symbol).parse_binance_to_internal( + self._binance_account_type, + ) + instrument_id: Optional[InstrumentId] = self._instrument_ids.get(nautilus_symbol) + if not instrument_id: + instrument_id = InstrumentId(Symbol(nautilus_symbol), BINANCE_VENUE) + self._instrument_ids[nautilus_symbol] = instrument_id + return instrument_id + + async def _modify_order(self, command: ModifyOrder) -> None: + self._log.error( # pragma: no cover + "Cannot modify order: Not supported by the exchange.", # pragma: no cover + ) + + async def _cancel_order(self, command: CancelOrder) -> None: + self.generate_order_pending_cancel( + strategy_id=command.strategy_id, + instrument_id=command.instrument_id, + client_order_id=command.client_order_id, + venue_order_id=command.venue_order_id, + ts_event=self._clock.timestamp_ns(), + ) + + await self._cancel_order_single( + instrument_id=command.instrument_id, + client_order_id=command.client_order_id, + venue_order_id=command.venue_order_id, + ) + + async def _cancel_all_orders(self, command: CancelAllOrders) -> None: + open_orders_strategy = self._cache.orders_open( + instrument_id=command.instrument_id, + strategy_id=command.strategy_id, + ) + for order in open_orders_strategy: + if order.is_pending_cancel: + continue # Already pending cancel + self.generate_order_pending_cancel( + strategy_id=order.strategy_id, + instrument_id=order.instrument_id, + client_order_id=order.client_order_id, + venue_order_id=order.venue_order_id, + ts_event=self._clock.timestamp_ns(), + ) + + # Check total orders for instrument + open_orders_total_count = self._cache.orders_open_count( + instrument_id=command.instrument_id, + ) + + try: + if open_orders_total_count == len(open_orders_strategy): + await self._http_account.cancel_all_open_orders( + symbol=command.instrument_id.symbol.value, + ) + else: + for order in open_orders_strategy: + await self._cancel_order_single( + instrument_id=order.instrument_id, + client_order_id=order.client_order_id, + venue_order_id=order.venue_order_id, + ) + except BinanceError as e: + self._log.exception(f"Cannot cancel open orders: {e.message}", e) + + async def _cancel_order_single( + self, + instrument_id: InstrumentId, + client_order_id: ClientOrderId, + venue_order_id: Optional[VenueOrderId], + ) -> None: + try: + if venue_order_id is not None: + await self._http_account.cancel_order( + symbol=instrument_id.symbol.value, + order_id=venue_order_id.value, + ) + else: + await self._http_account.cancel_order( + symbol=instrument_id.symbol.value, + orig_client_order_id=client_order_id.value, + ) + except BinanceError as e: + self._log.exception( + f"Cannot cancel order " + f"{repr(client_order_id)}, " + f"{repr(venue_order_id)}: " + f"{e.message}", + e, + ) + + # -- WEBSOCKET EVENT HANDLERS -------------------------------------------------------------------- + + def _handle_user_ws_message(self, raw: bytes) -> None: + # Implement in child class + raise NotImplementedError diff --git a/nautilus_trader/adapters/binance/common/functions.py b/nautilus_trader/adapters/binance/common/functions.py deleted file mode 100644 index 9806490d0d5f..000000000000 --- a/nautilus_trader/adapters/binance/common/functions.py +++ /dev/null @@ -1,44 +0,0 @@ -# ------------------------------------------------------------------------------------------------- -# Copyright (C) 2015-2023 Nautech Systems Pty Ltd. All rights reserved. -# https://nautechsystems.io -# -# Licensed under the GNU Lesser General Public License Version 3.0 (the "License"); -# You may not use this file except in compliance with the License. -# You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# ------------------------------------------------------------------------------------------------- - -import json - -from nautilus_trader.adapters.binance.common.enums import BinanceAccountType - - -def parse_symbol(symbol: str, account_type: BinanceAccountType): - symbol = symbol.upper() - if account_type.is_spot or account_type.is_margin: - return symbol - - # Parse Futures symbol - if symbol[-1].isdigit(): - return symbol # Deliverable - if symbol.endswith("_PERP"): - symbol = symbol.replace("_", "-") - return symbol - else: - return symbol + "-PERP" - - -def format_symbol(symbol: str): - return symbol.upper().replace(" ", "").replace("/", "").replace("-PERP", "") - - -def convert_symbols_list_to_json_array(symbols: list[str]): - if symbols is None: - return symbols - formatted_symbols: list[str] = [format_symbol(s) for s in symbols] - return json.dumps(formatted_symbols).replace(" ", "").replace("/", "") diff --git a/nautilus_trader/adapters/binance/common/parsing/data.py b/nautilus_trader/adapters/binance/common/parsing/data.py deleted file mode 100644 index 25ea28775a7a..000000000000 --- a/nautilus_trader/adapters/binance/common/parsing/data.py +++ /dev/null @@ -1,229 +0,0 @@ -# ------------------------------------------------------------------------------------------------- -# Copyright (C) 2015-2023 Nautech Systems Pty Ltd. All rights reserved. -# https://nautechsystems.io -# -# Licensed under the GNU Lesser General Public License Version 3.0 (the "License"); -# You may not use this file except in compliance with the License. -# You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# ------------------------------------------------------------------------------------------------- - -from decimal import Decimal - -from nautilus_trader.adapters.binance.common.schemas import BinanceCandlestick -from nautilus_trader.adapters.binance.common.schemas import BinanceOrderBookData -from nautilus_trader.adapters.binance.common.schemas import BinanceQuoteData -from nautilus_trader.adapters.binance.common.schemas import BinanceTickerData -from nautilus_trader.adapters.binance.common.schemas import BinanceTrade -from nautilus_trader.adapters.binance.common.types import BinanceBar -from nautilus_trader.adapters.binance.common.types import BinanceTicker -from nautilus_trader.core.datetime import millis_to_nanos -from nautilus_trader.model.data.bar import BarSpecification -from nautilus_trader.model.data.bar import BarType -from nautilus_trader.model.data.tick import QuoteTick -from nautilus_trader.model.data.tick import TradeTick -from nautilus_trader.model.enums import AggregationSource -from nautilus_trader.model.enums import AggressorSide -from nautilus_trader.model.enums import BarAggregation -from nautilus_trader.model.enums import BookAction -from nautilus_trader.model.enums import BookType -from nautilus_trader.model.enums import OrderSide -from nautilus_trader.model.enums import PriceType -from nautilus_trader.model.identifiers import InstrumentId -from nautilus_trader.model.identifiers import TradeId -from nautilus_trader.model.objects import Price -from nautilus_trader.model.objects import Quantity -from nautilus_trader.model.orderbook.data import BookOrder -from nautilus_trader.model.orderbook.data import OrderBookDelta -from nautilus_trader.model.orderbook.data import OrderBookDeltas - - -def parse_trade_tick_http( - instrument_id: InstrumentId, - trade: BinanceTrade, - ts_init: int, -) -> TradeTick: - return TradeTick( - instrument_id=instrument_id, - price=Price.from_str(trade.price), - size=Quantity.from_str(trade.qty), - aggressor_side=AggressorSide.SELLER if trade.isBuyerMaker else AggressorSide.BUYER, - trade_id=TradeId(str(trade.id)), - ts_event=millis_to_nanos(trade.time), - ts_init=ts_init, - ) - - -def parse_bar_http(bar_type: BarType, values: list, ts_init: int) -> BinanceBar: - return BinanceBar( - bar_type=bar_type, - open=Price.from_str(values[1]), - high=Price.from_str(values[2]), - low=Price.from_str(values[3]), - close=Price.from_str(values[4]), - volume=Quantity.from_str(values[5]), - quote_volume=Decimal(values[7]), - count=values[8], - taker_buy_base_volume=Decimal(values[9]), - taker_buy_quote_volume=Decimal(values[10]), - ts_event=millis_to_nanos(values[0]), - ts_init=ts_init, - ) - - -def parse_diff_depth_stream_ws( - instrument_id: InstrumentId, - data: BinanceOrderBookData, - ts_init: int, -) -> OrderBookDeltas: - ts_event: int = millis_to_nanos(data.T) if data.T is not None else millis_to_nanos(data.E) - - bid_deltas: list[OrderBookDelta] = [ - parse_book_delta_ws(instrument_id, OrderSide.BUY, d, ts_event, ts_init, data.u) - for d in data.b - ] - ask_deltas: list[OrderBookDelta] = [ - parse_book_delta_ws(instrument_id, OrderSide.SELL, d, ts_event, ts_init, data.u) - for d in data.a - ] - - return OrderBookDeltas( - instrument_id=instrument_id, - book_type=BookType.L2_MBP, - deltas=bid_deltas + ask_deltas, - ts_event=ts_event, - ts_init=ts_init, - sequence=data.u, - ) - - -def parse_book_delta_ws( - instrument_id: InstrumentId, - side: OrderSide, - delta: tuple[str, str], - ts_event: int, - ts_init: int, - update_id: int, -) -> OrderBookDelta: - price = float(delta[0]) - size = float(delta[1]) - - order = BookOrder( - price=price, - size=size, - side=side, - ) - - return OrderBookDelta( - instrument_id=instrument_id, - book_type=BookType.L2_MBP, - action=BookAction.UPDATE if size > 0.0 else BookAction.DELETE, - order=order, - ts_event=ts_event, - ts_init=ts_init, - sequence=update_id, - ) - - -def parse_quote_tick_ws( - instrument_id: InstrumentId, - data: BinanceQuoteData, - ts_init: int, -) -> QuoteTick: - return QuoteTick( - instrument_id=instrument_id, - bid=Price.from_str(data.b), - ask=Price.from_str(data.a), - bid_size=Quantity.from_str(data.B), - ask_size=Quantity.from_str(data.A), - ts_event=ts_init, - ts_init=ts_init, - ) - - -def parse_ticker_24hr_ws( - instrument_id: InstrumentId, - data: BinanceTickerData, - ts_init: int, -) -> BinanceTicker: - return BinanceTicker( - instrument_id=instrument_id, - price_change=Decimal(data.p), - price_change_percent=Decimal(data.P), - weighted_avg_price=Decimal(data.w), - prev_close_price=Decimal(data.x) if data.x is not None else None, - last_price=Decimal(data.c), - last_qty=Decimal(data.Q), - bid_price=Decimal(data.b) if data.b is not None else None, - bid_qty=Decimal(data.B) if data.B is not None else None, - ask_price=Decimal(data.a) if data.a is not None else None, - ask_qty=Decimal(data.A) if data.A is not None else None, - open_price=Decimal(data.o), - high_price=Decimal(data.h), - low_price=Decimal(data.l), - volume=Decimal(data.v), - quote_volume=Decimal(data.q), - open_time_ms=data.O, - close_time_ms=data.C, - first_id=data.F, - last_id=data.L, - count=data.n, - ts_event=millis_to_nanos(data.E), - ts_init=ts_init, - ) - - -def parse_bar_ws( - instrument_id: InstrumentId, - data: BinanceCandlestick, - ts_init: int, -) -> BinanceBar: - resolution = data.i[-1] - if resolution == "s": - aggregation = BarAggregation.SECOND - elif resolution == "m": - aggregation = BarAggregation.MINUTE - elif resolution == "h": - aggregation = BarAggregation.HOUR - elif resolution == "d": - aggregation = BarAggregation.DAY - elif resolution == "w": - aggregation = BarAggregation.WEEK - elif resolution == "M": - aggregation = BarAggregation.MONTH - else: - raise RuntimeError( # pragma: no cover (design-time error) - f"unsupported time aggregation resolution, was {resolution}", # pragma: no cover (design-time error) # noqa - ) - - bar_spec = BarSpecification( - step=int(data.i[:-1]), - aggregation=aggregation, - price_type=PriceType.LAST, - ) - - bar_type = BarType( - instrument_id=instrument_id, - bar_spec=bar_spec, - aggregation_source=AggregationSource.EXTERNAL, - ) - - return BinanceBar( - bar_type=bar_type, - open=Price.from_str(data.o), - high=Price.from_str(data.h), - low=Price.from_str(data.l), - close=Price.from_str(data.c), - volume=Quantity.from_str(data.v), - quote_volume=Decimal(data.q), - count=data.n, - taker_buy_base_volume=Decimal(data.V), - taker_buy_quote_volume=Decimal(data.Q), - ts_event=millis_to_nanos(data.T), - ts_init=ts_init, - ) diff --git a/nautilus_trader/adapters/binance/common/schemas.py b/nautilus_trader/adapters/binance/common/schemas.py deleted file mode 100644 index 9dbd8aa62ef7..000000000000 --- a/nautilus_trader/adapters/binance/common/schemas.py +++ /dev/null @@ -1,275 +0,0 @@ -# ------------------------------------------------------------------------------------------------- -# Copyright (C) 2015-2023 Nautech Systems Pty Ltd. All rights reserved. -# https://nautechsystems.io -# -# Licensed under the GNU Lesser General Public License Version 3.0 (the "License"); -# You may not use this file except in compliance with the License. -# You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# ------------------------------------------------------------------------------------------------- - -from typing import Optional - -import msgspec - - -################################################################################ -# HTTP responses -################################################################################ - - -class BinanceListenKey(msgspec.Struct): - """HTTP response from creating a new `Binance` user listen key.""" - - listenKey: str - - -class BinanceQuote(msgspec.Struct): - """HTTP response from `Binance` GET /fapi/v1/ticker/bookTicker.""" - - symbol: str - bidPrice: str - bidQty: str - askPrice: str - askQty: str - time: int # Transaction time - - -class BinanceTrade(msgspec.Struct): - """HTTP response from `Binance` GET /fapi/v1/trades.""" - - id: int - price: str - qty: str - quoteQty: str - time: int - isBuyerMaker: bool - isBestMatch: Optional[bool] = True - - -class BinanceTicker(msgspec.Struct, kw_only=True): - """HTTP response from `Binance` GET /fapi/v1/ticker/24hr .""" - - symbol: str - priceChange: str - priceChangePercent: str - weightedAvgPrice: str - prevClosePrice: Optional[str] = None - lastPrice: str - lastQty: str - bidPrice: str - bidQty: str - askPrice: str - askQty: str - openPrice: str - highPrice: str - lowPrice: str - volume: str - quoteVolume: str - openTime: int - closeTime: int - firstId: int - lastId: int - count: int - - -################################################################################ -# WebSocket messages -################################################################################ - - -class BinanceDataMsgWrapper(msgspec.Struct): - """ - Provides a wrapper for data WebSocket messages from `Binance`. - """ - - stream: str - - -class BinanceOrderBookData(msgspec.Struct, kw_only=True): - """WebSocket message 'inner struct' for `Binance` Diff. Book Depth Streams.""" - - e: str # Event type - E: int # Event time - T: Optional[int] = None # Transaction time (Binance Futures only) - s: str # Symbol - U: int # First update ID in event - u: int # Final update ID in event - pu: Optional[int] = None # ?? (Binance Futures only) - b: list[tuple[str, str]] # Bids to be updated - a: list[tuple[str, str]] # Asks to be updated - - -class BinanceOrderBookMsg(msgspec.Struct): - """WebSocket message from `Binance` Diff. Book Depth Streams.""" - - stream: str - data: BinanceOrderBookData - - -class BinanceQuoteData(msgspec.Struct): - """WebSocket message from `Binance` Individual Symbol Book Ticker Streams.""" - - s: str # symbol - u: int # order book updateId - b: str # best bid price - B: str # best bid qty - a: str # best ask price - A: str # best ask qty - - -class BinanceQuoteMsg(msgspec.Struct): - """WebSocket message from `Binance` Individual Symbol Book Ticker Streams.""" - - stream: str - data: BinanceQuoteData - - -class BinanceAggregatedTradeData(msgspec.Struct): - """WebSocket message from `Binance` Aggregate Trade Streams.""" - - e: str # Event type - E: int # Event time - s: str # Symbol - a: int # Aggregate trade ID - p: str # Price - q: str # Quantity - f: int # First trade ID - l: int # Last trade ID - T: int # Trade time - m: bool # Is the buyer the market maker? - - -class BinanceAggregatedTradeMsg(msgspec.Struct): - """WebSocket message.""" - - stream: str - data: BinanceAggregatedTradeData - - -class BinanceTickerData(msgspec.Struct, kw_only=True): - """ - WebSocker message from `Binance` 24hr Ticker - - Fields - ------ - - e: Event type - - E: Event time - - s: Symbol - - p: Price change - - P: Price change percent - - w: Weighted average price - - x: Previous close price - - c: Last price - - Q: Last quantity - - b: Best bid price - - B: Best bid quantity - - a: Best ask price - - A: Best ask quantity - - o: Open price - - h: High price - - l: Low price - - v: Total traded base asset volume - - q: Total traded quote asset volume - - O: Statistics open time - - C: Statistics close time - - F: First trade ID - - L: Last trade ID - - n: Total number of trades - """ - - e: str # Event type - E: int # Event time - s: str # Symbol - p: str # Price change - P: str # Price change percent - w: str # Weighted average price - x: Optional[str] = None # First trade(F)-1 price (first trade before the 24hr rolling window) - c: str # Last price - Q: str # Last quantity - b: Optional[str] = None # Best bid price - B: Optional[str] = None # Best bid quantity - a: Optional[str] = None # Best ask price - A: Optional[str] = None # Best ask quantity - o: str # Open price - h: str # High price - l: str # Low price - v: str # Total traded base asset volume - q: str # Total traded quote asset volume - O: int # Statistics open time - C: int # Statistics close time - F: int # First trade ID - L: int # Last trade ID - n: int # Total number of trades - - -class BinanceTickerMsg(msgspec.Struct): - """WebSocket message.""" - - stream: str - data: BinanceTickerData - - -class BinanceCandlestick(msgspec.Struct): - """ - WebSocket message 'inner struct' for `Binance` Kline/Candlestick Streams. - - Fields - ------ - - t: Kline start time - - T: Kline close time - - s: Symbol - - i: Interval - - f: First trade ID - - L: Last trade ID - - o: Open price - - c: Close price - - h: High price - - l: Low price - - v: Base asset volume - - n: Number of trades - - x: Is this kline closed? - - q: Quote asset volume - - V: Taker buy base asset volume - - Q: Taker buy quote asset volume - - B: Ignore - """ - - t: int # Kline start time - T: int # Kline close time - s: str # Symbol - i: str # Interval - f: int # First trade ID - L: int # Last trade ID - o: str # Open price - c: str # Close price - h: str # High price - l: str # Low price - v: str # Base asset volume - n: int # Number of trades - x: bool # Is this kline closed? - q: str # Quote asset volume - V: str # Taker buy base asset volume - Q: str # Taker buy quote asset volume - B: str # Ignore - - -class BinanceCandlestickData(msgspec.Struct): - """WebSocket message 'inner struct'.""" - - e: str - E: int - s: str - k: BinanceCandlestick - - -class BinanceCandlestickMsg(msgspec.Struct): - """WebSocket message for `Binance` Kline/Candlestick Streams.""" - - stream: str - data: BinanceCandlestickData diff --git a/nautilus_trader/adapters/binance/common/parsing/__init__.py b/nautilus_trader/adapters/binance/common/schemas/__init__.py similarity index 100% rename from nautilus_trader/adapters/binance/common/parsing/__init__.py rename to nautilus_trader/adapters/binance/common/schemas/__init__.py diff --git a/nautilus_trader/adapters/binance/common/schemas/account.py b/nautilus_trader/adapters/binance/common/schemas/account.py new file mode 100644 index 000000000000..61bf6583775e --- /dev/null +++ b/nautilus_trader/adapters/binance/common/schemas/account.py @@ -0,0 +1,247 @@ +# ------------------------------------------------------------------------------------------------- +# Copyright (C) 2015-2023 Nautech Systems Pty Ltd. All rights reserved. +# https://nautechsystems.io +# +# Licensed under the GNU Lesser General Public License Version 3.0 (the "License"); +# You may not use this file except in compliance with the License. +# You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ------------------------------------------------------------------------------------------------- + +from decimal import Decimal +from typing import Optional + +import msgspec + +from nautilus_trader.adapters.binance.common.enums import BinanceEnumParser +from nautilus_trader.adapters.binance.common.enums import BinanceOrderSide +from nautilus_trader.adapters.binance.common.enums import BinanceOrderStatus +from nautilus_trader.adapters.binance.common.enums import BinanceOrderType +from nautilus_trader.adapters.binance.common.enums import BinanceTimeInForce +from nautilus_trader.core.datetime import millis_to_nanos +from nautilus_trader.core.uuid import UUID4 +from nautilus_trader.execution.reports import OrderStatusReport +from nautilus_trader.execution.reports import TradeReport +from nautilus_trader.model.currency import Currency +from nautilus_trader.model.enums import ContingencyType +from nautilus_trader.model.enums import LiquiditySide +from nautilus_trader.model.enums import OrderSide +from nautilus_trader.model.enums import TrailingOffsetType +from nautilus_trader.model.enums import TriggerType +from nautilus_trader.model.identifiers import AccountId +from nautilus_trader.model.identifiers import ClientOrderId +from nautilus_trader.model.identifiers import InstrumentId +from nautilus_trader.model.identifiers import OrderListId +from nautilus_trader.model.identifiers import PositionId +from nautilus_trader.model.identifiers import TradeId +from nautilus_trader.model.identifiers import VenueOrderId +from nautilus_trader.model.objects import Money +from nautilus_trader.model.objects import Price +from nautilus_trader.model.objects import Quantity + + +################################################################################ +# HTTP responses +################################################################################ + + +class BinanceUserTrade(msgspec.Struct, frozen=True): + """ + HTTP response from `Binance Spot/Margin` + `GET /api/v3/myTrades` + HTTP response from `Binance USD-M Futures` + `GET /fapi/v1/userTrades` + HTTP response from `Binance COIN-M Futures` + `GET /dapi/v1/userTrades` + """ + + commission: str + commissionAsset: str + price: str + qty: str + + # Parameters not present in 'fills' list (see FULL response of BinanceOrder) + symbol: Optional[str] = None + id: Optional[int] = None + orderId: Optional[int] = None + time: Optional[int] = None + quoteQty: Optional[str] = None # SPOT/MARGIN & USD-M FUTURES only + + # Parameters in SPOT/MARGIN only: + orderListId: Optional[int] = None # unless OCO, the value will always be -1 + isBuyer: Optional[bool] = None + isMaker: Optional[bool] = None + isBestMatch: Optional[bool] = None + tradeId: Optional[int] = None # only in BinanceOrder FULL response + + # Parameters in FUTURES only: + buyer: Optional[bool] = None + maker: Optional[bool] = None + realizedPnl: Optional[str] = None + side: Optional[BinanceOrderSide] = None + positionSide: Optional[str] = None + baseQty: Optional[str] = None # COIN-M FUTURES only + pair: Optional[str] = None # COIN-M FUTURES only + + def parse_to_trade_report( + self, + account_id: AccountId, + instrument_id: InstrumentId, + report_id: UUID4, + ts_init: int, + ) -> TradeReport: + venue_position_id = None + if self.positionSide is not None: + venue_position_id = PositionId(f"{instrument_id}-{self.positionSide}") + + order_side = OrderSide.BUY if self.isBuyer or self.buyer else OrderSide.SELL + liquidity_side = LiquiditySide.MAKER if self.isMaker or self.maker else LiquiditySide.TAKER + + return TradeReport( + account_id=account_id, + instrument_id=instrument_id, + venue_order_id=VenueOrderId(str(self.orderId)), + venue_position_id=venue_position_id, + trade_id=TradeId(str(self.id)), + order_side=order_side, + last_qty=Quantity.from_str(self.qty), + last_px=Price.from_str(self.price), + liquidity_side=liquidity_side, + ts_event=millis_to_nanos(self.time), + commission=Money(self.commission, Currency.from_str(self.commissionAsset)), + report_id=report_id, + ts_init=ts_init, + ) + + +class BinanceOrder(msgspec.Struct, frozen=True): + """ + HTTP response from `Binance Spot/Margin` + `GET /api/v3/order` + HTTP response from `Binance USD-M Futures` + `GET /fapi/v1/order` + HTTP response from `Binance COIN-M Futures` + `GET /dapi/v1/order` + """ + + symbol: str + orderId: int + clientOrderId: str + + # Parameters not in ACK response: + price: Optional[str] = None + origQty: Optional[str] = None + executedQty: Optional[str] = None + status: Optional[BinanceOrderStatus] = None + timeInForce: Optional[BinanceTimeInForce] = None + type: Optional[BinanceOrderType] = None + side: Optional[BinanceOrderSide] = None + stopPrice: Optional[str] = None # please ignore when order type is TRAILING_STOP_MARKET + time: Optional[int] = None + updateTime: Optional[int] = None + + # Parameters in SPOT/MARGIN only: + orderListId: Optional[int] = None # Unless OCO, the value will always be -1 + cumulativeQuoteQty: Optional[str] = None # cumulative quote qty + icebergQty: Optional[str] = None + isWorking: Optional[bool] = None + workingTime: Optional[int] = None + origQuoteOrderQty: Optional[str] = None + selfTradePreventionMode: Optional[str] = None + transactTime: Optional[int] = None # POST & DELETE methods only + fills: Optional[list[BinanceUserTrade]] = None # FULL response only + + # Parameters in FUTURES only: + avgPrice: Optional[str] = None + origType: Optional[BinanceOrderType] = None + reduceOnly: Optional[bool] = None + positionSide: Optional[str] = None + closePosition: Optional[bool] = None + activatePrice: Optional[str] = None # activation price, only for TRAILING_STOP_MARKET order + priceRate: Optional[str] = None # callback rate, only for TRAILING_STOP_MARKET order + workingType: Optional[str] = None + priceProtect: Optional[bool] = None # if conditional order trigger is protected + cumQuote: Optional[str] = None # USD-M FUTURES only + cumBase: Optional[str] = None # COIN-M FUTURES only + pair: Optional[str] = None # COIN-M FUTURES only + + def parse_to_order_status_report( + self, + account_id: AccountId, + instrument_id: InstrumentId, + report_id: UUID4, + enum_parser: BinanceEnumParser, + ts_init: int, + ) -> OrderStatusReport: + if self.price is None: + raise RuntimeError( + "Cannot generate order status report from Binance ACK response.", + ) + + client_order_id = ClientOrderId(self.clientOrderId) if self.clientOrderId != "" else None + order_list_id = OrderListId(str(self.orderListId)) if self.orderListId is not None else None + contingency_type = ( + ContingencyType.OCO + if self.orderListId is not None and self.orderListId != -1 + else ContingencyType.NO_CONTINGENCY + ) + + trigger_price = Decimal(self.stopPrice) + trigger_type = None + if self.workingType is not None: + trigger_type = enum_parser.parse_binance_trigger_type(self.workingType) + elif trigger_price > 0: + trigger_type = TriggerType.LAST_TRADE if trigger_price > 0 else None + + trailing_offset = None + trailing_offset_type = TrailingOffsetType.NO_TRAILING_OFFSET + if self.priceRate is not None: + trailing_offset = Decimal(self.priceRate) + trailing_offset_type = TrailingOffsetType.BASIS_POINTS + + avg_px = Decimal(self.avgPrice) if self.avgPrice is not None else None + post_only = ( + self.type == BinanceOrderType.LIMIT_MAKER or self.timeInForce == BinanceTimeInForce.GTX + ) + reduce_only = self.reduceOnly if self.reduceOnly is not None else False + + return OrderStatusReport( + account_id=account_id, + instrument_id=instrument_id, + client_order_id=client_order_id, + order_list_id=order_list_id, + venue_order_id=VenueOrderId(str(self.orderId)), + order_side=enum_parser.parse_binance_order_side(self.side), + order_type=enum_parser.parse_binance_order_type(self.type), + contingency_type=contingency_type, + time_in_force=enum_parser.parse_binance_time_in_force(self.timeInForce), + order_status=enum_parser.parse_binance_order_status(self.status), + price=Price.from_str(str(Decimal(self.price))), + trigger_price=Price.from_str(str(trigger_price)), + trigger_type=trigger_type, + trailing_offset=trailing_offset, + trailing_offset_type=trailing_offset_type, + quantity=Quantity.from_str(self.origQty), + filled_qty=Quantity.from_str(self.executedQty), + avg_px=avg_px, + post_only=post_only, + reduce_only=reduce_only, + ts_accepted=millis_to_nanos(self.time), + ts_last=millis_to_nanos(self.updateTime), + report_id=report_id, + ts_init=ts_init, + ) + + +class BinanceStatusCode(msgspec.Struct, frozen=True): + """ + HTTP response status code + """ + + code: int + msg: str diff --git a/nautilus_trader/adapters/binance/common/schemas/market.py b/nautilus_trader/adapters/binance/common/schemas/market.py new file mode 100644 index 000000000000..0f695f0b03d7 --- /dev/null +++ b/nautilus_trader/adapters/binance/common/schemas/market.py @@ -0,0 +1,635 @@ +# ------------------------------------------------------------------------------------------------- +# Copyright (C) 2015-2023 Nautech Systems Pty Ltd. All rights reserved. +# https://nautechsystems.io +# +# Licensed under the GNU Lesser General Public License Version 3.0 (the "License"); +# You may not use this file except in compliance with the License. +# You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ------------------------------------------------------------------------------------------------- + +from decimal import Decimal +from typing import Optional + +import msgspec + +from nautilus_trader.adapters.binance.common.enums import BinanceEnumParser +from nautilus_trader.adapters.binance.common.enums import BinanceExchangeFilterType +from nautilus_trader.adapters.binance.common.enums import BinanceKlineInterval +from nautilus_trader.adapters.binance.common.enums import BinanceRateLimitInterval +from nautilus_trader.adapters.binance.common.enums import BinanceRateLimitType +from nautilus_trader.adapters.binance.common.enums import BinanceSymbolFilterType +from nautilus_trader.adapters.binance.common.types import BinanceBar +from nautilus_trader.adapters.binance.common.types import BinanceTicker +from nautilus_trader.core.datetime import millis_to_nanos +from nautilus_trader.model.data.bar import BarType +from nautilus_trader.model.data.tick import QuoteTick +from nautilus_trader.model.data.tick import TradeTick +from nautilus_trader.model.enums import AggregationSource +from nautilus_trader.model.enums import AggressorSide +from nautilus_trader.model.enums import BookAction +from nautilus_trader.model.enums import BookType +from nautilus_trader.model.enums import OrderSide +from nautilus_trader.model.identifiers import InstrumentId +from nautilus_trader.model.identifiers import TradeId +from nautilus_trader.model.objects import Price +from nautilus_trader.model.objects import Quantity +from nautilus_trader.model.orderbook.data import BookOrder +from nautilus_trader.model.orderbook.data import OrderBookDelta +from nautilus_trader.model.orderbook.data import OrderBookDeltas +from nautilus_trader.model.orderbook.data import OrderBookSnapshot + + +################################################################################ +# HTTP responses +################################################################################ + + +class BinanceTime(msgspec.Struct, frozen=True): + """ + Schema of current server time + GET response of `time` + """ + + serverTime: int + + +class BinanceExchangeFilter(msgspec.Struct): + """ + Schema of an exchange filter, within response of GET `exchangeInfo` + """ + + filterType: BinanceExchangeFilterType + maxNumOrders: Optional[int] = None + maxNumAlgoOrders: Optional[int] = None + + +class BinanceRateLimit(msgspec.Struct): + """ + Schema of rate limit info, within response of GET `exchangeInfo` + """ + + rateLimitType: BinanceRateLimitType + interval: BinanceRateLimitInterval + intervalNum: int + limit: int + count: Optional[int] = None # SPOT/MARGIN rateLimit/order response only + + +class BinanceSymbolFilter(msgspec.Struct): + """ + Schema of a symbol filter, within response of GET `exchangeInfo` + """ + + filterType: BinanceSymbolFilterType + minPrice: Optional[str] = None + maxPrice: Optional[str] = None + tickSize: Optional[str] = None + multiplierUp: Optional[str] = None + multiplierDown: Optional[str] = None + multiplierDecimal: Optional[str] = None + avgPriceMins: Optional[int] = None + minQty: Optional[str] = None + maxQty: Optional[str] = None + stepSize: Optional[str] = None + limit: Optional[int] = None + maxNumOrders: Optional[int] = None + + notional: Optional[str] = None # SPOT/MARGIN & USD-M FUTURES only + minNotional: Optional[str] = None # SPOT/MARGIN & USD-M FUTURES only + maxNumAlgoOrders: Optional[int] = None # SPOT/MARGIN & USD-M FUTURES only + + bidMultiplierUp: Optional[str] = None # SPOT/MARGIN only + bidMultiplierDown: Optional[str] = None # SPOT/MARGIN only + askMultiplierUp: Optional[str] = None # SPOT/MARGIN only + askMultiplierDown: Optional[str] = None # SPOT/MARGIN only + applyMinToMarket: Optional[bool] = None # SPOT/MARGIN only + maxNotional: Optional[str] = None # SPOT/MARGIN only + applyMaxToMarket: Optional[bool] = None # SPOT/MARGIN only + maxNumIcebergOrders: Optional[int] = None # SPOT/MARGIN only + maxPosition: Optional[str] = None # SPOT/MARGIN only + minTrailingAboveDelta: Optional[int] = None # SPOT/MARGIN only + maxTrailingAboveDelta: Optional[int] = None # SPOT/MARGIN only + minTrailingBelowDelta: Optional[int] = None # SPOT/MARGIN only + maxTrailingBelowDetla: Optional[int] = None # SPOT/MARGIN only + + +class BinanceDepth(msgspec.Struct, frozen=True): + """ + Schema of a binance orderbook depth. + GET response of `depth` + """ + + lastUpdateId: int + bids: list[tuple[str, str]] + asks: list[tuple[str, str]] + + symbol: Optional[str] = None # COIN-M FUTURES only + pair: Optional[str] = None # COIN-M FUTURES only + + E: Optional[int] = None # FUTURES only, Message output time + T: Optional[int] = None # FUTURES only, Transaction time + + def parse_to_order_book_snapshot( + self, + instrument_id: InstrumentId, + ts_init: int, + ) -> OrderBookSnapshot: + return OrderBookSnapshot( + instrument_id=instrument_id, + book_type=BookType.L2_MBP, + bids=[[float(o[0]), float(o[1])] for o in self.bids or []], + asks=[[float(o[0]), float(o[1])] for o in self.asks or []], + ts_event=ts_init, + ts_init=ts_init, + sequence=self.lastUpdateId or 0, + ) + + +class BinanceTrade(msgspec.Struct, frozen=True): + """Schema of a single trade.""" + + id: int + price: str + qty: str + quoteQty: str + time: int + isBuyerMaker: bool + isBestMatch: Optional[bool] = None # SPOT/MARGIN only + + def parse_to_trade_tick( + self, + instrument_id: InstrumentId, + ts_init: int, + ) -> TradeTick: + """Parse Binance trade to internal TradeTick""" + return TradeTick( + instrument_id=instrument_id, + price=Price.from_str(self.price), + size=Quantity.from_str(self.qty), + aggressor_side=AggressorSide.SELLER if self.isBuyerMaker else AggressorSide.BUYER, + trade_id=TradeId(str(self.id)), + ts_event=millis_to_nanos(self.time), + ts_init=ts_init, + ) + + +class BinanceAggTrade(msgspec.Struct, frozen=True): + """Schema of a single compressed aggregate trade""" + + a: int # Aggregate tradeId + p: str # Price + q: str # Quantity + f: int # First tradeId + l: int # Last tradeId + T: int # Timestamp + m: bool # Was the buyer the maker? + M: Optional[bool] = None # SPOT/MARGIN only, was the trade the best price match? + + +class BinanceKline(msgspec.Struct, array_like=True): + """Array-like schema of single Binance kline""" + + open_time: int + open: str + high: str + low: str + close: str + volume: str + close_time: int + asset_volume: str + trades_count: int + taker_base_volume: str + taker_quote_volume: str + ignore: str + + def parse_to_binance_bar( + self, + bar_type: BarType, + ts_init: int, + ) -> BinanceBar: + """Parse kline to BinanceBar""" + return BinanceBar( + bar_type=bar_type, + open=Price.from_str(self.open), + high=Price.from_str(self.high), + low=Price.from_str(self.low), + close=Price.from_str(self.close), + volume=Quantity.from_str(self.volume), + quote_volume=Decimal(self.asset_volume), + count=self.trades_count, + taker_buy_base_volume=Decimal(self.taker_base_volume), + taker_buy_quote_volume=Decimal(self.taker_quote_volume), + ts_event=millis_to_nanos(self.open_time), + ts_init=ts_init, + ) + + +class BinanceTicker24hr(msgspec.Struct, frozen=True): + """Schema of single Binance 24hr ticker (FULL/MINI)""" + + symbol: Optional[str] + lastPrice: Optional[str] + openPrice: Optional[str] + highPrice: Optional[str] + lowPrice: Optional[str] + volume: Optional[str] + openTime: Optional[int] + closeTime: Optional[int] + firstId: Optional[int] + lastId: Optional[int] + count: Optional[int] + + priceChange: Optional[str] = None # FULL response only (SPOT/MARGIN) + priceChangePercent: Optional[str] = None # FULL response only (SPOT/MARGIN) + weightedAvgPrice: Optional[str] = None # FULL response only (SPOT/MARGIN) + lastQty: Optional[str] = None # FULL response only (SPOT/MARGIN) + + prevClosePrice: Optional[str] = None # SPOT/MARGIN only + bidPrice: Optional[str] = None # SPOT/MARGIN only + bidQty: Optional[str] = None # SPOT/MARGIN only + askPrice: Optional[str] = None # SPOT/MARGIN only + askQty: Optional[str] = None # SPOT/MARGIN only + + pair: Optional[str] = None # COIN-M FUTURES only + baseVolume: Optional[str] = None # COIN-M FUTURES only + + quoteVolume: Optional[str] = None # SPOT/MARGIN & USD-M FUTURES only + + +class BinanceTickerPrice(msgspec.Struct, frozen=True): + """Schema of single Binance Price Ticker""" + + symbol: Optional[str] + price: Optional[str] + time: Optional[int] = None # FUTURES only + ps: Optional[str] = None # COIN-M FUTURES only, pair + + +class BinanceTickerBook(msgspec.Struct, frozen=True): + """Schema of a single Binance Order Book Ticker""" + + symbol: Optional[str] + bidPrice: Optional[str] + bidQty: Optional[str] + askPrice: Optional[str] + askQty: Optional[str] + pair: Optional[str] = None # USD-M FUTURES only + time: Optional[int] = None # FUTURES only, transaction time + + +################################################################################ +# WebSocket messages +################################################################################ + + +class BinanceDataMsgWrapper(msgspec.Struct): + """ + Provides a wrapper for data WebSocket messages from `Binance`. + """ + + stream: str + + +class BinanceOrderBookDelta(msgspec.Struct, array_like=True): + """Schema of single ask/bid delta""" + + price: str + size: str + + def parse_to_order_book_delta( + self, + instrument_id: InstrumentId, + side: OrderSide, + ts_event: int, + ts_init: int, + update_id: int, + ) -> OrderBookDelta: + price = float(self.price) + size = float(self.size) + + order = BookOrder( + price=price, + size=size, + side=side, + ) + + return OrderBookDelta( + instrument_id=instrument_id, + book_type=BookType.L2_MBP, + action=BookAction.UPDATE if size > 0.0 else BookAction.DELETE, + order=order, + ts_event=ts_event, + ts_init=ts_init, + sequence=update_id, + ) + + +class BinanceOrderBookData(msgspec.Struct, frozen=True): + """WebSocket message 'inner struct' for `Binance` Partial & Diff. Book Depth Streams.""" + + e: str # Event type + E: int # Event time + s: str # Symbol + U: int # First update ID in event + u: int # Final update ID in event + b: list[BinanceOrderBookDelta] # Bids to be updated + a: list[BinanceOrderBookDelta] # Asks to be updated + + T: Optional[int] = None # FUTURES only, transaction time + pu: Optional[int] = None # FUTURES only, previous final update ID + + ps: Optional[str] = None # COIN-M FUTURES only, pair + + def parse_to_order_book_deltas( + self, + instrument_id: InstrumentId, + ts_init: int, + ) -> OrderBookDeltas: + ts_event: int = millis_to_nanos(self.T) if self.T is not None else millis_to_nanos(self.E) + + bid_deltas: list[OrderBookDelta] = [ + delta.parse_to_order_book_delta(instrument_id, OrderSide.BUY, ts_event, ts_init, self.u) + for delta in self.b + ] + ask_deltas: list[OrderBookDelta] = [ + delta.parse_to_order_book_delta( + instrument_id, + OrderSide.SELL, + ts_event, + ts_init, + self.u, + ) + for delta in self.a + ] + + return OrderBookDeltas( + instrument_id=instrument_id, + book_type=BookType.L2_MBP, + deltas=bid_deltas + ask_deltas, + ts_event=ts_event, + ts_init=ts_init, + sequence=self.u, + ) + + def parse_to_order_book_snapshot( + self, + instrument_id: InstrumentId, + ts_init: int, + ) -> OrderBookSnapshot: + return OrderBookSnapshot( + instrument_id=instrument_id, + book_type=BookType.L2_MBP, + bids=[[float(o.price), float(o.size)] for o in self.b], + asks=[[float(o.price), float(o.size)] for o in self.a], + ts_event=millis_to_nanos(self.T), + ts_init=ts_init, + sequence=self.u, + ) + + +class BinanceOrderBookMsg(msgspec.Struct, frozen=True): + """WebSocket message from `Binance` Partial & Diff. Book Depth Streams.""" + + stream: str + data: BinanceOrderBookData + + +class BinanceQuoteData(msgspec.Struct, frozen=True): + """WebSocket message from `Binance` Individual Symbol Book Ticker Streams.""" + + s: str # symbol + u: int # order book updateId + b: str # best bid price + B: str # best bid qty + a: str # best ask price + A: str # best ask qty + + def parse_to_quote_tick( + self, + instrument_id: InstrumentId, + ts_init: int, + ) -> QuoteTick: + return QuoteTick( + instrument_id=instrument_id, + bid=Price.from_str(self.b), + ask=Price.from_str(self.a), + bid_size=Quantity.from_str(self.B), + ask_size=Quantity.from_str(self.A), + ts_event=ts_init, + ts_init=ts_init, + ) + + +class BinanceQuoteMsg(msgspec.Struct, frozen=True): + """WebSocket message from `Binance` Individual Symbol Book Ticker Streams.""" + + stream: str + data: BinanceQuoteData + + +class BinanceAggregatedTradeData(msgspec.Struct, frozen=True): + """WebSocket message from `Binance` Aggregate Trade Streams.""" + + e: str # Event type + E: int # Event time + s: str # Symbol + a: int # Aggregate trade ID + p: str # Price + q: str # Quantity + f: int # First trade ID + l: int # Last trade ID + T: int # Trade time + m: bool # Is the buyer the market maker? + + +class BinanceAggregatedTradeMsg(msgspec.Struct, frozen=True): + """WebSocket message.""" + + stream: str + data: BinanceAggregatedTradeData + + +class BinanceTickerData(msgspec.Struct, kw_only=True, frozen=True): + """ + WebSocket message from `Binance` 24hr Ticker + + Fields + ------ + - e: Event type + - E: Event time + - s: Symbol + - p: Price change + - P: Price change percent + - w: Weighted average price + - x: Previous close price + - c: Last price + - Q: Last quantity + - b: Best bid price + - B: Best bid quantity + - a: Best ask price + - A: Best ask quantity + - o: Open price + - h: High price + - l: Low price + - v: Total traded base asset volume + - q: Total traded quote asset volume + - O: Statistics open time + - C: Statistics close time + - F: First trade ID + - L: Last trade ID + - n: Total number of trades + """ + + e: str # Event type + E: int # Event time + s: str # Symbol + p: str # Price change + P: str # Price change percent + w: str # Weighted average price + x: Optional[str] = None # First trade(F)-1 price (first trade before the 24hr rolling window) + c: str # Last price + Q: str # Last quantity + b: Optional[str] = None # Best bid price + B: Optional[str] = None # Best bid quantity + a: Optional[str] = None # Best ask price + A: Optional[str] = None # Best ask quantity + o: str # Open price + h: str # High price + l: str # Low price + v: str # Total traded base asset volume + q: str # Total traded quote asset volume + O: int # Statistics open time + C: int # Statistics close time + F: int # First trade ID + L: int # Last trade ID + n: int # Total number of trades + + def parse_to_binance_ticker( + self, + instrument_id: InstrumentId, + ts_init: int, + ) -> BinanceTicker: + return BinanceTicker( + instrument_id=instrument_id, + price_change=Decimal(self.p), + price_change_percent=Decimal(self.P), + weighted_avg_price=Decimal(self.w), + prev_close_price=Decimal(self.x) if self.x is not None else None, + last_price=Decimal(self.c), + last_qty=Decimal(self.Q), + bid_price=Decimal(self.b) if self.b is not None else None, + bid_qty=Decimal(self.B) if self.B is not None else None, + ask_price=Decimal(self.a) if self.a is not None else None, + ask_qty=Decimal(self.A) if self.A is not None else None, + open_price=Decimal(self.o), + high_price=Decimal(self.h), + low_price=Decimal(self.l), + volume=Decimal(self.v), + quote_volume=Decimal(self.q), + open_time_ms=self.O, + close_time_ms=self.C, + first_id=self.F, + last_id=self.L, + count=self.n, + ts_event=millis_to_nanos(self.E), + ts_init=ts_init, + ) + + +class BinanceTickerMsg(msgspec.Struct, frozen=True): + """WebSocket message.""" + + stream: str + data: BinanceTickerData + + +class BinanceCandlestick(msgspec.Struct, frozen=True): + """ + WebSocket message 'inner struct' for `Binance` Kline/Candlestick Streams. + + Fields + ------ + - t: Kline start time + - T: Kline close time + - s: Symbol + - i: Interval + - f: First trade ID + - L: Last trade ID + - o: Open price + - c: Close price + - h: High price + - l: Low price + - v: Base asset volume + - n: Number of trades + - x: Is this kline closed? + - q: Quote asset volume + - V: Taker buy base asset volume + - Q: Taker buy quote asset volume + - B: Ignore + """ + + t: int # Kline start time + T: int # Kline close time + s: str # Symbol + i: BinanceKlineInterval # Interval + f: int # First trade ID + L: int # Last trade ID + o: str # Open price + c: str # Close price + h: str # High price + l: str # Low price + v: str # Base asset volume + n: int # Number of trades + x: bool # Is this kline closed? + q: str # Quote asset volume + V: str # Taker buy base asset volume + Q: str # Taker buy quote asset volume + B: str # Ignore + + def parse_to_binance_bar( + self, + instrument_id: InstrumentId, + enum_parser: BinanceEnumParser, + ts_init: int, + ) -> BinanceBar: + bar_type = BarType( + instrument_id=instrument_id, + bar_spec=enum_parser.parse_binance_kline_interval_to_bar_spec(self.i), + aggregation_source=AggregationSource.EXTERNAL, + ) + return BinanceBar( + bar_type=bar_type, + open=Price.from_str(self.o), + high=Price.from_str(self.h), + low=Price.from_str(self.l), + close=Price.from_str(self.c), + volume=Quantity.from_str(self.v), + quote_volume=Decimal(self.q), + count=self.n, + taker_buy_base_volume=Decimal(self.V), + taker_buy_quote_volume=Decimal(self.Q), + ts_event=millis_to_nanos(self.T), + ts_init=ts_init, + ) + + +class BinanceCandlestickData(msgspec.Struct, frozen=True): + """WebSocket message 'inner struct'.""" + + e: str + E: int + s: str + k: BinanceCandlestick + + +class BinanceCandlestickMsg(msgspec.Struct, frozen=True): + """WebSocket message for `Binance` Kline/Candlestick Streams.""" + + stream: str + data: BinanceCandlestickData diff --git a/nautilus_trader/adapters/binance/common/schemas/symbol.py b/nautilus_trader/adapters/binance/common/schemas/symbol.py new file mode 100644 index 000000000000..c1b97254de13 --- /dev/null +++ b/nautilus_trader/adapters/binance/common/schemas/symbol.py @@ -0,0 +1,60 @@ +# ------------------------------------------------------------------------------------------------- +# Copyright (C) 2015-2023 Nautech Systems Pty Ltd. All rights reserved. +# https://nautechsystems.io +# +# Licensed under the GNU Lesser General Public License Version 3.0 (the "License"); +# You may not use this file except in compliance with the License. +# You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ------------------------------------------------------------------------------------------------- + +import json + +from nautilus_trader.adapters.binance.common.enums import BinanceAccountType + + +################################################################################ +# HTTP responses +################################################################################ + + +class BinanceSymbol(str): + """Binance compatible symbol""" + + def __new__(cls, symbol: str): + if symbol is not None: + # Format the string on construction to be binance compatible + return super().__new__( + cls, + symbol.upper().replace(" ", "").replace("/", "").replace("-PERP", ""), + ) + + def parse_binance_to_internal(self, account_type: BinanceAccountType) -> str: + if account_type.is_spot_or_margin: + return str(self) + + # Parse Futures symbol + if self[-1].isdigit(): + return str(self) # Deliverable + if self.endswith("_PERP"): + return str(self).replace("_", "-") + else: + return str(self) + "-PERP" + + +class BinanceSymbols(str): + """Binance compatible list of symbols""" + + def __new__(cls, symbols: list[str]): + if symbols is not None: + binance_symbols: list[BinanceSymbol] = [BinanceSymbol(symbol) for symbol in symbols] + return super().__new__(cls, json.dumps(binance_symbols).replace(" ", "")) + + def parse_str_to_list(self) -> list[BinanceSymbol]: + binance_symbols: list[BinanceSymbol] = json.loads(self) + return binance_symbols diff --git a/nautilus_trader/adapters/binance/http/enums.py b/nautilus_trader/adapters/binance/common/schemas/user.py similarity index 72% rename from nautilus_trader/adapters/binance/http/enums.py rename to nautilus_trader/adapters/binance/common/schemas/user.py index d17da2174597..d3887333b69d 100644 --- a/nautilus_trader/adapters/binance/http/enums.py +++ b/nautilus_trader/adapters/binance/common/schemas/user.py @@ -13,14 +13,15 @@ # limitations under the License. # ------------------------------------------------------------------------------------------------- -from enum import Enum +import msgspec -class NewOrderRespType(Enum): - """ - Represents a `Binance` newOrderRespType. - """ +################################################################################ +# HTTP responses +################################################################################ - ACK = "ACK" - RESULT = "RESULT" - FULL = "FULL" + +class BinanceListenKey(msgspec.Struct): + """HTTP response from creating a new `Binance` user listen key.""" + + listenKey: str diff --git a/nautilus_trader/adapters/binance/factories.py b/nautilus_trader/adapters/binance/factories.py index f738837e749d..592b4478714b 100644 --- a/nautilus_trader/adapters/binance/factories.py +++ b/nautilus_trader/adapters/binance/factories.py @@ -108,6 +108,7 @@ def get_cached_binance_http_client( def get_cached_binance_spot_instrument_provider( client: BinanceHttpClient, logger: Logger, + clock: LiveClock, account_type: BinanceAccountType, config: InstrumentProviderConfig, ) -> BinanceSpotInstrumentProvider: @@ -122,6 +123,8 @@ def get_cached_binance_spot_instrument_provider( The client for the instrument provider. logger : Logger The logger for the instrument provider. + clock : LiveClock + The clock for the instrument provider. account_type : BinanceAccountType The Binance account type for the instrument provider. config : InstrumentProviderConfig @@ -135,6 +138,7 @@ def get_cached_binance_spot_instrument_provider( return BinanceSpotInstrumentProvider( client=client, logger=logger, + clock=clock, account_type=account_type, config=config, ) @@ -144,6 +148,7 @@ def get_cached_binance_spot_instrument_provider( def get_cached_binance_futures_instrument_provider( client: BinanceHttpClient, logger: Logger, + clock: LiveClock, account_type: BinanceAccountType, config: InstrumentProviderConfig, ) -> BinanceFuturesInstrumentProvider: @@ -158,6 +163,8 @@ def get_cached_binance_futures_instrument_provider( The client for the instrument provider. logger : Logger The logger for the instrument provider. + clock : LiveClock + The clock for the instrument provider. account_type : BinanceAccountType The Binance account type for the instrument provider. config : InstrumentProviderConfig @@ -171,6 +178,7 @@ def get_cached_binance_futures_instrument_provider( return BinanceFuturesInstrumentProvider( client=client, logger=logger, + clock=clock, account_type=account_type, config=config, ) @@ -241,11 +249,12 @@ def create( # type: ignore ) provider: Union[BinanceSpotInstrumentProvider, BinanceFuturesInstrumentProvider] - if config.account_type.is_spot or config.account_type.is_margin: + if config.account_type.is_spot_or_margin: # Get instrument provider singleton provider = get_cached_binance_spot_instrument_provider( client=client, logger=logger, + clock=clock, account_type=config.account_type, config=config.instrument_provider, ) @@ -267,6 +276,7 @@ def create( # type: ignore provider = get_cached_binance_futures_instrument_provider( client=client, logger=logger, + clock=clock, account_type=config.account_type, config=config.instrument_provider, ) @@ -355,6 +365,7 @@ def create( # type: ignore provider = get_cached_binance_spot_instrument_provider( client=client, logger=logger, + clock=clock, account_type=config.account_type, config=config.instrument_provider, ) @@ -378,6 +389,7 @@ def create( # type: ignore provider = get_cached_binance_futures_instrument_provider( client=client, logger=logger, + clock=clock, account_type=config.account_type, config=config.instrument_provider, ) @@ -400,12 +412,12 @@ def create( # type: ignore def _get_api_key(account_type: BinanceAccountType, is_testnet: bool) -> str: if is_testnet: - if account_type.is_spot or account_type.is_margin: + if account_type.is_spot_or_margin: return os.environ["BINANCE_TESTNET_API_KEY"] else: return os.environ["BINANCE_FUTURES_TESTNET_API_KEY"] - if account_type.is_spot or account_type.is_margin: + if account_type.is_spot_or_margin: return os.environ["BINANCE_API_KEY"] else: return os.environ["BINANCE_FUTURES_API_KEY"] @@ -413,12 +425,12 @@ def _get_api_key(account_type: BinanceAccountType, is_testnet: bool) -> str: def _get_api_secret(account_type: BinanceAccountType, is_testnet: bool) -> str: if is_testnet: - if account_type.is_spot or account_type.is_margin: + if account_type.is_spot_or_margin: return os.environ["BINANCE_TESTNET_API_SECRET"] else: return os.environ["BINANCE_FUTURES_TESTNET_API_SECRET"] - if account_type.is_spot or account_type.is_margin: + if account_type.is_spot_or_margin: return os.environ["BINANCE_API_SECRET"] else: return os.environ["BINANCE_FUTURES_API_SECRET"] @@ -427,7 +439,7 @@ def _get_api_secret(account_type: BinanceAccountType, is_testnet: bool) -> str: def _get_http_base_url(account_type: BinanceAccountType, is_testnet: bool, is_us: bool) -> str: # Testnet base URLs if is_testnet: - if account_type in (BinanceAccountType.SPOT, BinanceAccountType.MARGIN): + if account_type.is_spot_or_margin: return "https://testnet.binance.vision" elif account_type == BinanceAccountType.FUTURES_USDT: return "https://testnet.binancefuture.com" @@ -440,9 +452,9 @@ def _get_http_base_url(account_type: BinanceAccountType, is_testnet: bool, is_us # Live base URLs top_level_domain: str = "us" if is_us else "com" - if account_type == BinanceAccountType.SPOT: + if account_type.is_spot: return f"https://api.binance.{top_level_domain}" - elif account_type == BinanceAccountType.MARGIN: + elif account_type.is_margin: return f"https://sapi.binance.{top_level_domain}" elif account_type == BinanceAccountType.FUTURES_USDT: return f"https://fapi.binance.{top_level_domain}" @@ -457,7 +469,7 @@ def _get_http_base_url(account_type: BinanceAccountType, is_testnet: bool, is_us def _get_ws_base_url(account_type: BinanceAccountType, is_testnet: bool, is_us: bool) -> str: # Testnet base URLs if is_testnet: - if account_type in (BinanceAccountType.SPOT, BinanceAccountType.MARGIN): + if account_type.is_spot_or_margin: return "wss://testnet.binance.vision" elif account_type == BinanceAccountType.FUTURES_USDT: return "wss://stream.binancefuture.com" @@ -470,7 +482,7 @@ def _get_ws_base_url(account_type: BinanceAccountType, is_testnet: bool, is_us: # Live base URLs top_level_domain: str = "us" if is_us else "com" - if account_type in (BinanceAccountType.SPOT, BinanceAccountType.MARGIN): + if account_type.is_spot_or_margin: return f"wss://stream.binance.{top_level_domain}:9443" elif account_type == BinanceAccountType.FUTURES_USDT: return f"wss://fstream.binance.{top_level_domain}" diff --git a/nautilus_trader/adapters/binance/futures/data.py b/nautilus_trader/adapters/binance/futures/data.py index 29584e2c4022..1eda8aceb218 100644 --- a/nautilus_trader/adapters/binance/futures/data.py +++ b/nautilus_trader/adapters/binance/futures/data.py @@ -14,67 +14,32 @@ # ------------------------------------------------------------------------------------------------- import asyncio -from typing import Any, Optional +from typing import Optional import msgspec -import pandas as pd -from nautilus_trader.adapters.binance.common.constants import BINANCE_VENUE +from nautilus_trader.adapters.binance.common.data import BinanceCommonDataClient from nautilus_trader.adapters.binance.common.enums import BinanceAccountType -from nautilus_trader.adapters.binance.common.functions import parse_symbol -from nautilus_trader.adapters.binance.common.parsing.data import parse_bar_http -from nautilus_trader.adapters.binance.common.parsing.data import parse_bar_ws -from nautilus_trader.adapters.binance.common.parsing.data import parse_diff_depth_stream_ws -from nautilus_trader.adapters.binance.common.parsing.data import parse_quote_tick_ws -from nautilus_trader.adapters.binance.common.parsing.data import parse_ticker_24hr_ws -from nautilus_trader.adapters.binance.common.parsing.data import parse_trade_tick_http -from nautilus_trader.adapters.binance.common.schemas import BinanceCandlestickMsg -from nautilus_trader.adapters.binance.common.schemas import BinanceDataMsgWrapper -from nautilus_trader.adapters.binance.common.schemas import BinanceOrderBookMsg -from nautilus_trader.adapters.binance.common.schemas import BinanceQuoteMsg -from nautilus_trader.adapters.binance.common.schemas import BinanceTickerMsg -from nautilus_trader.adapters.binance.common.schemas import BinanceTrade -from nautilus_trader.adapters.binance.common.types import BinanceBar -from nautilus_trader.adapters.binance.common.types import BinanceTicker +from nautilus_trader.adapters.binance.futures.enums import BinanceFuturesEnumParser from nautilus_trader.adapters.binance.futures.http.market import BinanceFuturesMarketHttpAPI -from nautilus_trader.adapters.binance.futures.http.user import BinanceFuturesUserDataHttpAPI -from nautilus_trader.adapters.binance.futures.parsing.data import parse_futures_book_snapshot -from nautilus_trader.adapters.binance.futures.parsing.data import parse_futures_mark_price_ws -from nautilus_trader.adapters.binance.futures.parsing.data import parse_futures_trade_tick_ws from nautilus_trader.adapters.binance.futures.schemas.market import BinanceFuturesMarkPriceMsg from nautilus_trader.adapters.binance.futures.schemas.market import BinanceFuturesTradeMsg from nautilus_trader.adapters.binance.futures.types import BinanceFuturesMarkPriceUpdate from nautilus_trader.adapters.binance.http.client import BinanceHttpClient -from nautilus_trader.adapters.binance.websocket.client import BinanceWebSocketClient from nautilus_trader.cache.cache import Cache from nautilus_trader.common.clock import LiveClock -from nautilus_trader.common.enums import LogColor from nautilus_trader.common.logging import Logger from nautilus_trader.common.providers import InstrumentProvider -from nautilus_trader.core.asynchronous import sleep0 -from nautilus_trader.core.datetime import secs_to_millis -from nautilus_trader.core.uuid import UUID4 -from nautilus_trader.live.data_client import LiveMarketDataClient -from nautilus_trader.model.data.bar import BarType from nautilus_trader.model.data.base import DataType from nautilus_trader.model.data.base import GenericData -from nautilus_trader.model.data.tick import QuoteTick from nautilus_trader.model.data.tick import TradeTick -from nautilus_trader.model.enums import BarAggregation -from nautilus_trader.model.enums import BookType -from nautilus_trader.model.enums import PriceType -from nautilus_trader.model.enums import bar_aggregation_to_str -from nautilus_trader.model.identifiers import ClientId from nautilus_trader.model.identifiers import InstrumentId -from nautilus_trader.model.identifiers import Symbol -from nautilus_trader.model.instruments.base import Instrument from nautilus_trader.model.orderbook.data import OrderBookData -from nautilus_trader.model.orderbook.data import OrderBookDeltas from nautilus_trader.model.orderbook.data import OrderBookSnapshot from nautilus_trader.msgbus.bus import MessageBus -class BinanceFuturesDataClient(LiveMarketDataClient): +class BinanceFuturesDataClient(BinanceCommonDataClient): """ Provides a data client for the `Binance Futures` exchange. @@ -112,101 +77,38 @@ def __init__( account_type: BinanceAccountType = BinanceAccountType.FUTURES_USDT, base_url_ws: Optional[str] = None, ): - super().__init__( - loop=loop, - client_id=ClientId(BINANCE_VENUE.value), - venue=BINANCE_VENUE, - instrument_provider=instrument_provider, - msgbus=msgbus, - cache=cache, - clock=clock, - logger=logger, - ) - - assert account_type.is_futures, "account type is not for futures" - self._binance_account_type = account_type - self._log.info(f"Account type: {self._binance_account_type.value}.", LogColor.BLUE) - - self._update_instruments_interval: int = 60 * 60 # Once per hour (hardcode) - self._update_instruments_task: Optional[asyncio.Task] = None + if not account_type.is_futures: + raise RuntimeError( # pragma: no cover (design-time error) + f"`BinanceAccountType` not FUTURES_USDT or FUTURES_COIN, was {account_type}", # pragma: no cover + ) - # HTTP API - self._http_client = client - self._http_market = BinanceFuturesMarketHttpAPI(client=client, account_type=account_type) - self._http_user = BinanceFuturesUserDataHttpAPI(client=client, account_type=account_type) + # Futures HTTP API + self._futures_http_market = BinanceFuturesMarketHttpAPI(client, account_type) - # Listen keys - self._ping_listen_keys_interval: int = 60 * 5 # Once every 5 mins (hardcode) - self._ping_listen_keys_task: Optional[asyncio.Task] = None - self._listen_key: Optional[str] = None + # Futures enum parser + self._futures_enum_parser = BinanceFuturesEnumParser() - # WebSocket API - self._ws_client = BinanceWebSocketClient( + # Instantiate common base class + super().__init__( loop=loop, + client=client, + market=self._futures_http_market, + enum_parser=self._futures_enum_parser, + msgbus=msgbus, + cache=cache, clock=clock, logger=logger, - handler=self._handle_ws_message, - base_url=base_url_ws, + instrument_provider=instrument_provider, + account_type=account_type, + base_url_ws=base_url_ws, ) - # Hot caches - self._instrument_ids: dict[str, InstrumentId] = {} - self._book_buffer: dict[InstrumentId, list[OrderBookData]] = {} - - self._log.info(f"Base URL HTTP {self._http_client.base_url}.", LogColor.BLUE) - self._log.info(f"Base URL WebSocket {base_url_ws}.", LogColor.BLUE) - - async def _connect(self) -> None: - # Connect HTTP client - if not self._http_client.connected: - await self._http_client.connect() - - await self._instrument_provider.initialize() - - self._send_all_instruments_to_data_engine() - self._update_instruments_task = self.create_task(self._update_instruments()) + # Register additional futures websocket handlers + self._ws_handlers["@markPrice"] = self._handle_mark_price - # Connect WebSocket clients - self.create_task(self._connect_websockets()) - - async def _connect_websockets(self) -> None: - self._log.info("Awaiting subscriptions...") - await asyncio.sleep(4) - if self._ws_client.has_subscriptions: - await self._ws_client.connect() - - async def _update_instruments(self) -> None: - try: - while True: - self._log.debug( - f"Scheduled `update_instruments` to run in " - f"{self._update_instruments_interval}s.", - ) - await asyncio.sleep(self._update_instruments_interval) - await self._instrument_provider.load_all_async() - self._send_all_instruments_to_data_engine() - except asyncio.CancelledError: - self._log.debug("`update_instruments` task was canceled.") - - async def _disconnect(self) -> None: - # Cancel tasks - if self._update_instruments_task: - self._log.debug("Canceling `update_instruments` task...") - self._update_instruments_task.cancel() - self._update_instruments_task.done() - - if self._ping_listen_keys_task: - self._log.debug("Canceling `ping_listen_keys` task...") - self._ping_listen_keys_task.cancel() - self._ping_listen_keys_task.done() - - # Disconnect WebSocket client - if self._ws_client.is_connected: - await self._ws_client.disconnect() - - # Disconnect HTTP client - if self._http_client.connected: - await self._http_client.disconnect() + # Websocket msgspec decoders + self._decoder_futures_trade_msg = msgspec.json.Decoder(BinanceFuturesTradeMsg) + self._decoder_futures_mark_price_msg = msgspec.json.Decoder(BinanceFuturesMarkPriceMsg) # -- SUBSCRIPTIONS ---------------------------------------------------------------------------- @@ -231,148 +133,6 @@ async def _subscribe(self, data_type: DataType) -> None: f"Cannot subscribe to {data_type.type} (not implemented).", ) - async def _subscribe_instruments(self) -> None: - pass # Do nothing further - - async def _subscribe_instrument(self, instrument_id: InstrumentId) -> None: - pass # Do nothing further - - async def _subscribe_order_book_deltas( - self, - instrument_id: InstrumentId, - book_type: BookType, - depth: Optional[int] = None, - kwargs: Optional[dict] = None, - ) -> None: - await self._subscribe_order_book( - instrument_id=instrument_id, - book_type=book_type, - depth=depth, - ) - - async def _subscribe_order_book_snapshots( - self, - instrument_id: InstrumentId, - book_type: BookType, - depth: Optional[int] = None, - kwargs: Optional[dict] = None, - ) -> None: - await self._subscribe_order_book( - instrument_id=instrument_id, - book_type=book_type, - depth=depth, - ) - - async def _subscribe_order_book( - self, - instrument_id: InstrumentId, - book_type: BookType, - depth: Optional[int] = None, - ) -> None: - if book_type == BookType.L3_MBO: - self._log.error( - "Cannot subscribe to order book deltas: " - "L3_MBO data is not published by Binance. " - "Valid book types are L1_TBBO, L2_MBP.", - ) - return - - if depth is None or depth == 0: - depth = 20 - - # Add delta stream buffer - self._book_buffer[instrument_id] = [] - - if 0 < depth <= 20: - if depth not in (5, 10, 20): - self._log.error( - "Cannot subscribe to order book snapshots: " - f"invalid `depth`, was {depth}. " - "Valid depths are 5, 10 or 20.", - ) - return - self._ws_client.subscribe_partial_book_depth( - symbol=instrument_id.symbol.value, - depth=depth, - speed=0, - ) - else: - self._ws_client.subscribe_diff_book_depth( - symbol=instrument_id.symbol.value, - speed=0, - ) - - while not self._ws_client.is_connected: - await sleep0() - - data: dict[str, Any] = await self._http_market.depth( - symbol=instrument_id.symbol.value, - limit=depth, - ) - - ts_event: int = self._clock.timestamp_ns() - last_update_id: int = data.get("lastUpdateId", 0) - - snapshot = OrderBookSnapshot( - instrument_id=instrument_id, - book_type=BookType.L2_MBP, - bids=[[float(o[0]), float(o[1])] for o in data["bids"]], - asks=[[float(o[0]), float(o[1])] for o in data["asks"]], - ts_event=ts_event, - ts_init=ts_event, - sequence=last_update_id, - ) - - self._handle_data(snapshot) - - book_buffer = self._book_buffer.pop(instrument_id, []) - for deltas in book_buffer: - if deltas.sequence <= last_update_id: - continue - self._handle_data(deltas) - - async def _subscribe_ticker(self, instrument_id: InstrumentId) -> None: - self._ws_client.subscribe_ticker(instrument_id.symbol.value) - - async def _subscribe_quote_ticks(self, instrument_id: InstrumentId) -> None: - self._ws_client.subscribe_book_ticker(instrument_id.symbol.value) - - async def _subscribe_trade_ticks(self, instrument_id: InstrumentId) -> None: - self._ws_client.subscribe_trades(instrument_id.symbol.value) - - async def _subscribe_bars(self, bar_type: BarType) -> None: - if not bar_type.spec.is_time_aggregated(): - self._log.error( - f"Cannot subscribe to {bar_type}: only time bars are aggregated by Binance.", - ) - return - - if bar_type.spec.aggregation in (BarAggregation.MILLISECOND, BarAggregation.SECOND): - self._log.error( - f"Cannot subscribe to {bar_type}: " - f"{bar_aggregation_to_str(bar_type.spec.aggregation)} " - f"bars are not aggregated by Binance.", - ) - return - - if bar_type.spec.aggregation == BarAggregation.MINUTE: - resolution = "m" - elif bar_type.spec.aggregation == BarAggregation.HOUR: - resolution = "h" - elif bar_type.spec.aggregation == BarAggregation.DAY: - resolution = "d" - else: - raise RuntimeError( # pragma: no cover (design-time error) - f"invalid `BarAggregation`, " # pragma: no cover - f"was {bar_aggregation_to_str(bar_type.spec.aggregation)}", # pragma: no cover - ) - - self._ws_client.subscribe_bars( - symbol=bar_type.instrument_id.symbol.value, - interval=f"{bar_type.spec.step}{resolution}", - ) - self._add_subscription_bars(bar_type) - async def _unsubscribe(self, data_type: DataType) -> None: if data_type.type == BinanceFuturesMarkPriceUpdate: if not self._binance_account_type.is_futures: @@ -392,240 +152,15 @@ async def _unsubscribe(self, data_type: DataType) -> None: f"Cannot unsubscribe from {data_type.type} (not implemented).", ) - async def _unsubscribe_instruments(self) -> None: - pass # Do nothing further - - async def _unsubscribe_instrument(self, instrument_id: InstrumentId) -> None: - pass # Do nothing further - - async def _unsubscribe_order_book_deltas(self, instrument_id: InstrumentId) -> None: - pass # TODO: Unsubscribe from Binance if no other subscriptions - - async def _unsubscribe_order_book_snapshots(self, instrument_id: InstrumentId) -> None: - pass # TODO: Unsubscribe from Binance if no other subscriptions - - async def _unsubscribe_ticker(self, instrument_id: InstrumentId) -> None: - pass # TODO: Unsubscribe from Binance if no other subscriptions - - async def _unsubscribe_quote_ticks(self, instrument_id: InstrumentId) -> None: - pass # TODO: Unsubscribe from Binance if no other subscriptions - - async def _unsubscribe_trade_ticks(self, instrument_id: InstrumentId) -> None: - pass # TODO: Unsubscribe from Binance if no other subscriptions + # -- WEBSOCKET HANDLERS --------------------------------------------------------------------------------- - async def _unsubscribe_bars(self, bar_type: BarType) -> None: - pass # TODO: Unsubscribe from Binance if no other subscriptions - - # -- REQUESTS --------------------------------------------------------------------------------- - - async def _request_instrument(self, instrument_id: InstrumentId, correlation_id: UUID4) -> None: - instrument: Optional[Instrument] = self._instrument_provider.find(instrument_id) - if instrument is None: - self._log.error(f"Cannot find instrument for {instrument_id}.") - return - - data_type = DataType( - type=Instrument, - metadata={"instrument_id": instrument_id}, - ) - - self._handle_data_response( - data_type=data_type, - data=[instrument], # Data engine handles lists of instruments - correlation_id=correlation_id, - ) - - async def _request_quote_ticks( - self, - instrument_id: InstrumentId, # noqa - limit: int, # noqa - correlation_id: UUID4, # noqa - from_datetime: Optional[pd.Timestamp] = None, # noqa - to_datetime: Optional[pd.Timestamp] = None, # noqa - ) -> None: - self._log.error( - "Cannot request historical quote ticks: not published by Binance.", - ) - - async def _request_trade_ticks( - self, - instrument_id: InstrumentId, - limit: int, - correlation_id: UUID4, - from_datetime: Optional[pd.Timestamp] = None, - to_datetime: Optional[pd.Timestamp] = None, - ) -> None: - if limit == 0 or limit > 1000: - limit = 1000 - - if from_datetime is not None or to_datetime is not None: - self._log.warning( - "Trade ticks have been requested with a from/to time range, " - f"however the request will be for the most recent {limit}.", - ) - - response: list[BinanceTrade] = await self._http_market.trades( - instrument_id.symbol.value, - limit, - ) - - ticks: list[TradeTick] = [ - parse_trade_tick_http( - trade=trade, - instrument_id=instrument_id, - ts_init=self._clock.timestamp_ns(), - ) - for trade in response - ] - - self._handle_trade_ticks(instrument_id, ticks, correlation_id) - - async def _request_bars( # noqa (too complex) - self, - bar_type: BarType, - limit: int, - correlation_id: UUID4, - from_datetime: Optional[pd.Timestamp] = None, - to_datetime: Optional[pd.Timestamp] = None, - ) -> None: - if bar_type.is_internally_aggregated(): - self._log.error( - f"Cannot request {bar_type}: " - f"only historical bars with EXTERNAL aggregation available from Binance.", - ) - return - - if not bar_type.spec.is_time_aggregated(): - self._log.error( - f"Cannot request {bar_type}: only time bars are aggregated by Binance.", - ) - return - - if bar_type.spec.aggregation in (BarAggregation.MILLISECOND, BarAggregation.SECOND): - self._log.error( - f"Cannot request {bar_type}: " - f"{bar_aggregation_to_str(bar_type.spec.aggregation)} " - f"bars are not aggregated by Binance.", - ) - return - - if bar_type.spec.price_type != PriceType.LAST: - self._log.error( - f"Cannot request {bar_type}: " - f"only historical bars for LAST price type available from Binance.", - ) - return - - if limit == 0 or limit > 1000: - limit = 1000 - - if bar_type.spec.aggregation == BarAggregation.MINUTE: - resolution = "m" - elif bar_type.spec.aggregation == BarAggregation.HOUR: - resolution = "h" - elif bar_type.spec.aggregation == BarAggregation.DAY: - resolution = "d" - else: - raise RuntimeError( # pragma: no cover (design-time error) - f"invalid `BarAggregation`, " # pragma: no cover - f"was {bar_aggregation_to_str(bar_type.spec.aggregation)}", # pragma: no cover - ) - - start_time_ms = None - if from_datetime is not None: - start_time_ms = secs_to_millis(from_datetime.timestamp()) - - end_time_ms = None - if to_datetime is not None: - end_time_ms = secs_to_millis(to_datetime.timestamp()) - - data: list[list[Any]] = await self._http_market.klines( - symbol=bar_type.instrument_id.symbol.value, - interval=f"{bar_type.spec.step}{resolution}", - start_time_ms=start_time_ms, - end_time_ms=end_time_ms, - limit=limit, - ) - - bars: list[BinanceBar] = [ - parse_bar_http( - bar_type, - values=b, - ts_init=self._clock.timestamp_ns(), - ) - for b in data - ] - partial: BinanceBar = bars.pop() - - self._handle_bars(bar_type, bars, partial, correlation_id) - - def _send_all_instruments_to_data_engine(self) -> None: - for instrument in self._instrument_provider.get_all().values(): - self._handle_data(instrument) - - for currency in self._instrument_provider.currencies().values(): - self._cache.add_currency(currency) - - def _get_cached_instrument_id(self, symbol: str) -> InstrumentId: - # Parse instrument ID - nautilus_symbol: str = parse_symbol(symbol, account_type=self._binance_account_type) - instrument_id: Optional[InstrumentId] = self._instrument_ids.get(nautilus_symbol) - if not instrument_id: - instrument_id = InstrumentId(Symbol(nautilus_symbol), BINANCE_VENUE) - self._instrument_ids[nautilus_symbol] = instrument_id - return instrument_id - - def _handle_ws_message(self, raw: bytes) -> None: - # TODO(cs): Uncomment for development - # self._log.info(str(raw), LogColor.CYAN) - - wrapper = msgspec.json.decode(raw, type=BinanceDataMsgWrapper) - - try: - if "@depth@" in wrapper.stream: - self._handle_book_diff_update(raw) - elif "@depth" in wrapper.stream: - self._handle_book_update(raw) - elif "@bookTicker" in wrapper.stream: - self._handle_book_ticker(raw) - elif "@trade" in wrapper.stream: - self._handle_trade(raw) - elif "@ticker" in wrapper.stream: - self._handle_ticker(raw) - elif "@kline" in wrapper.stream: - self._handle_kline(raw) - elif "@markPrice" in wrapper.stream: - self._handle_mark_price(raw) - else: - self._log.error( - f"Unrecognized websocket message type " f"{msgspec.json.decode(raw)['stream']}", - ) - except (TypeError, ValueError) as e: - self._log.error(f"Error handling websocket message, {e}") - - def _handle_book_diff_update(self, raw: bytes) -> None: - msg: BinanceOrderBookMsg = msgspec.json.decode(raw, type=BinanceOrderBookMsg) + def _handle_book_partial_update(self, raw: bytes) -> None: + msg = self._decoder_order_book_msg.decode(raw) instrument_id: InstrumentId = self._get_cached_instrument_id(msg.data.s) - book_deltas: OrderBookDeltas = parse_diff_depth_stream_ws( + book_snapshot: OrderBookSnapshot = msg.data.parse_to_order_book_snapshot( instrument_id=instrument_id, - data=msg.data, ts_init=self._clock.timestamp_ns(), ) - book_buffer: Optional[list[OrderBookData]] = self._book_buffer.get(instrument_id) - if book_buffer is not None: - book_buffer.append(book_deltas) - else: - self._handle_data(book_deltas) - - def _handle_book_update(self, raw: bytes) -> None: - msg: BinanceOrderBookMsg = msgspec.json.decode(raw, type=BinanceOrderBookMsg) - instrument_id: InstrumentId = self._get_cached_instrument_id(msg.data.s) - book_snapshot: OrderBookSnapshot = parse_futures_book_snapshot( - instrument_id=instrument_id, - data=msg.data, - ts_init=self._clock.timestamp_ns(), - ) - # Check if book buffer active book_buffer: Optional[list[OrderBookData]] = self._book_buffer.get(instrument_id) if book_buffer is not None: @@ -633,55 +168,21 @@ def _handle_book_update(self, raw: bytes) -> None: else: self._handle_data(book_snapshot) - def _handle_book_ticker(self, raw: bytes) -> None: - msg: BinanceQuoteMsg = msgspec.json.decode(raw, type=BinanceQuoteMsg) - instrument_id: InstrumentId = self._get_cached_instrument_id(msg.data.s) - quote_tick: QuoteTick = parse_quote_tick_ws( - instrument_id=instrument_id, - data=msg.data, - ts_init=self._clock.timestamp_ns(), - ) - self._handle_data(quote_tick) - def _handle_trade(self, raw: bytes) -> None: - msg: BinanceFuturesTradeMsg = msgspec.json.decode(raw, type=BinanceFuturesTradeMsg) + # NOTE @trade is an undocumented endpoint for Futures exchanges + msg = self._decoder_futures_trade_msg.decode(raw) instrument_id: InstrumentId = self._get_cached_instrument_id(msg.data.s) - trade_tick: TradeTick = parse_futures_trade_tick_ws( + trade_tick: TradeTick = msg.data.parse_to_trade_tick( instrument_id=instrument_id, - data=msg.data, ts_init=self._clock.timestamp_ns(), ) self._handle_data(trade_tick) - def _handle_ticker(self, raw: bytes) -> None: - msg: BinanceTickerMsg = msgspec.json.decode(raw, type=BinanceTickerMsg) - instrument_id: InstrumentId = self._get_cached_instrument_id(msg.data.s) - ticker: BinanceTicker = parse_ticker_24hr_ws( - instrument_id=instrument_id, - data=msg.data, - ts_init=self._clock.timestamp_ns(), - ) - self._handle_data(ticker) - - def _handle_kline(self, raw: bytes) -> None: - msg: BinanceCandlestickMsg = msgspec.json.decode(raw, type=BinanceCandlestickMsg) - if not msg.data.k.x: - return # Not closed yet - - instrument_id: InstrumentId = self._get_cached_instrument_id(msg.data.s) - bar: BinanceBar = parse_bar_ws( - instrument_id=instrument_id, - data=msg.data.k, - ts_init=self._clock.timestamp_ns(), - ) - self._handle_data(bar) - def _handle_mark_price(self, raw: bytes) -> None: - msg: BinanceFuturesMarkPriceMsg = msgspec.json.decode(raw, type=BinanceFuturesMarkPriceMsg) + msg = self._decoder_futures_mark_price_msg.decode(raw) instrument_id: InstrumentId = self._get_cached_instrument_id(msg.data.s) - data: BinanceFuturesMarkPriceUpdate = parse_futures_mark_price_ws( + data = msg.data.parse_to_binance_futures_mark_price_update( instrument_id=instrument_id, - data=msg.data, ts_init=self._clock.timestamp_ns(), ) data_type = DataType( diff --git a/nautilus_trader/adapters/binance/futures/enums.py b/nautilus_trader/adapters/binance/futures/enums.py index 795957b02f81..efa15766a600 100644 --- a/nautilus_trader/adapters/binance/futures/enums.py +++ b/nautilus_trader/adapters/binance/futures/enums.py @@ -16,6 +16,14 @@ from enum import Enum from enum import unique +from nautilus_trader.adapters.binance.common.enums import BinanceEnumParser +from nautilus_trader.adapters.binance.common.enums import BinanceOrderType +from nautilus_trader.model.enums import OrderType +from nautilus_trader.model.enums import PositionSide +from nautilus_trader.model.enums import TimeInForce +from nautilus_trader.model.enums import TriggerType +from nautilus_trader.model.orders.base import Order + """ Defines `Binance` Futures specific enums. @@ -51,19 +59,6 @@ class BinanceFuturesContractStatus(Enum): CLOSE = "CLOSE" -@unique -class BinanceFuturesOrderType(Enum): - """Represents a `Binance Futures` price type.""" - - LIMIT = "LIMIT" - MARKET = "MARKET" - STOP = "STOP" - STOP_MARKET = "STOP_MARKET" - TAKE_PROFIT = "TAKE_PROFIT" - TAKE_PROFIT_MARKET = "TAKE_PROFIT_MARKET" - TRAILING_STOP_MARKET = "TRAILING_STOP_MARKET" - - @unique class BinanceFuturesPositionSide(Enum): """Represents a `Binance Futures` position side.""" @@ -73,16 +68,6 @@ class BinanceFuturesPositionSide(Enum): SHORT = "SHORT" -@unique -class BinanceFuturesTimeInForce(Enum): - """Represents a `Binance Futures` order time in force.""" - - GTC = "GTC" - IOC = "IOC" - FOK = "FOK" - GTX = "GTX" # Good Till Crossing (Post Only) - - @unique class BinanceFuturesWorkingType(Enum): """Represents a `Binance Futures` working type.""" @@ -129,3 +114,83 @@ class BinanceFuturesEventType(Enum): ACCOUNT_UPDATE = "ACCOUNT_UPDATE" ORDER_TRADE_UPDATE = "ORDER_TRADE_UPDATE" ACCOUNT_CONFIG_UPDATE = "ACCOUNT_CONFIG_UPDATE" + + +class BinanceFuturesEnumParser(BinanceEnumParser): + """ + Provides parsing methods for enums used by the 'Binance Futures' exchange. + """ + + def __init__(self) -> None: + super().__init__() + + self.futures_ext_to_int_order_type = { + BinanceOrderType.LIMIT: OrderType.LIMIT, + BinanceOrderType.MARKET: OrderType.MARKET, + BinanceOrderType.STOP: OrderType.STOP_LIMIT, + BinanceOrderType.STOP_MARKET: OrderType.STOP_MARKET, + BinanceOrderType.TAKE_PROFIT: OrderType.LIMIT_IF_TOUCHED, + BinanceOrderType.TAKE_PROFIT_MARKET: OrderType.MARKET_IF_TOUCHED, + BinanceOrderType.TRAILING_STOP_MARKET: OrderType.TRAILING_STOP_MARKET, + } + self.futures_int_to_ext_order_type = { + b: a for a, b in self.futures_ext_to_int_order_type.items() + } + + self.futures_ext_to_int_position_side = { + BinanceFuturesPositionSide.BOTH: PositionSide.FLAT, + BinanceFuturesPositionSide.LONG: PositionSide.LONG, + BinanceFuturesPositionSide.SHORT: PositionSide.SHORT, + } + + self.futures_valid_time_in_force = { + TimeInForce.GTC, + TimeInForce.GTD, # Will be transformed to GTC with warning + TimeInForce.FOK, + TimeInForce.IOC, + } + + self.futures_valid_order_types = { + OrderType.MARKET, + OrderType.LIMIT, + OrderType.STOP_MARKET, + OrderType.STOP_LIMIT, + OrderType.MARKET_IF_TOUCHED, + OrderType.LIMIT_IF_TOUCHED, + OrderType.TRAILING_STOP_MARKET, + } + + def parse_binance_order_type(self, order_type: BinanceOrderType) -> OrderType: + try: + return self.futures_ext_to_int_order_type[order_type] + except KeyError: + raise RuntimeError( # pragma: no cover (design-time error) + f"unrecognized Binance Futures order type, was {order_type}", # pragma: no cover + ) + + def parse_internal_order_type(self, order: Order) -> BinanceOrderType: + try: + return self.futures_int_to_ext_order_type[order.order_type] + except KeyError: + raise RuntimeError( # pragma: no cover (design-time error) + f"unrecognized or unsupported internal order type, was {order.order_type}", # pragma: no cover + ) + + def parse_binance_trigger_type(self, trigger_type: str) -> TriggerType: + if trigger_type == BinanceFuturesWorkingType.CONTRACT_PRICE: + return TriggerType.LAST_TRADE + elif trigger_type == BinanceFuturesWorkingType.MARK_PRICE: + return TriggerType.MARK_PRICE + else: + return None + + def parse_futures_position_side( + self, + position_side: BinanceFuturesPositionSide, + ) -> PositionSide: + try: + return self.futures_ext_to_int_position_side[position_side] + except KeyError: + raise RuntimeError( # pragma: no cover (design-time error) + f"unrecognized binance futures position side, was {position_side}", # pragma: no cover + ) diff --git a/nautilus_trader/adapters/binance/futures/execution.py b/nautilus_trader/adapters/binance/futures/execution.py index 166a279c5516..f53ad40a3406 100644 --- a/nautilus_trader/adapters/binance/futures/execution.py +++ b/nautilus_trader/adapters/binance/futures/execution.py @@ -18,101 +18,38 @@ from typing import Optional import msgspec -import pandas as pd from nautilus_trader.accounting.accounts.margin import MarginAccount -from nautilus_trader.adapters.binance.common.constants import BINANCE_VENUE from nautilus_trader.adapters.binance.common.enums import BinanceAccountType -from nautilus_trader.adapters.binance.common.enums import BinanceExecutionType -from nautilus_trader.adapters.binance.common.enums import BinanceOrderSide -from nautilus_trader.adapters.binance.common.functions import format_symbol -from nautilus_trader.adapters.binance.common.functions import parse_symbol -from nautilus_trader.adapters.binance.common.schemas import BinanceListenKey +from nautilus_trader.adapters.binance.common.execution import BinanceCommonExecutionClient +from nautilus_trader.adapters.binance.futures.enums import BinanceFuturesEnumParser from nautilus_trader.adapters.binance.futures.enums import BinanceFuturesEventType -from nautilus_trader.adapters.binance.futures.enums import BinanceFuturesTimeInForce from nautilus_trader.adapters.binance.futures.http.account import BinanceFuturesAccountHttpAPI from nautilus_trader.adapters.binance.futures.http.market import BinanceFuturesMarketHttpAPI from nautilus_trader.adapters.binance.futures.http.user import BinanceFuturesUserDataHttpAPI -from nautilus_trader.adapters.binance.futures.parsing.account import parse_account_balances_http -from nautilus_trader.adapters.binance.futures.parsing.account import parse_account_balances_ws -from nautilus_trader.adapters.binance.futures.parsing.account import parse_account_margins_http -from nautilus_trader.adapters.binance.futures.parsing.execution import binance_order_type -from nautilus_trader.adapters.binance.futures.parsing.execution import parse_order_report_http -from nautilus_trader.adapters.binance.futures.parsing.execution import parse_order_type -from nautilus_trader.adapters.binance.futures.parsing.execution import parse_position_report_http -from nautilus_trader.adapters.binance.futures.parsing.execution import parse_time_in_force -from nautilus_trader.adapters.binance.futures.parsing.execution import parse_trade_report_http -from nautilus_trader.adapters.binance.futures.parsing.execution import parse_trigger_type from nautilus_trader.adapters.binance.futures.providers import BinanceFuturesInstrumentProvider -from nautilus_trader.adapters.binance.futures.rules import BINANCE_FUTURES_VALID_ORDER_TYPES -from nautilus_trader.adapters.binance.futures.rules import BINANCE_FUTURES_VALID_TIF from nautilus_trader.adapters.binance.futures.schemas.account import BinanceFuturesAccountInfo -from nautilus_trader.adapters.binance.futures.schemas.account import BinanceFuturesAccountTrade -from nautilus_trader.adapters.binance.futures.schemas.account import BinanceFuturesOrder from nautilus_trader.adapters.binance.futures.schemas.account import BinanceFuturesPositionRisk -from nautilus_trader.adapters.binance.futures.schemas.user import BinanceFuturesAccountUpdateMsg from nautilus_trader.adapters.binance.futures.schemas.user import BinanceFuturesAccountUpdateWrapper -from nautilus_trader.adapters.binance.futures.schemas.user import BinanceFuturesOrderData -from nautilus_trader.adapters.binance.futures.schemas.user import BinanceFuturesOrderUpdateMsg from nautilus_trader.adapters.binance.futures.schemas.user import BinanceFuturesOrderUpdateWrapper from nautilus_trader.adapters.binance.futures.schemas.user import BinanceFuturesUserMsgWrapper from nautilus_trader.adapters.binance.http.client import BinanceHttpClient -from nautilus_trader.adapters.binance.http.error import BinanceError -from nautilus_trader.adapters.binance.websocket.client import BinanceWebSocketClient from nautilus_trader.cache.cache import Cache from nautilus_trader.common.clock import LiveClock from nautilus_trader.common.enums import LogColor from nautilus_trader.common.logging import Logger -from nautilus_trader.core.correctness import PyCondition from nautilus_trader.core.datetime import millis_to_nanos -from nautilus_trader.core.datetime import secs_to_millis from nautilus_trader.core.uuid import UUID4 -from nautilus_trader.execution.messages import CancelAllOrders -from nautilus_trader.execution.messages import CancelOrder -from nautilus_trader.execution.messages import ModifyOrder -from nautilus_trader.execution.messages import SubmitOrder -from nautilus_trader.execution.messages import SubmitOrderList -from nautilus_trader.execution.reports import OrderStatusReport from nautilus_trader.execution.reports import PositionStatusReport -from nautilus_trader.execution.reports import TradeReport -from nautilus_trader.live.execution_client import LiveExecutionClient -from nautilus_trader.model.enums import AccountType -from nautilus_trader.model.enums import LiquiditySide -from nautilus_trader.model.enums import OmsType -from nautilus_trader.model.enums import OrderSide -from nautilus_trader.model.enums import OrderStatus from nautilus_trader.model.enums import OrderType -from nautilus_trader.model.enums import TimeInForce -from nautilus_trader.model.enums import TrailingOffsetType -from nautilus_trader.model.enums import TriggerType -from nautilus_trader.model.enums import order_side_to_str from nautilus_trader.model.enums import order_type_to_str from nautilus_trader.model.enums import time_in_force_to_str -from nautilus_trader.model.enums import trailing_offset_type_to_str -from nautilus_trader.model.enums import trigger_type_to_str -from nautilus_trader.model.identifiers import AccountId -from nautilus_trader.model.identifiers import ClientId -from nautilus_trader.model.identifiers import ClientOrderId from nautilus_trader.model.identifiers import InstrumentId -from nautilus_trader.model.identifiers import PositionId -from nautilus_trader.model.identifiers import StrategyId -from nautilus_trader.model.identifiers import Symbol -from nautilus_trader.model.identifiers import TradeId -from nautilus_trader.model.identifiers import VenueOrderId -from nautilus_trader.model.instruments.base import Instrument -from nautilus_trader.model.objects import Money -from nautilus_trader.model.objects import Price -from nautilus_trader.model.objects import Quantity from nautilus_trader.model.orders.base import Order -from nautilus_trader.model.orders.limit import LimitOrder -from nautilus_trader.model.orders.market import MarketOrder -from nautilus_trader.model.orders.stop_market import StopMarketOrder -from nautilus_trader.model.orders.trailing_stop_market import TrailingStopMarketOrder -from nautilus_trader.model.position import Position from nautilus_trader.msgbus.bus import MessageBus -class BinanceFuturesExecutionClient(LiveExecutionClient): +class BinanceFuturesExecutionClient(BinanceCommonExecutionClient): """ Provides an execution client for the `Binance Futures` exchange. @@ -157,108 +94,68 @@ def __init__( clock_sync_interval_secs: int = 900, warn_gtd_to_gtc: bool = True, ): + if not account_type.is_futures: + raise RuntimeError( # pragma: no cover (design-time error) + f"`BinanceAccountType` not FUTURES_USDT or FUTURES_COIN, was {account_type}", # pragma: no cover + ) + + # Futures HTTP API + self._futures_http_account = BinanceFuturesAccountHttpAPI(client, clock, account_type) + self._futures_http_market = BinanceFuturesMarketHttpAPI(client, account_type) + self._futures_http_user = BinanceFuturesUserDataHttpAPI(client, account_type) + + # Futures enum parser + self._futures_enum_parser = BinanceFuturesEnumParser() + + # Instantiate common base class super().__init__( loop=loop, - client_id=ClientId(BINANCE_VENUE.value), - venue=BINANCE_VENUE, - oms_type=OmsType.HEDGING, - instrument_provider=instrument_provider, - account_type=AccountType.MARGIN, - base_currency=None, + client=client, + account=self._futures_http_account, + market=self._futures_http_market, + user=self._futures_http_user, + enum_parser=self._futures_enum_parser, msgbus=msgbus, cache=cache, clock=clock, logger=logger, + instrument_provider=instrument_provider, + account_type=account_type, + base_url_ws=base_url_ws, + clock_sync_interval_secs=clock_sync_interval_secs, + warn_gtd_to_gtc=warn_gtd_to_gtc, ) - self._binance_account_type = account_type - self._log.info(f"Account type: {self._binance_account_type.value}.", LogColor.BLUE) - - self._set_account_id(AccountId(f"{BINANCE_VENUE.value}-futures-master")) - - # Settings - self._warn_gtd_to_gtc = warn_gtd_to_gtc - - # Clock sync - self._clock_sync_interval_secs = clock_sync_interval_secs - - # Tasks - self._task_clock_sync: Optional[asyncio.Task] = None - - # HTTP API - self._http_client = client - self._http_account = BinanceFuturesAccountHttpAPI(client=client, account_type=account_type) - self._http_market = BinanceFuturesMarketHttpAPI(client=client, account_type=account_type) - self._http_user = BinanceFuturesUserDataHttpAPI(client=client, account_type=account_type) - - # Listen keys - self._ping_listen_keys_interval: int = 60 * 5 # Once every 5 mins (hardcode) - self._ping_listen_keys_task: Optional[asyncio.Task] = None - self._listen_key: Optional[str] = None - - # WebSocket API - self._ws_client = BinanceWebSocketClient( - loop=loop, - clock=clock, - logger=logger, - handler=self._handle_user_ws_message, - base_url=base_url_ws, + # Register additional futures websocket user data event handlers + self._futures_user_ws_handlers = { + BinanceFuturesEventType.ACCOUNT_UPDATE: self._handle_account_update, + BinanceFuturesEventType.ORDER_TRADE_UPDATE: self._handle_order_trade_update, + BinanceFuturesEventType.MARGIN_CALL: self._handle_margin_call, + BinanceFuturesEventType.ACCOUNT_CONFIG_UPDATE: self._handle_account_config_update, + BinanceFuturesEventType.LISTEN_KEY_EXPIRED: self._handle_listen_key_expired, + } + + # Websocket futures schema decoders + self._decoder_futures_user_msg_wrapper = msgspec.json.Decoder(BinanceFuturesUserMsgWrapper) + self._decoder_futures_order_update_wrapper = msgspec.json.Decoder( + BinanceFuturesOrderUpdateWrapper, ) - - # Hot caches - self._instrument_ids: dict[str, InstrumentId] = {} - - self._log.info(f"Base URL HTTP {self._http_client.base_url}.", LogColor.BLUE) - self._log.info(f"Base URL WebSocket {base_url_ws}.", LogColor.BLUE) - - async def _connect(self) -> None: - # Connect HTTP client - if not self._http_client.connected: - await self._http_client.connect() - - await self._instrument_provider.initialize() - - # Authenticate API key and update account(s) - account_info: BinanceFuturesAccountInfo = await self._http_account.account(recv_window=5000) - self._authenticate_api_key(account_info=account_info) - - binance_positions: list[BinanceFuturesPositionRisk] - binance_positions = await self._http_account.get_position_risk() - await self._update_account_state( - account_info=account_info, - position_risks=binance_positions, + self._decoder_futures_account_update_wrapper = msgspec.json.Decoder( + BinanceFuturesAccountUpdateWrapper, ) - # Get listen keys - msg: BinanceListenKey = await self._http_user.create_listen_key() - - self._listen_key = msg.listenKey - self._log.info(f"Listen key {self._listen_key}") - self._ping_listen_keys_task = self.create_task(self._ping_listen_keys()) - - # Setup clock sync - if self._clock_sync_interval_secs > 0: - self._task_clock_sync = self.create_task(self._sync_clock_with_binance_server()) - - # Connect WebSocket client - self._ws_client.subscribe(key=self._listen_key) - await self._ws_client.connect() - - def _authenticate_api_key(self, account_info: BinanceFuturesAccountInfo) -> None: + async def _update_account_state(self) -> None: + account_info: BinanceFuturesAccountInfo = ( + await self._futures_http_account.query_futures_account_info(recv_window=str(5000)) + ) if account_info.canTrade: self._log.info("Binance API key authenticated.", LogColor.GREEN) self._log.info(f"API key {self._http_client.api_key} has trading permissions.") else: self._log.error("Binance API key does not have trading permissions.") - - async def _update_account_state( - self, - account_info: BinanceFuturesAccountInfo, - position_risks: list[BinanceFuturesPositionRisk], - ) -> None: self.generate_account_state( - balances=parse_account_balances_http(assets=account_info.assets), - margins=parse_account_margins_http(assets=account_info.assets), + balances=account_info.parse_to_account_balances(), + margins=account_info.parse_to_margin_balances(), reported=True, ts_event=millis_to_nanos(account_info.updateTime), ) @@ -266,323 +163,72 @@ async def _update_account_state( await asyncio.sleep(0.1) account: MarginAccount = self.get_account() - + position_risks = await self._futures_http_account.query_futures_position_risk() for position in position_risks: instrument_id: InstrumentId = self._get_cached_instrument_id(position.symbol) leverage = Decimal(position.leverage) account.set_leverage(instrument_id, leverage) self._log.debug(f"Set leverage {position.symbol} {leverage}X") - async def _ping_listen_keys(self) -> None: - try: - while True: - self._log.debug( - f"Scheduled `ping_listen_keys` to run in " - f"{self._ping_listen_keys_interval}s.", - ) - await asyncio.sleep(self._ping_listen_keys_interval) - if self._listen_key: - self._log.debug(f"Pinging WebSocket listen key {self._listen_key}...") - await self._http_user.ping_listen_key(self._listen_key) - except asyncio.CancelledError: - self._log.debug("`ping_listen_keys` task was canceled.") - - async def _sync_clock_with_binance_server(self) -> None: - try: - while True: - # self._log.debug( - # f"Syncing Nautilus clock with Binance server...", - # ) - response: dict[str, int] = await self._http_market.time() - server_time: int = response["serverTime"] - self._log.info(f"Binance server time {server_time} UNIX (ms).") - - nautilus_time = self._clock.timestamp_ms() - self._log.info(f"Nautilus clock time {nautilus_time} UNIX (ms).") - - # offset_ns = millis_to_nanos(nautilus_time - server_time) - # self._log.info(f"Setting Nautilus clock offset {offset_ns} (ns).") - # self._clock.set_offset(offset_ns) - - await asyncio.sleep(self._clock_sync_interval_secs) - except asyncio.CancelledError: - self._log.debug("`sync_clock_with_binance_server` task was canceled.") - - async def _disconnect(self) -> None: - # Cancel tasks - if self._ping_listen_keys_task: - self._log.debug("Canceling `ping_listen_keys` task...") - self._ping_listen_keys_task.cancel() - self._ping_listen_keys_task.done() - - if self._task_clock_sync: - self._log.debug("Canceling `task_clock_sync` task...") - self._task_clock_sync.cancel() - self._task_clock_sync.done() - - # Disconnect WebSocket clients - if self._ws_client.is_connected: - await self._ws_client.disconnect() - - # Disconnect HTTP client - if self._http_client.connected: - await self._http_client.disconnect() - # -- EXECUTION REPORTS ------------------------------------------------------------------------ - async def generate_order_status_report( - self, - instrument_id: InstrumentId, - client_order_id: Optional[ClientOrderId] = None, - venue_order_id: Optional[VenueOrderId] = None, - ) -> Optional[OrderStatusReport]: - PyCondition.false( - client_order_id is None and venue_order_id is None, - "both `client_order_id` and `venue_order_id` were `None`", - ) - - self._log.info( - f"Generating OrderStatusReport for " - f"{repr(client_order_id) if client_order_id else ''} " - f"{repr(venue_order_id) if venue_order_id else ''}...", - ) - - try: - binance_order: Optional[BinanceFuturesOrder] - if venue_order_id: - binance_order = await self._http_account.get_order( - symbol=instrument_id.symbol.value, - order_id=venue_order_id.value, - ) - else: - binance_order = await self._http_account.get_order( - symbol=instrument_id.symbol.value, - orig_client_order_id=client_order_id.value - if client_order_id is not None - else None, - ) - except BinanceError as e: - self._log.error( - f"Cannot generate order status report for {repr(client_order_id)}: {e.message}", - ) - return None - - if not binance_order: - return None - - report: OrderStatusReport = parse_order_report_http( - account_id=self.account_id, - instrument_id=self._get_cached_instrument_id(binance_order.symbol), - data=binance_order, - report_id=UUID4(), - ts_init=self._clock.timestamp_ns(), - ) - - self._log.debug(f"Received {report}.") - return report - - async def generate_order_status_reports( # noqa (C901 too complex) + async def _get_binance_position_status_reports( self, - instrument_id: InstrumentId = None, - start: Optional[pd.Timestamp] = None, - end: Optional[pd.Timestamp] = None, - open_only: bool = False, - ) -> list[OrderStatusReport]: - self._log.info(f"Generating OrderStatusReports for {self.id}...") - - # Check cache for all active symbols - open_orders: list[Order] = self._cache.orders_open(venue=self.venue) - open_positions: list[Position] = self._cache.positions_open(venue=self.venue) - - active_symbols: set[str] = set() - for o in open_orders: - active_symbols.add(format_symbol(o.instrument_id.symbol.value)) - for p in open_positions: - active_symbols.add(format_symbol(p.instrument_id.symbol.value)) - - binance_orders: list[BinanceFuturesOrder] = [] - reports: dict[VenueOrderId, OrderStatusReport] = {} - - try: - # Check Binance for all active positions - binance_positions: list[BinanceFuturesPositionRisk] - binance_positions = await self._http_account.get_position_risk() - for position in binance_positions: - if Decimal(position.positionAmt) == 0: - continue # Flat position - # Add active symbol - active_symbols.add(position.symbol) - - # Check Binance for all open orders - binance_open_orders: list[BinanceFuturesOrder] - binance_open_orders = await self._http_account.get_open_orders( - symbol=instrument_id.symbol.value if instrument_id is not None else None, - ) - binance_orders.extend(binance_open_orders) - # Add active symbol - for order in binance_orders: - active_symbols.add(order.symbol) - - # Check Binance for all orders for active symbols - for symbol in active_symbols: - response = await self._http_account.get_orders( - symbol=symbol, - start_time=secs_to_millis(start.timestamp()) if start is not None else None, - end_time=secs_to_millis(end.timestamp()) if end is not None else None, - ) - binance_orders.extend(response) - except BinanceError as e: - self._log.exception(f"Cannot generate order status report: {e.message}", e) - return [] - - # Parse all Binance orders - for data in binance_orders: - report = parse_order_report_http( - account_id=self.account_id, - instrument_id=self._get_cached_instrument_id(data.symbol), - data=data, - report_id=UUID4(), - ts_init=self._clock.timestamp_ns(), - ) - - self._log.debug(f"Received {report}.") - reports[report.venue_order_id] = report # One report per order - - len_reports = len(reports) - plural = "" if len_reports == 1 else "s" - self._log.info(f"Generated {len(reports)} OrderStatusReport{plural}.") - - return list(reports.values()) - - async def generate_trade_reports( # noqa (C901 too complex) - self, - instrument_id: InstrumentId = None, - venue_order_id: VenueOrderId = None, - start: Optional[pd.Timestamp] = None, - end: Optional[pd.Timestamp] = None, - ) -> list[TradeReport]: - self._log.info(f"Generating TradeReports for {self.id}...") - - # Check cache for all active symbols - open_orders: list[Order] = self._cache.orders_open(venue=self.venue) - open_positions: list[Position] = self._cache.positions_open(venue=self.venue) - - active_symbols: set[str] = set() - for o in open_orders: - active_symbols.add(format_symbol(o.instrument_id.symbol.value)) - for p in open_positions: - active_symbols.add(format_symbol(p.instrument_id.symbol.value)) - - binance_trades: list[BinanceFuturesAccountTrade] = [] - reports: list[TradeReport] = [] - - try: - # Check Binance for all active positions - binance_positions: list[BinanceFuturesPositionRisk] - binance_positions = await self._http_account.get_position_risk() - for data in binance_positions: - if Decimal(data.positionAmt) == 0: - continue # Flat position - # Add active symbol - active_symbols.add(data.symbol) - - # Check Binance for trades on all active symbols - for symbol in active_symbols: - symbol_trades = await self._http_account.get_account_trades( - symbol=symbol, - start_time=secs_to_millis(start.timestamp()) if start is not None else None, - end_time=secs_to_millis(end.timestamp()) if end is not None else None, - ) - binance_trades.extend(symbol_trades) - except BinanceError as e: - self._log.exception(f"Cannot generate trade report: {e.message}", e) - return [] - - # Parse all Binance trades - for trade in binance_trades: - report = parse_trade_report_http( - account_id=self.account_id, - instrument_id=self._get_cached_instrument_id(trade.symbol), - data=trade, - report_id=UUID4(), - ts_init=self._clock.timestamp_ns(), - ) - - self._log.debug(f"Received {report}.") - reports.append(report) - - # Confirm sorting in ascending order - reports = sorted(reports, key=lambda x: x.trade_id) - - len_reports = len(reports) - plural = "" if len_reports == 1 else "s" - self._log.info(f"Generated {len(reports)} TradeReport{plural}.") - - return reports - - async def generate_position_status_reports( - self, - instrument_id: InstrumentId = None, - start: Optional[pd.Timestamp] = None, - end: Optional[pd.Timestamp] = None, + symbol: str = None, ) -> list[PositionStatusReport]: - self._log.info(f"Generating PositionStatusReports for {self.id}...") - reports: list[PositionStatusReport] = [] - - try: - # Check Binance for all active positions - binance_positions: list[BinanceFuturesPositionRisk] - binance_positions = await self._http_account.get_position_risk() - except BinanceError as e: - self._log.exception(f"Cannot generate position status report: {e.message}", e) - return [] - - # Parse all Binance positions - for data in binance_positions: - if Decimal(data.positionAmt) == 0: + # Check Binance for all active positions + binance_positions: list[BinanceFuturesPositionRisk] + binance_positions = await self._futures_http_account.query_futures_position_risk(symbol) + for position in binance_positions: + if Decimal(position.positionAmt) == 0: continue # Flat position - - report: PositionStatusReport = parse_position_report_http( + report = position.parse_to_position_status_report( account_id=self.account_id, - instrument_id=self._get_cached_instrument_id(data.symbol), - data=data, + instrument_id=self._get_cached_instrument_id(position.symbol), report_id=UUID4(), + enum_parser=self._futures_enum_parser, ts_init=self._clock.timestamp_ns(), ) - self._log.debug(f"Received {report}.") reports.append(report) - - len_reports = len(reports) - plural = "" if len_reports == 1 else "s" - self._log.info(f"Generated {len(reports)} PositionStatusReport{plural}.") - return reports - # -- COMMAND HANDLERS ------------------------------------------------------------------------- + async def _get_binance_active_position_symbols( + self, + symbol: str = None, + ) -> list[str]: + # Check Binance for all active positions + active_symbols: list[str] = [] + binance_positions: list[BinanceFuturesPositionRisk] + binance_positions = await self._futures_http_account.query_futures_position_risk(symbol) + for position in binance_positions: + if Decimal(position.positionAmt) == 0: + continue # Flat position + # Add active symbol + active_symbols.append(position.symbol) + return active_symbols - async def _submit_order(self, command: SubmitOrder) -> None: # noqa (too complex) - order: Order = command.order if isinstance(command, SubmitOrder) else command + # -- COMMAND HANDLERS ------------------------------------------------------------------------- + def _check_order_validity(self, order: Order): # Check order type valid - if order.order_type not in BINANCE_FUTURES_VALID_ORDER_TYPES: + if order.order_type not in self._futures_enum_parser.futures_valid_order_types: self._log.error( f"Cannot submit order: {order_type_to_str(order.order_type)} " f"orders not supported by the Binance exchange for FUTURES accounts. " - f"Use any of {[order_type_to_str(t) for t in BINANCE_FUTURES_VALID_ORDER_TYPES]}", + f"Use any of {[order_type_to_str(t) for t in self._futures_enum_parser.futures_valid_order_types]}", ) return - # Check time in force valid - if order.time_in_force not in BINANCE_FUTURES_VALID_TIF: + if order.time_in_force not in self._futures_enum_parser.futures_valid_time_in_force: self._log.error( f"Cannot submit order: " f"{time_in_force_to_str(order.time_in_force)} " - f"not supported by the exchange. Use any of {BINANCE_FUTURES_VALID_TIF}.", + f"not supported by the exchange. " + f"Use any of {[time_in_force_to_str(t) for t in self._futures_enum_parser.futures_valid_time_in_force]}.", ) return - # Check post-only if order.is_post_only and order.order_type != OrderType.LIMIT: self._log.error( @@ -591,411 +237,30 @@ async def _submit_order(self, command: SubmitOrder) -> None: # noqa (too comple ) return - # Generate event here to ensure correct ordering of events - self.generate_order_submitted( - strategy_id=order.strategy_id, - instrument_id=order.instrument_id, - client_order_id=order.client_order_id, - ts_event=self._clock.timestamp_ns(), - ) - - try: - if order.order_type == OrderType.MARKET: - await self._submit_market_order(order) - elif order.order_type == OrderType.LIMIT: - await self._submit_limit_order(order) - elif order.order_type in (OrderType.STOP_MARKET, OrderType.MARKET_IF_TOUCHED): - await self._submit_stop_market_order(order) - elif order.order_type in (OrderType.STOP_LIMIT, OrderType.LIMIT_IF_TOUCHED): - await self._submit_stop_limit_order(order) - elif order.order_type == OrderType.TRAILING_STOP_MARKET: - await self._submit_trailing_stop_market_order(order) - except BinanceError as e: - self.generate_order_rejected( - strategy_id=order.strategy_id, - instrument_id=order.instrument_id, - client_order_id=order.client_order_id, - reason=e.message, - ts_event=self._clock.timestamp_ns(), - ) - - async def _submit_market_order(self, order: MarketOrder) -> None: - await self._http_account.new_order( - symbol=format_symbol(order.instrument_id.symbol.value), - side=order_side_to_str(order.side), - type="MARKET", - quantity=str(order.quantity), - new_client_order_id=order.client_order_id.value, - recv_window=5000, - ) - - async def _submit_limit_order(self, order: LimitOrder) -> None: - time_in_force_str: str = self._convert_time_in_force_to_str(order.time_in_force) - if order.is_post_only: - time_in_force_str = "GTX" - - await self._http_account.new_order( - symbol=format_symbol(order.instrument_id.symbol.value), - side=order_side_to_str(order.side), - type=binance_order_type(order).value, - time_in_force=time_in_force_str, - quantity=str(order.quantity), - price=str(order.price), - reduce_only=order.is_reduce_only, # Cannot be sent with Hedge-Mode or closePosition - new_client_order_id=order.client_order_id.value, - recv_window=5000, - ) - - async def _submit_stop_market_order(self, order: StopMarketOrder) -> None: - time_in_force_str: str = self._convert_time_in_force_to_str(order.time_in_force) - - if order.trigger_type in (TriggerType.DEFAULT, TriggerType.LAST_TRADE): - working_type = "CONTRACT_PRICE" - elif order.trigger_type == TriggerType.MARK_PRICE: - working_type = "MARK_PRICE" - else: - self._log.error( - f"Cannot submit order: invalid `order.trigger_type`, was " - f"{trigger_type_to_str(order.trigger_price)}. {order}", - ) - return - - await self._http_account.new_order( - symbol=format_symbol(order.instrument_id.symbol.value), - side=order_side_to_str(order.side), - type=binance_order_type(order).value, - time_in_force=time_in_force_str, - quantity=str(order.quantity), - stop_price=str(order.trigger_price), - working_type=working_type, - reduce_only=order.is_reduce_only, # Cannot be sent with Hedge-Mode or closePosition - new_client_order_id=order.client_order_id.value, - recv_window=5000, - ) - - async def _submit_stop_limit_order(self, order: StopMarketOrder) -> None: - time_in_force_str: str = self._convert_time_in_force_to_str(order.time_in_force) - - if order.trigger_type in (TriggerType.DEFAULT, TriggerType.LAST_TRADE): - working_type = "CONTRACT_PRICE" - elif order.trigger_type == TriggerType.MARK_PRICE: - working_type = "MARK_PRICE" - else: - self._log.error( - f"Cannot submit order: invalid `order.trigger_type`, was " - f"{trigger_type_to_str(order.trigger_price)}. {order}", - ) - return - - await self._http_account.new_order( - symbol=format_symbol(order.instrument_id.symbol.value), - side=order_side_to_str(order.side), - type=binance_order_type(order).value, - time_in_force=time_in_force_str, - quantity=str(order.quantity), - price=str(order.price), - stop_price=str(order.trigger_price), - working_type=working_type, - reduce_only=order.is_reduce_only, # Cannot be sent with Hedge-Mode or closePosition - new_client_order_id=order.client_order_id.value, - recv_window=5000, - ) - - async def _submit_trailing_stop_market_order(self, order: TrailingStopMarketOrder) -> None: - time_in_force_str: str = self._convert_time_in_force_to_str(order.time_in_force) - - if order.trigger_type in (TriggerType.DEFAULT, TriggerType.LAST_TRADE): - working_type = "CONTRACT_PRICE" - elif order.trigger_type == TriggerType.MARK_PRICE: - working_type = "MARK_PRICE" - else: - self._log.error( - f"Cannot submit order: invalid `order.trigger_type`, was " - f"{trigger_type_to_str(order.trigger_price)}. {order}", - ) - return - - if order.trailing_offset_type != TrailingOffsetType.BASIS_POINTS: - self._log.error( - f"Cannot submit order: invalid `order.trailing_offset_type`, was " - f"{trailing_offset_type_to_str(order.trailing_offset_type)} (use `BASIS_POINTS`). " - f"{order}", - ) - return - - # Ensure activation price - activation_price: Optional[Price] = order.trigger_price - if not activation_price: - quote = self._cache.quote_tick(order.instrument_id) - trade = self._cache.trade_tick(order.instrument_id) - if quote: - if order.side == OrderSide.BUY: - activation_price = quote.ask - elif order.side == OrderSide.SELL: - activation_price = quote.bid - elif trade: - activation_price = trade.price - else: - self._log.error( - "Cannot submit order: no trigger price specified for Binance activation price " - f"and could not find quotes or trades for {order.instrument_id}", - ) - - await self._http_account.new_order( - symbol=format_symbol(order.instrument_id.symbol.value), - side=order_side_to_str(order.side), - type=binance_order_type(order).value, - time_in_force=time_in_force_str, - quantity=str(order.quantity), - activation_price=str(activation_price), - callback_rate=str(order.trailing_offset / 100), - working_type=working_type, - reduce_only=order.is_reduce_only, # Cannot be sent with Hedge-Mode or closePosition - new_client_order_id=order.client_order_id.value, - recv_window=5000, - ) - - async def _submit_order_list(self, command: SubmitOrderList) -> None: - for order in command.order_list.orders: - if order.linked_order_ids: # TODO(cs): Implement - self._log.warning(f"Cannot yet handle OCO conditional orders, {order}.") - await self._submit_order(order) - - async def _modify_order(self, command: ModifyOrder) -> None: - self._log.error( # pragma: no cover - "Cannot modify order: Not supported by the exchange.", # pragma: no cover - ) - - async def _cancel_order(self, command: CancelOrder) -> None: - self.generate_order_pending_cancel( - strategy_id=command.strategy_id, - instrument_id=command.instrument_id, - client_order_id=command.client_order_id, - venue_order_id=command.venue_order_id, - ts_event=self._clock.timestamp_ns(), - ) - - await self._cancel_order_single( - instrument_id=command.instrument_id, - client_order_id=command.client_order_id, - venue_order_id=command.venue_order_id, - ) - - async def _cancel_all_orders(self, command: CancelAllOrders) -> None: - open_orders_strategy = self._cache.orders_open( - instrument_id=command.instrument_id, - strategy_id=command.strategy_id, - ) - for order in open_orders_strategy: - if order.is_pending_cancel: - continue # Already pending cancel - self.generate_order_pending_cancel( - strategy_id=order.strategy_id, - instrument_id=order.instrument_id, - client_order_id=order.client_order_id, - venue_order_id=order.venue_order_id, - ts_event=self._clock.timestamp_ns(), - ) - - # Check total orders for instrument - open_orders_total_count = self._cache.orders_open_count( - instrument_id=command.instrument_id, - ) - - try: - if open_orders_total_count == len(open_orders_strategy): - await self._http_account.cancel_open_orders( - symbol=format_symbol(command.instrument_id.symbol.value), - ) - else: - for order in open_orders_strategy: - await self._cancel_order_single( - instrument_id=order.instrument_id, - client_order_id=order.client_order_id, - venue_order_id=order.venue_order_id, - ) - except BinanceError as e: - self._log.exception(f"Cannot cancel open orders: {e.message}", e) - - async def _cancel_order_single( - self, - instrument_id: InstrumentId, - client_order_id: ClientOrderId, - venue_order_id: Optional[VenueOrderId], - ) -> None: - try: - if venue_order_id is not None: - await self._http_account.cancel_order( - symbol=format_symbol(instrument_id.symbol.value), - order_id=venue_order_id.value, - ) - else: - await self._http_account.cancel_order( - symbol=format_symbol(instrument_id.symbol.value), - orig_client_order_id=client_order_id.value, - ) - except BinanceError as e: - self._log.exception( - f"Cannot cancel order " - f"{repr(client_order_id)}, " - f"{repr(venue_order_id)}: " - f"{e.message}", - e, - ) - - def _convert_time_in_force_to_str(self, time_in_force: TimeInForce): - time_in_force_str: str = time_in_force_to_str(time_in_force) - if time_in_force_str == TimeInForce.GTD.name: - if self._warn_gtd_to_gtc: - self._log.warning("Converting GTD `time_in_force` to GTC.") - time_in_force_str = TimeInForce.GTC.name - return time_in_force_str - - def _get_cached_instrument_id(self, symbol: str) -> InstrumentId: - # Parse instrument ID - nautilus_symbol: str = parse_symbol(symbol, account_type=self._binance_account_type) - instrument_id: Optional[InstrumentId] = self._instrument_ids.get(nautilus_symbol) - if not instrument_id: - instrument_id = InstrumentId(Symbol(nautilus_symbol), BINANCE_VENUE) - self._instrument_ids[nautilus_symbol] = instrument_id - return instrument_id + # -- WEBSOCKET EVENT HANDLERS -------------------------------------------------------------------- def _handle_user_ws_message(self, raw: bytes) -> None: # TODO(cs): Uncomment for development # self._log.info(str(json.dumps(msgspec.json.decode(raw), indent=4)), color=LogColor.MAGENTA) - - wrapper = msgspec.json.decode(raw, type=BinanceFuturesUserMsgWrapper) - + wrapper = self._decoder_futures_user_msg_wrapper.decode(raw) try: - if wrapper.data.e == BinanceFuturesEventType.ACCOUNT_UPDATE: - account_update = msgspec.json.decode(raw, type=BinanceFuturesAccountUpdateWrapper) - self._handle_account_update(account_update.data) - elif wrapper.data.e == BinanceFuturesEventType.ORDER_TRADE_UPDATE: - order_update = msgspec.json.decode(raw, type=BinanceFuturesOrderUpdateWrapper) - self._handle_order_trade_update(order_update.data) - elif wrapper.data.e == BinanceFuturesEventType.MARGIN_CALL: - self._log.warning("MARGIN CALL received.") # Implement - elif wrapper.data.e == BinanceFuturesEventType.ACCOUNT_CONFIG_UPDATE: - self._log.info("Account config updated.", LogColor.BLUE) # Implement - elif wrapper.data.e == BinanceFuturesEventType.LISTEN_KEY_EXPIRED: - self._log.warning("Listen key expired.") # Implement + self._futures_user_ws_handlers[wrapper.data.e](raw) except Exception as e: self._log.exception(f"Error on handling {repr(raw)}", e) - def _handle_account_update(self, msg: BinanceFuturesAccountUpdateMsg) -> None: - self.generate_account_state( - balances=parse_account_balances_ws(raw_balances=msg.a.B), - margins=[], - reported=True, - ts_event=millis_to_nanos(msg.T), - ) + def _handle_account_update(self, raw: bytes) -> None: + account_update = self._decoder_futures_account_update_wrapper.decode(raw) + account_update.data.handle_account_update(self) - def _handle_order_trade_update(self, msg: BinanceFuturesOrderUpdateMsg) -> None: - data: BinanceFuturesOrderData = msg.o - instrument_id: InstrumentId = self._get_cached_instrument_id(data.s) - client_order_id = ClientOrderId(data.c) if data.c != "" else None - venue_order_id = VenueOrderId(str(data.i)) - ts_event = millis_to_nanos(msg.T) + def _handle_order_trade_update(self, raw: bytes) -> None: + order_update = self._decoder_futures_order_update_wrapper.decode(raw) + order_update.data.o.handle_order_trade_update(self) - # Fetch strategy ID - strategy_id: StrategyId = self._cache.strategy_id_for_order(client_order_id) - if strategy_id is None: - if strategy_id is None: - self._generate_external_order_report( - instrument_id, - client_order_id, - venue_order_id, - msg.o, - ts_event, - ) - return + def _handle_margin_call(self, raw: bytes) -> None: + self._log.warning("MARGIN CALL received.") # Implement - if data.x == BinanceExecutionType.NEW: - self.generate_order_accepted( - strategy_id=strategy_id, - instrument_id=instrument_id, - client_order_id=client_order_id, - venue_order_id=venue_order_id, - ts_event=ts_event, - ) - elif data.x == BinanceExecutionType.TRADE: - instrument: Instrument = self._instrument_provider.find(instrument_id=instrument_id) - - # Determine commission - if data.N is not None: - commission = Money.from_str(f"{data.n} {data.N}") - else: - # Commission in margin collateral currency - commission = Money(0, instrument.quote_currency) - - self.generate_order_filled( - strategy_id=strategy_id, - instrument_id=instrument_id, - client_order_id=client_order_id, - venue_order_id=venue_order_id, - venue_position_id=PositionId(f"{instrument_id}-{data.ps.value}"), - trade_id=TradeId(str(data.t)), - order_side=OrderSide.BUY if data.S == BinanceOrderSide.BUY else OrderSide.SELL, - order_type=parse_order_type(data.o), - last_qty=Quantity.from_str(data.l), - last_px=Price.from_str(data.L), - quote_currency=instrument.quote_currency, - commission=commission, - liquidity_side=LiquiditySide.MAKER if data.m else LiquiditySide.TAKER, - ts_event=ts_event, - ) - elif data.x == BinanceExecutionType.CANCELED: - self.generate_order_canceled( - strategy_id=strategy_id, - instrument_id=instrument_id, - client_order_id=client_order_id, - venue_order_id=venue_order_id, - ts_event=ts_event, - ) - elif data.x == BinanceExecutionType.EXPIRED: - self.generate_order_expired( - strategy_id=strategy_id, - instrument_id=instrument_id, - client_order_id=client_order_id, - venue_order_id=venue_order_id, - ts_event=ts_event, - ) - else: - self._log.error( - f"Cannot handle ORDER_TRADE_UPDATE: unrecognized type {data.x.value}", - ) - - def _generate_external_order_report( - self, - instrument_id: InstrumentId, - client_order_id: ClientOrderId, - venue_order_id: VenueOrderId, - data: BinanceFuturesOrderData, - ts_event: int, - ) -> None: - report = OrderStatusReport( - account_id=self.account_id, - instrument_id=instrument_id, - client_order_id=client_order_id, - venue_order_id=venue_order_id, - order_side=OrderSide.BUY if data.S == BinanceOrderSide.BUY else OrderSide.SELL, - order_type=parse_order_type(data.o), - time_in_force=parse_time_in_force(data.f), - order_status=OrderStatus.ACCEPTED, - price=Price.from_str(data.p) if data.p is not None else None, - trigger_price=Price.from_str(data.sp) if data.sp is not None else None, - trigger_type=parse_trigger_type(data.wt), - trailing_offset=Decimal(data.cr) * 100 if data.cr is not None else None, - trailing_offset_type=TrailingOffsetType.BASIS_POINTS, - quantity=Quantity.from_str(data.q), - filled_qty=Quantity.from_str(data.z), - avg_px=None, - post_only=data.f == BinanceFuturesTimeInForce.GTX, - reduce_only=data.R, - report_id=UUID4(), - ts_accepted=ts_event, - ts_last=ts_event, - ts_init=self._clock.timestamp_ns(), - ) + def _handle_account_config_update(self, raw: bytes) -> None: + self._log.info("Account config updated.", LogColor.BLUE) # Implement - self._send_order_status_report(report) + def _handle_listen_key_expired(self, raw: bytes) -> None: + self._log.warning("Listen key expired.") # Implement diff --git a/nautilus_trader/adapters/binance/futures/http/account.py b/nautilus_trader/adapters/binance/futures/http/account.py index c05f3c7dbebd..93b547db5550 100644 --- a/nautilus_trader/adapters/binance/futures/http/account.py +++ b/nautilus_trader/adapters/binance/futures/http/account.py @@ -13,652 +13,377 @@ # limitations under the License. # ------------------------------------------------------------------------------------------------- -from typing import Any, Optional +from typing import Optional import msgspec from nautilus_trader.adapters.binance.common.enums import BinanceAccountType -from nautilus_trader.adapters.binance.common.functions import format_symbol +from nautilus_trader.adapters.binance.common.enums import BinanceMethodType +from nautilus_trader.adapters.binance.common.enums import BinanceSecurityType +from nautilus_trader.adapters.binance.common.schemas.account import BinanceStatusCode +from nautilus_trader.adapters.binance.common.schemas.symbol import BinanceSymbol from nautilus_trader.adapters.binance.futures.schemas.account import BinanceFuturesAccountInfo -from nautilus_trader.adapters.binance.futures.schemas.account import BinanceFuturesAccountTrade -from nautilus_trader.adapters.binance.futures.schemas.account import BinanceFuturesOrder +from nautilus_trader.adapters.binance.futures.schemas.account import BinanceFuturesDualSidePosition from nautilus_trader.adapters.binance.futures.schemas.account import BinanceFuturesPositionRisk +from nautilus_trader.adapters.binance.http.account import BinanceAccountHttpAPI from nautilus_trader.adapters.binance.http.client import BinanceHttpClient -from nautilus_trader.adapters.binance.http.enums import NewOrderRespType +from nautilus_trader.adapters.binance.http.endpoint import BinanceHttpEndpoint +from nautilus_trader.common.clock import LiveClock -class BinanceFuturesAccountHttpAPI: +class BinanceFuturesPositionModeHttp(BinanceHttpEndpoint): """ - Provides access to the `Binance Futures` Account/Trade HTTP REST API. + Endpoint of user's position mode for every FUTURES symbol - Parameters + `GET /fapi/v1/positionSide/dual` + `GET /dapi/v1/positionSide/dual` + + `POST /fapi/v1/positionSide/dual` + `POST /dapi/v1/positionSide/dual` + + References ---------- - client : BinanceHttpClient - The Binance REST API client. - account_type : BinanceAccountType - The Binance account type. + https://binance-docs.github.io/apidocs/futures/en/#change-position-mode-trade + https://binance-docs.github.io/apidocs/delivery/en/#change-position-mode-trade + """ def __init__( self, client: BinanceHttpClient, - account_type: BinanceAccountType = BinanceAccountType.SPOT, + base_endpoint: str, ): - self.client = client - - if account_type == BinanceAccountType.FUTURES_USDT: - self.BASE_ENDPOINT = "/fapi/v1/" - elif account_type == BinanceAccountType.FUTURES_COIN: - self.BASE_ENDPOINT = "/dapi/v1/" - else: - raise RuntimeError( # pragma: no cover (design-time error) - f"invalid `BinanceAccountType`, was {account_type}", # pragma: no cover - ) - - # Decoders - self._decoder_account = msgspec.json.Decoder(BinanceFuturesAccountInfo) - self._decoder_order = msgspec.json.Decoder(list[BinanceFuturesOrder]) - self._decoder_trade = msgspec.json.Decoder(list[BinanceFuturesAccountTrade]) - self._decoder_position = msgspec.json.Decoder(list[BinanceFuturesPositionRisk]) + methods = { + BinanceMethodType.GET: BinanceSecurityType.USER_DATA, + BinanceMethodType.POST: BinanceSecurityType.TRADE, + } + url_path = base_endpoint + "positionSide/dual" + super().__init__( + client, + methods, + url_path, + ) + self._get_resp_decoder = msgspec.json.Decoder(BinanceFuturesDualSidePosition) + self._post_resp_decoder = msgspec.json.Decoder(BinanceStatusCode) - async def change_position_mode( - self, - is_dual_side_position: bool, - recv_window: Optional[int] = None, - ): + class GetParameters(msgspec.Struct, omit_defaults=True, frozen=True): """ - Change Position Mode (TRADE). - - `POST /fapi/v1/positionSide/dual (HMAC SHA256)`. + Parameters of positionSide/dual GET request Parameters ---------- - is_dual_side_position : bool - If `Hedge Mode` will be set, otherwise `One-way` Mode. - recv_window : int, optional + timestamp : str + The millisecond timestamp of the request + recvWindow : str, optional The response receive window for the request (cannot be greater than 60000). - Returns - ------- - dict[str, Any] - - References - ---------- - https://binance-docs.github.io/apidocs/futures/en/#change-position-mode-trade - """ - payload: dict[str, str] = { - "dualSidePosition": str(is_dual_side_position).lower(), - } - if recv_window is not None: - payload["recvWindow"] = str(recv_window) - - raw: bytes = await self.client.sign_request( - http_method="POST", - url_path=self.BASE_ENDPOINT + "positionSide/dual", - payload=payload, - ) - return msgspec.json.decode(raw) + timestamp: str + recvWindow: Optional[str] = None - async def get_position_mode( - self, - recv_window: Optional[int] = None, - ): + class PostParameters(msgspec.Struct, omit_defaults=True, frozen=True): """ - Get Current Position Mode (USER_DATA). - - `GET /fapi/v1/positionSide/dual (HMAC SHA256)`. + Parameters of positionSide/dual POST request Parameters ---------- - recv_window : int, optional + timestamp : str + The millisecond timestamp of the request + dualSidePosition : str ('true', 'false') + The dual side position mode to set... + `true`: Hedge Mode, `false`: One-way mode + recvWindow : str, optional The response receive window for the request (cannot be greater than 60000). - References - ---------- - https://binance-docs.github.io/apidocs/futures/en/#get-current-position-mode-user_data """ - payload: dict[str, str] = {} - if recv_window is not None: - payload["recvWindow"] = str(recv_window) - - raw: bytes = await self.client.sign_request( - http_method="GET", - url_path=self.BASE_ENDPOINT + "positionSide/dual", - payload=payload, - ) - return msgspec.json.decode(raw) + timestamp: str + dualSidePosition: str + recvWindow: Optional[str] = None - async def new_order( # noqa (too complex) - self, - symbol: str, - side: str, - type: str, - position_side: Optional[str] = None, - time_in_force: Optional[str] = None, - quantity: Optional[str] = None, - reduce_only: Optional[bool] = False, - price: Optional[str] = None, - new_client_order_id: Optional[str] = None, - stop_price: Optional[str] = None, - close_position: Optional[bool] = None, - activation_price: Optional[str] = None, - callback_rate: Optional[str] = None, - working_type: Optional[str] = None, - price_protect: Optional[bool] = None, - new_order_resp_type: Optional[NewOrderRespType] = None, - recv_window: Optional[int] = None, - ) -> dict[str, Any]: - """ - Submit a new order. + async def _get(self, parameters: GetParameters) -> BinanceFuturesDualSidePosition: + method_type = BinanceMethodType.GET + raw = await self._method(method_type, parameters) + return self._get_resp_decoder.decode(raw) - Submit New Order (TRADE). - `POST /api/v3/order`. + async def _post(self, parameters: PostParameters) -> BinanceStatusCode: + method_type = BinanceMethodType.GET + raw = await self._method(method_type, parameters) + return self._post_resp_decoder.decode(raw) - Parameters - ---------- - symbol : str - The symbol for the request. - side : str - The order side for the request. - type : str - The order type for the request. - position_side : str, {'BOTH', 'LONG', 'SHORT'}, default BOTH - The position side for the order. - time_in_force : str, optional - The order time in force for the request. - quantity : str, optional - The order quantity in base asset units for the request. - reduce_only : bool, optional - If the order will only reduce a position. - price : str, optional - The order price for the request. - new_client_order_id : str, optional - The client order ID for the request. A unique ID among open orders. - Automatically generated if not provided. - stop_price : str, optional - The order stop price for the request. - Used with STOP_LOSS, STOP_LOSS_LIMIT, TAKE_PROFIT, and TAKE_PROFIT_LIMIT orders. - close_position : bool, optional - If close all open positions for the given symbol. - activation_price : str, optional. - The price to activate a trailing stop. - Used with TRAILING_STOP_MARKET orders, default as the last price (supporting different `working_type`). - callback_rate : str, optional - The percentage to trail the stop. - Used with TRAILING_STOP_MARKET orders, min 0.1, max 5 where 1 for 1%. - working_type : str {'MARK_PRICE', 'CONTRACT_PRICE'}, optional - The trigger type for the order. API default "CONTRACT_PRICE". - price_protect : bool, optional - If price protection is active. - new_order_resp_type : NewOrderRespType, optional - The response type for the order request. - MARKET and LIMIT order types default to FULL, all other orders default to ACK. - recv_window : int, optional - The response receive window for the request (cannot be greater than 60000). - Returns - ------- - dict[str, Any] +class BinanceFuturesAllOpenOrdersHttp(BinanceHttpEndpoint): + """ + Endpoint of all open FUTURES orders. - References - ---------- - https://binance-docs.github.io/apidocs/spot/en/#new-order-trade + `DELETE /fapi/v1/allOpenOrders` + `DELETE /dapi/v1/allOpenOrders` - """ - payload: dict[str, str] = { - "symbol": format_symbol(symbol), - "side": side, - "type": type, - } - if position_side is not None: - payload["positionSide"] = position_side - if time_in_force is not None: - payload["timeInForce"] = time_in_force - if quantity is not None: - payload["quantity"] = quantity - if reduce_only is not None: - payload["reduceOnly"] = str(reduce_only).lower() - if price is not None: - payload["price"] = price - if new_client_order_id is not None: - payload["newClientOrderId"] = new_client_order_id - if stop_price is not None: - payload["stopPrice"] = stop_price - if close_position is not None: - payload["closePosition"] = str(close_position).lower() - if activation_price is not None: - payload["activationPrice"] = activation_price - if callback_rate is not None: - payload["callbackRate"] = callback_rate - if working_type is not None: - payload["workingType"] = working_type - if price_protect is not None: - payload["priceProtect"] = str(price_protect).lower() - if new_order_resp_type is not None: - payload["newOrderRespType"] = new_order_resp_type.value - if recv_window is not None: - payload["recvWindow"] = str(recv_window) - - raw: bytes = await self.client.sign_request( - http_method="POST", - url_path=self.BASE_ENDPOINT + "order", - payload=payload, - ) + References + ---------- + https://binance-docs.github.io/apidocs/futures/en/#cancel-all-open-orders-trade + https://binance-docs.github.io/apidocs/delivery/en/#cancel-all-open-orders-trade - return msgspec.json.decode(raw) + """ - async def cancel_order( + def __init__( self, - symbol: str, - order_id: Optional[str] = None, - orig_client_order_id: Optional[str] = None, - new_client_order_id: Optional[str] = None, - recv_window: Optional[int] = None, - ) -> dict[str, Any]: - """ - Cancel an open order. + client: BinanceHttpClient, + base_endpoint: str, + ): + methods = { + BinanceMethodType.DELETE: BinanceSecurityType.TRADE, + } + url_path = base_endpoint + "allOpenOrders" + super().__init__( + client, + methods, + url_path, + ) + self._delete_resp_decoder = msgspec.json.Decoder(BinanceStatusCode) - Cancel Order (TRADE). - `DELETE /api/v3/order`. + class DeleteParameters(msgspec.Struct, omit_defaults=True, frozen=True): + """ + Parameters of allOpenOrders DELETE request Parameters ---------- - symbol : str - The symbol for the request. - order_id : str, optional - The order ID to cancel. - orig_client_order_id : str, optional - The original client order ID to cancel. - new_client_order_id : str, optional - The new client order ID to uniquely identify this request. - recv_window : int, optional + timestamp : str + The millisecond timestamp of the request + symbol : BinanceSymbol + The symbol of the request + recvWindow : str, optional The response receive window for the request (cannot be greater than 60000). - Returns - ------- - dict[str, Any] - - References - ---------- - https://binance-docs.github.io/apidocs/spot/en/#cancel-order-trade - """ - payload: dict[str, str] = {"symbol": format_symbol(symbol)} - if order_id is not None: - payload["orderId"] = str(order_id) - if orig_client_order_id is not None: - payload["origClientOrderId"] = str(orig_client_order_id) - if new_client_order_id is not None: - payload["newClientOrderId"] = str(new_client_order_id) - if recv_window is not None: - payload["recvWindow"] = str(recv_window) - - raw: bytes = await self.client.sign_request( - http_method="DELETE", - url_path=self.BASE_ENDPOINT + "order", - payload=payload, - ) - return msgspec.json.decode(raw) + timestamp: str + symbol: BinanceSymbol + recvWindow: Optional[str] = None - async def cancel_open_orders( - self, - symbol: str, - recv_window: Optional[int] = None, - ) -> dict[str, Any]: - """ - Cancel all open orders for a symbol. This includes OCO orders. + async def _delete(self, parameters: DeleteParameters) -> BinanceStatusCode: + method_type = BinanceMethodType.DELETE + raw = await self._method(method_type, parameters) + return self._delete_resp_decoder.decode(raw) - Cancel all Open Orders for a Symbol (TRADE). - `DELETE /fapi/v1/allOpenOrders (HMAC SHA256)`. - Parameters - ---------- - symbol : str - The symbol for the request. - recv_window : int, optional - The response receive window for the request (cannot be greater than 60000). - - Returns - ------- - dict[str, Any] +class BinanceFuturesAccountHttp(BinanceHttpEndpoint): + """ + Endpoint of current FUTURES account information - References - ---------- - https://binance-docs.github.io/apidocs/spot/en/#cancel-all-open-orders-on-a-symbol-trade + `GET /fapi/v2/account` + `GET /dapi/v1/account` - """ - payload: dict[str, str] = {"symbol": format_symbol(symbol)} - if recv_window is not None: - payload["recvWindow"] = str(recv_window) - - raw: bytes = await self.client.sign_request( - http_method="DELETE", - url_path=self.BASE_ENDPOINT + "allOpenOrders", - payload=payload, - ) + References + ---------- + https://binance-docs.github.io/apidocs/futures/en/#account-information-v2-user_data + https://binance-docs.github.io/apidocs/delivery/en/#account-information-user_data - return msgspec.json.decode(raw) + """ - async def get_order( + def __init__( self, - symbol: str, - order_id: Optional[str] = None, - orig_client_order_id: Optional[str] = None, - recv_window: Optional[int] = None, - ) -> Optional[BinanceFuturesOrder]: - """ - Check an order's status. + client: BinanceHttpClient, + base_endpoint: str, + ): + methods = { + BinanceMethodType.GET: BinanceSecurityType.USER_DATA, + } + url_path = base_endpoint + "account" + super().__init__( + client, + methods, + url_path, + ) + self._resp_decoder = msgspec.json.Decoder(BinanceFuturesAccountInfo) - Query Order (USER_DATA). - `GET TBD`. + class GetParameters(msgspec.Struct, omit_defaults=True, frozen=True): + """ + Parameters of account GET request Parameters ---------- - symbol : str - The symbol for the request. - order_id : str, optional - The order ID for the request. - orig_client_order_id : str, optional - The original client order ID for the request. - recv_window : int, optional + timestamp : str + The millisecond timestamp of the request + recvWindow : str, optional The response receive window for the request (cannot be greater than 60000). - Returns - ------- - BinanceFuturesOrderMsg or None - - References - ---------- - TBD - """ - payload: dict[str, str] = {"symbol": format_symbol(symbol)} - if order_id is not None: - payload["orderId"] = order_id - if orig_client_order_id is not None: - payload["origClientOrderId"] = orig_client_order_id - if recv_window is not None: - payload["recvWindow"] = str(recv_window) - - raw: bytes = await self.client.sign_request( - http_method="GET", - url_path=self.BASE_ENDPOINT + "order", - payload=payload, - ) - if raw is None: - return None - return msgspec.json.decode(raw, type=BinanceFuturesOrder) - - async def get_open_orders( - self, - symbol: Optional[str] = None, - recv_window: Optional[int] = None, - ) -> list[BinanceFuturesOrder]: - """ - Get all open orders for a symbol. + timestamp: str + recvWindow: Optional[str] = None - Query Current Open Orders (USER_DATA). + async def _get(self, parameters: GetParameters) -> BinanceFuturesAccountInfo: + method_type = BinanceMethodType.GET + raw = await self._method(method_type, parameters) + return self._resp_decoder.decode(raw) - Parameters - ---------- - symbol : str, optional - The symbol for the request. - recv_window : int, optional - The response receive window for the request (cannot be greater than 60000). - Returns - ------- - dict[str, Any] +class BinanceFuturesPositionRiskHttp(BinanceHttpEndpoint): + """ + Endpoint of information of all FUTURES positions. - References - ---------- - https://binance-docs.github.io/apidocs/futures/en/#current-open-orders-user_data + `GET /fapi/v2/positionRisk` + `GET /dapi/v1/positionRisk` - """ - payload: dict[str, str] = {} - if symbol is not None: - payload["symbol"] = format_symbol(symbol) - if recv_window is not None: - payload["recvWindow"] = str(recv_window) - - raw: bytes = await self.client.sign_request( - http_method="GET", - url_path=self.BASE_ENDPOINT + "openOrders", - payload=payload, - ) + References + ---------- + https://binance-docs.github.io/apidocs/futures/en/#position-information-v2-user_data + https://binance-docs.github.io/apidocs/delivery/en/#position-information-user_data - return self._decoder_order.decode(raw) + """ - async def get_orders( + def __init__( self, - symbol: str, - order_id: Optional[str] = None, - start_time: Optional[int] = None, - end_time: Optional[int] = None, - limit: Optional[int] = None, - recv_window: Optional[int] = None, - ) -> list[BinanceFuturesOrder]: - """ - Get all account orders (open, or closed). + client: BinanceHttpClient, + base_endpoint: str, + ): + methods = { + BinanceMethodType.GET: BinanceSecurityType.USER_DATA, + } + url_path = base_endpoint + "positionRisk" + super().__init__( + client, + methods, + url_path, + ) + self._get_resp_decoder = msgspec.json.Decoder(list[BinanceFuturesPositionRisk]) - All Orders (USER_DATA). + class GetParameters(msgspec.Struct, omit_defaults=True, frozen=True): + """ + Parameters of positionRisk GET request Parameters ---------- - symbol : str - The symbol for the request. - order_id : str, optional - The order ID for the request. - start_time : int, optional - The start time (UNIX milliseconds) filter for the request. - end_time : int, optional - The end time (UNIX milliseconds) filter for the request. - limit : int, optional - The limit for the response. - recv_window : int, optional + timestamp : str + The millisecond timestamp of the request + symbol : BinanceSymbol + The symbol of the request + recvWindow : str, optional The response receive window for the request (cannot be greater than 60000). - Returns - ------- - list[dict[str, Any]] - - References - ---------- - https://binance-docs.github.io/apidocs/futures/en/#all-orders-user_data - """ - payload: dict[str, str] = {"symbol": format_symbol(symbol)} - if order_id is not None: - payload["orderId"] = order_id - if start_time is not None: - payload["startTime"] = str(start_time) - if end_time is not None: - payload["endTime"] = str(end_time) - if limit is not None: - payload["limit"] = str(limit) - if recv_window is not None: - payload["recvWindow"] = str(recv_window) - - raw: bytes = await self.client.sign_request( - http_method="GET", - url_path=self.BASE_ENDPOINT + "allOrders", - payload=payload, - ) - - return self._decoder_order.decode(raw) - async def account(self, recv_window: Optional[int] = None) -> BinanceFuturesAccountInfo: - """ - Get current account information. + timestamp: str + symbol: Optional[BinanceSymbol] = None + recvWindow: Optional[str] = None - Account Information (USER_DATA). - `GET /api/v3/account`. + async def _get(self, parameters: GetParameters) -> list[BinanceFuturesPositionRisk]: + method_type = BinanceMethodType.GET + raw = await self._method(method_type, parameters) + return self._get_resp_decoder.decode(raw) - Parameters - ---------- - recv_window : int, optional - The response receive window for the request (cannot be greater than 60000). - Returns - ------- - BinanceFuturesAccountInfo +class BinanceFuturesAccountHttpAPI(BinanceAccountHttpAPI): + """ + Provides access to the `Binance Futures` Account/Trade HTTP REST API. - References - ---------- - https://binance-docs.github.io/apidocs/spot/en/#account-information-user_data + Parameters + ---------- + client : BinanceHttpClient + The Binance REST API client. + account_type : BinanceAccountType + The Binance account type. + """ - """ - payload: dict[str, str] = {} - if recv_window is not None: - payload["recvWindow"] = str(recv_window) - - raw: bytes = await self.client.sign_request( - http_method="GET", - url_path=self.BASE_ENDPOINT + "account", - payload=payload, + def __init__( + self, + client: BinanceHttpClient, + clock: LiveClock, + account_type: BinanceAccountType = BinanceAccountType.FUTURES_USDT, + ): + super().__init__( + client=client, + clock=clock, + account_type=account_type, ) + if not account_type.is_futures: + raise RuntimeError( # pragma: no cover (design-time error) + f"`BinanceAccountType` not FUTURES_USDT or FUTURES_COIN, was {account_type}", # pragma: no cover + ) + v2_endpoint_base = self.base_endpoint + if account_type == BinanceAccountType.FUTURES_USDT: + v2_endpoint_base = "/fapi/v2/" - return self._decoder_account.decode(raw) + # Create endpoints + self._endpoint_futures_position_mode = BinanceFuturesPositionModeHttp( + client, + self.base_endpoint, + ) + self._endpoint_futures_all_open_orders = BinanceFuturesAllOpenOrdersHttp( + client, + self.base_endpoint, + ) + self._endpoint_futures_account = BinanceFuturesAccountHttp(client, v2_endpoint_base) + self._endpoint_futures_position_risk = BinanceFuturesPositionRiskHttp( + client, + v2_endpoint_base, + ) - async def get_account_trades( + async def query_futures_hedge_mode( self, - symbol: str, - from_id: Optional[str] = None, - order_id: Optional[str] = None, - start_time: Optional[int] = None, - end_time: Optional[int] = None, - limit: Optional[int] = None, - recv_window: Optional[int] = None, - ) -> list[BinanceFuturesAccountTrade]: - """ - Get trades for a specific account and symbol. - - Account Trade List (USER_DATA) - - Parameters - ---------- - symbol : str - The symbol for the request. - from_id : str, optional - The trade match ID to query from. - order_id : str, optional - The order ID for the trades. This can only be used in combination with symbol. - start_time : int, optional - The start time (UNIX milliseconds) filter for the request. - end_time : int, optional - The end time (UNIX milliseconds) filter for the request. - limit : int, optional - The limit for the response. - recv_window : int, optional - The response receive window for the request (cannot be greater than 60000). - - Returns - ------- - list[BinanceFuturesAccountTrade] + recv_window: Optional[str] = None, + ) -> BinanceFuturesDualSidePosition: + """Check Binance Futures hedge mode (dualSidePosition)""" + return await self._endpoint_futures_position_mode._get( + parameters=self._endpoint_futures_position_mode.GetParameters( + timestamp=self._timestamp(), + recvWindow=recv_window, + ), + ) - References - ---------- - https://binance-docs.github.io/apidocs/spot/en/#account-trade-list-user_data + async def set_futures_hedge_mode( + self, + dual_side_position: bool, + recv_window: Optional[str] = None, + ) -> BinanceStatusCode: + """Set Binance Futures hedge mode (dualSidePosition)""" + return await self._endpoint_futures_position_mode._post( + parameters=self._endpoint_futures_position_mode.PostParameters( + timestamp=self._timestamp(), + dualSidePosition=str(dual_side_position).lower(), + recvWindow=recv_window, + ), + ) - """ - payload: dict[str, str] = {"symbol": format_symbol(symbol)} - if from_id is not None: - payload["fromId"] = from_id - if order_id is not None: - payload["orderId"] = order_id - if start_time is not None: - payload["startTime"] = str(start_time) - if end_time is not None: - payload["endTime"] = str(end_time) - if limit is not None: - payload["limit"] = str(limit) - if recv_window is not None: - payload["recvWindow"] = str(recv_window) - - raw: bytes = await self.client.sign_request( - http_method="GET", - url_path=self.BASE_ENDPOINT + "userTrades", - payload=payload, + async def cancel_all_open_orders( + self, + symbol: str, + recv_window: Optional[str] = None, + ) -> bool: + """Delete all Futures open orders. Returns whether successful.""" + response = await self._endpoint_futures_all_open_orders._delete( + parameters=self._endpoint_futures_all_open_orders.DeleteParameters( + timestamp=self._timestamp(), + symbol=BinanceSymbol(symbol), + recvWindow=recv_window, + ), ) + return response.code == 200 - return self._decoder_trade.decode(raw) + async def query_futures_account_info( + self, + recv_window: Optional[str] = None, + ) -> BinanceFuturesAccountInfo: + """Check Binance Futures account information.""" + return await self._endpoint_futures_account._get( + parameters=self._endpoint_futures_account.GetParameters( + timestamp=self._timestamp(), + recvWindow=recv_window, + ), + ) - async def get_position_risk( + async def query_futures_position_risk( self, symbol: Optional[str] = None, - recv_window: Optional[int] = None, + recv_window: Optional[str] = None, ) -> list[BinanceFuturesPositionRisk]: - """ - Get current position information. - - Position Information V2 (USER_DATA)** - - `GET /fapi/v2/positionRisk` - - Parameters - ---------- - symbol : str, optional - The trading pair. If None then queries for all symbols. - recv_window : int, optional - The acceptable receive window for the response. - - Returns - ------- - list[BinanceFuturesPositionRisk] - - References - ---------- - https://binance-docs.github.io/apidocs/futures/en/#position-information-v2-user_data - - """ - payload: dict[str, str] = {} - if symbol is not None: - payload["symbol"] = format_symbol(symbol) - if recv_window is not None: - payload["recv_window"] = str(recv_window) - - raw: bytes = await self.client.sign_request( - http_method="GET", - url_path=self.BASE_ENDPOINT + "positionRisk", - payload=payload, + """Check all Futures position's info for a symbol.""" + return await self._endpoint_futures_position_risk._get( + parameters=self._endpoint_futures_position_risk.GetParameters( + timestamp=self._timestamp(), + symbol=BinanceSymbol(symbol), + recvWindow=recv_window, + ), ) - - return self._decoder_position.decode(raw) - - async def get_order_rate_limit(self, recv_window: Optional[int] = None) -> dict[str, Any]: - """ - Get the user's current order count usage for all intervals. - - Query Current Order Count Usage (TRADE). - `GET /api/v3/rateLimit/order`. - - Parameters - ---------- - recv_window : int, optional - The response receive window for the request (cannot be greater than 60000). - - Returns - ------- - dict[str, Any] - - References - ---------- - https://binance-docs.github.io/apidocs/spot/en/#query-current-order-count-usage-trade - - """ - payload: dict[str, str] = {} - if recv_window is not None: - payload["recvWindow"] = str(recv_window) - - raw: bytes = await self.client.sign_request( - http_method="GET", - url_path=self.BASE_ENDPOINT + "rateLimit/order", - payload=payload, - ) - - return msgspec.json.decode(raw) diff --git a/nautilus_trader/adapters/binance/futures/http/market.py b/nautilus_trader/adapters/binance/futures/http/market.py index 9bf6356bdcba..4aca25517a83 100644 --- a/nautilus_trader/adapters/binance/futures/http/market.py +++ b/nautilus_trader/adapters/binance/futures/http/market.py @@ -13,468 +13,86 @@ # limitations under the License. # ------------------------------------------------------------------------------------------------- -from typing import Any, Optional - import msgspec from nautilus_trader.adapters.binance.common.enums import BinanceAccountType -from nautilus_trader.adapters.binance.common.functions import convert_symbols_list_to_json_array -from nautilus_trader.adapters.binance.common.functions import format_symbol -from nautilus_trader.adapters.binance.common.schemas import BinanceTrade +from nautilus_trader.adapters.binance.common.enums import BinanceMethodType +from nautilus_trader.adapters.binance.common.enums import BinanceSecurityType from nautilus_trader.adapters.binance.futures.schemas.market import BinanceFuturesExchangeInfo from nautilus_trader.adapters.binance.http.client import BinanceHttpClient -from nautilus_trader.core.correctness import PyCondition +from nautilus_trader.adapters.binance.http.endpoint import BinanceHttpEndpoint +from nautilus_trader.adapters.binance.http.market import BinanceMarketHttpAPI -class BinanceFuturesMarketHttpAPI: +class BinanceFuturesExchangeInfoHttp(BinanceHttpEndpoint): """ - Provides access to the `Binance Market` HTTP REST API. + Endpoint of FUTURES exchange trading rules and symbol information - Parameters + `GET /fapi/v1/exchangeInfo` + `GET /dapi/v1/exchangeInfo` + + References ---------- - client : BinanceHttpClient - The Binance REST API client. + https://binance-docs.github.io/apidocs/futures/en/#exchange-information + https://binance-docs.github.io/apidocs/delivery/en/#exchange-information + """ def __init__( self, client: BinanceHttpClient, - account_type: BinanceAccountType = BinanceAccountType.FUTURES_USDT, + base_endpoint: str, ): - PyCondition.not_none(client, "client") - - self.client = client - self.account_type = account_type - - if self.account_type == BinanceAccountType.FUTURES_USDT: - self.BASE_ENDPOINT = "/fapi/v1/" - elif self.account_type == BinanceAccountType.FUTURES_COIN: - self.BASE_ENDPOINT = "/dapi/v1/" - else: - raise RuntimeError( # pragma: no cover (design-time error) - f"invalid `BinanceAccountType`, was {account_type}", # pragma: no cover - ) - - self._decoder_exchange_info = msgspec.json.Decoder(BinanceFuturesExchangeInfo) - self._decoder_trades = msgspec.json.Decoder(list[BinanceTrade]) - - async def ping(self) -> dict[str, Any]: - """ - Test the connectivity to the REST API. - - `GET /api/v3/ping` - - Returns - ------- - dict[str, Any] - - References - ---------- - https://binance-docs.github.io/apidocs/spot/en/#test-connectivity - - """ - raw: bytes = await self.client.query(url_path=self.BASE_ENDPOINT + "ping") - return msgspec.json.decode(raw) - - async def time(self) -> dict[str, Any]: - """ - Test connectivity to the Rest API and get the current server time. - - Check Server Time. - `GET /api/v3/time` - - Returns - ------- - dict[str, Any] - - References - ---------- - https://binance-docs.github.io/apidocs/spot/en/#check-server-time - - """ - raw: bytes = await self.client.query(url_path=self.BASE_ENDPOINT + "time") - return msgspec.json.decode(raw) - - async def exchange_info( - self, - symbol: Optional[str] = None, - symbols: Optional[list[str]] = None, - ) -> BinanceFuturesExchangeInfo: - """ - Get current exchange trading rules and symbol information. - Only either `symbol` or `symbols` should be passed. - - Exchange Information. - `GET /api/v3/exchangeinfo` - - Parameters - ---------- - symbol : str, optional - The trading pair. - symbols : list[str], optional - The list of trading pairs. - - Returns - ------- - BinanceFuturesExchangeInfo - - References - ---------- - https://binance-docs.github.io/apidocs/spot/en/#exchange-information - - """ - if symbol and symbols: - raise ValueError("`symbol` and `symbols` cannot be sent together") - - payload: dict[str, str] = {} - if symbol is not None: - payload["symbol"] = format_symbol(symbol) - if symbols is not None: - payload["symbols"] = convert_symbols_list_to_json_array(symbols) - - raw: bytes = await self.client.query( - url_path=self.BASE_ENDPOINT + "exchangeInfo", - payload=payload, - ) - - return self._decoder_exchange_info.decode(raw) - - async def depth(self, symbol: str, limit: Optional[int] = None) -> dict[str, Any]: - """ - Get orderbook. - - `GET /api/v3/depth` - - Parameters - ---------- - symbol : str - The trading pair. - limit : int, optional, default 100 - The limit for the response. Default 100; max 5000. - Valid limits:[5, 10, 20, 50, 100, 500, 1000, 5000]. - - Returns - ------- - dict[str, Any] - - References - ---------- - https://binance-docs.github.io/apidocs/spot/en/#order-book - - """ - payload: dict[str, str] = {"symbol": format_symbol(symbol)} - if limit is not None: - payload["limit"] = str(limit) - - raw: bytes = await self.client.query( - url_path=self.BASE_ENDPOINT + "depth", - payload=payload, - ) - - return msgspec.json.decode(raw) - - async def trades(self, symbol: str, limit: Optional[int] = None) -> list[BinanceTrade]: - """ - Get recent market trades. - - Recent Trades List. - `GET /api/v3/trades` - - Parameters - ---------- - symbol : str - The trading pair. - limit : int, optional - The limit for the response. Default 500; max 1000. - - Returns - ------- - list[BinanceTrade] - - References - ---------- - https://binance-docs.github.io/apidocs/spot/en/#recent-trades-list - - """ - payload: dict[str, str] = {"symbol": format_symbol(symbol)} - if limit is not None: - payload["limit"] = str(limit) - - raw: bytes = await self.client.query( - url_path=self.BASE_ENDPOINT + "trades", - payload=payload, - ) - - return self._decoder_trades.decode(raw) - - async def historical_trades( - self, - symbol: str, - from_id: Optional[int] = None, - limit: Optional[int] = None, - ) -> dict[str, Any]: - """ - Get older market trades. - - Old Trade Lookup. - `GET /api/v3/historicalTrades` - - Parameters - ---------- - symbol : str - The trading pair. - from_id : int, optional - The trade ID to fetch from. Default gets most recent trades. - limit : int, optional - The limit for the response. Default 500; max 1000. - - Returns - ------- - dict[str, Any] - - References - ---------- - https://binance-docs.github.io/apidocs/spot/en/#old-trade-lookup - - """ - payload: dict[str, str] = {"symbol": format_symbol(symbol)} - if limit is not None: - payload["limit"] = str(limit) - if from_id is not None: - payload["fromId"] = str(from_id) - - raw: bytes = await self.client.limit_request( - http_method="GET", - url_path=self.BASE_ENDPOINT + "historicalTrades", - payload=payload, - ) - - return msgspec.json.decode(raw) - - async def agg_trades( - self, - symbol: str, - from_id: Optional[int] = None, - start_time_ms: Optional[int] = None, - end_time_ms: Optional[int] = None, - limit: Optional[int] = None, - ) -> dict[str, Any]: - """ - Get recent aggregated market trades. - - Compressed/Aggregate Trades List. - `GET /api/v3/aggTrades` - - Parameters - ---------- - symbol : str - The trading pair. - from_id : int, optional - The trade ID to fetch from. Default gets most recent trades. - start_time_ms : int, optional - The UNIX timestamp (milliseconds) to get aggregate trades from INCLUSIVE. - end_time_ms: int, optional - The UNIX timestamp (milliseconds) to get aggregate trades until INCLUSIVE. - limit : int, optional - The limit for the response. Default 500; max 1000. - - Returns - ------- - dict[str, Any] - - References - ---------- - https://binance-docs.github.io/apidocs/spot/en/#compressed-aggregate-trades-list - - """ - payload: dict[str, str] = {"symbol": format_symbol(symbol)} - if from_id is not None: - payload["fromId"] = str(from_id) - if start_time_ms is not None: - payload["startTime"] = str(start_time_ms) - if end_time_ms is not None: - payload["endTime"] = str(end_time_ms) - if limit is not None: - payload["limit"] = str(limit) - - raw: bytes = await self.client.query( - url_path=self.BASE_ENDPOINT + "aggTrades", - payload=payload, - ) - - return msgspec.json.decode(raw) - - async def klines( - self, - symbol: str, - interval: str, - start_time_ms: Optional[int] = None, - end_time_ms: Optional[int] = None, - limit: Optional[int] = None, - ) -> list[list[Any]]: - """ - Kline/Candlestick Data. - - `GET /api/v3/klines` - - Parameters - ---------- - symbol : str - The trading pair. - interval : str - The interval of kline, e.g 1m, 5m, 1h, 1d, etc. - start_time_ms : int, optional - The UNIX timestamp (milliseconds) to get aggregate trades from INCLUSIVE. - end_time_ms: int, optional - The UNIX timestamp (milliseconds) to get aggregate trades until INCLUSIVE. - limit : int, optional - The limit for the response. Default 500; max 1000. - - Returns - ------- - list[list[Any]] - - References - ---------- - https://binance-docs.github.io/apidocs/spot/en/#kline-candlestick-data - - """ - payload: dict[str, str] = { - "symbol": format_symbol(symbol), - "interval": interval, + methods = { + BinanceMethodType.GET: BinanceSecurityType.NONE, } - if start_time_ms is not None: - payload["startTime"] = str(start_time_ms) - if end_time_ms is not None: - payload["endTime"] = str(end_time_ms) - if limit is not None: - payload["limit"] = str(limit) - - raw: bytes = await self.client.query( - url_path=self.BASE_ENDPOINT + "klines", - payload=payload, + url_path = base_endpoint + "exchangeInfo" + super().__init__( + client, + methods, + url_path, ) + self._get_resp_decoder = msgspec.json.Decoder(BinanceFuturesExchangeInfo) - return msgspec.json.decode(raw) - - async def avg_price(self, symbol: str) -> dict[str, Any]: - """ - Get the current average price for the given symbol. - - `GET /api/v3/avgPrice` - - Parameters - ---------- - symbol : str - The trading pair. - - Returns - ------- - dict[str, Any] - - References - ---------- - https://binance-docs.github.io/apidocs/spot/en/#current-average-price - - """ - payload: dict[str, str] = {"symbol": format_symbol(symbol)} - - raw: bytes = await self.client.query( - url_path=self.BASE_ENDPOINT + "avgPrice", - payload=payload, - ) - - return msgspec.json.decode(raw) - - async def ticker_24hr(self, symbol: Optional[str] = None) -> dict[str, Any]: - """ - 24hr Ticker Price Change Statistics. + async def _get(self) -> BinanceFuturesExchangeInfo: + method_type = BinanceMethodType.GET + raw = await self._method(method_type, None) + return self._get_resp_decoder.decode(raw) - `GET /api/v3/ticker/24hr` - Parameters - ---------- - symbol : str, optional - The trading pair. - - Returns - ------- - dict[str, Any] - - References - ---------- - https://binance-docs.github.io/apidocs/spot/en/#24hr-ticker-price-change-statistics - - """ - payload: dict[str, str] = {} - if symbol is not None: - payload["symbol"] = format_symbol(symbol) - - raw: bytes = await self.client.query( - url_path=self.BASE_ENDPOINT + "ticker/24hr", - payload=payload, - ) - - return msgspec.json.decode(raw) - - async def ticker_price(self, symbol: Optional[str] = None) -> dict[str, Any]: - """ - Symbol Price Ticker. - - `GET /api/v3/ticker/price` - - Parameters - ---------- - symbol : str, optional - The trading pair. - - Returns - ------- - dict[str, Any] +class BinanceFuturesMarketHttpAPI(BinanceMarketHttpAPI): + """ + Provides access to the `Binance Futures` HTTP REST API. - References - ---------- - https://binance-docs.github.io/apidocs/spot/en/#symbol-price-ticker + Parameters + ---------- + client : BinanceHttpClient + The Binance REST API client. + account_type : BinanceAccountType + The Binance account type, used to select the endpoint - """ - payload: dict[str, str] = {} - if symbol is not None: - payload["symbol"] = format_symbol(symbol) + """ - raw: bytes = await self.client.query( - url_path=self.BASE_ENDPOINT + "ticker/price", - payload=payload, + def __init__( + self, + client: BinanceHttpClient, + account_type: BinanceAccountType = BinanceAccountType.FUTURES_USDT, + ): + super().__init__( + client=client, + account_type=account_type, ) - return msgspec.json.decode(raw) - - async def book_ticker(self, symbol: Optional[str] = None) -> dict[str, Any]: - """ - Symbol Order Book Ticker. - - `GET /api/v3/ticker/bookTicker` - - Parameters - ---------- - symbol : str, optional - The trading pair. - - Returns - ------- - dict[str, Any] - - References - ---------- - https://binance-docs.github.io/apidocs/spot/en/#symbol-order-book-ticker - - """ - payload: dict[str, str] = {} - if symbol is not None: - payload["symbol"] = format_symbol(symbol).upper() + if not account_type.is_futures: + raise RuntimeError( # pragma: no cover (design-time error) + f"`BinanceAccountType` not FUTURES_USDT or FUTURES_COIN, was {account_type}", # pragma: no cover + ) - raw: bytes = await self.client.query( - url_path=self.BASE_ENDPOINT + "ticker/bookTicker", - payload=payload, + self._endpoint_futures_exchange_info = BinanceFuturesExchangeInfoHttp( + client, + self.base_endpoint, ) - return msgspec.json.decode(raw) + async def query_futures_exchange_info(self) -> BinanceFuturesExchangeInfo: + """Retrieve Binance Futures exchange information.""" + return await self._endpoint_futures_exchange_info._get() diff --git a/nautilus_trader/adapters/binance/futures/http/user.py b/nautilus_trader/adapters/binance/futures/http/user.py index fb71ef9ba3c9..6d5946c83dd5 100644 --- a/nautilus_trader/adapters/binance/futures/http/user.py +++ b/nautilus_trader/adapters/binance/futures/http/user.py @@ -13,17 +13,13 @@ # limitations under the License. # ------------------------------------------------------------------------------------------------- -from typing import Any - -import msgspec from nautilus_trader.adapters.binance.common.enums import BinanceAccountType -from nautilus_trader.adapters.binance.common.schemas import BinanceListenKey from nautilus_trader.adapters.binance.http.client import BinanceHttpClient -from nautilus_trader.core.correctness import PyCondition +from nautilus_trader.adapters.binance.http.user import BinanceUserDataHttpAPI -class BinanceFuturesUserDataHttpAPI: +class BinanceFuturesUserDataHttpAPI(BinanceUserDataHttpAPI): """ Provides access to the `Binance Futures` User Data HTTP REST API. @@ -31,6 +27,8 @@ class BinanceFuturesUserDataHttpAPI: ---------- client : BinanceHttpClient The Binance REST API client. + account_type : BinanceAccountType + The Binance account type, used to select the endpoint """ def __init__( @@ -38,101 +36,12 @@ def __init__( client: BinanceHttpClient, account_type: BinanceAccountType = BinanceAccountType.FUTURES_USDT, ): - PyCondition.not_none(client, "client") - - self.client = client - self.account_type = account_type + super().__init__( + client=client, + account_type=account_type, + ) - if account_type == BinanceAccountType.FUTURES_USDT: - self.BASE_ENDPOINT = "/fapi/v1/" - elif account_type == BinanceAccountType.FUTURES_COIN: - self.BASE_ENDPOINT = "/dapi/v1/" - else: + if not account_type.is_futures: raise RuntimeError( # pragma: no cover (design-time error) - f"invalid `BinanceAccountType`, was {account_type}", # pragma: no cover + f"`BinanceAccountType` not FUTURES_USDT or FUTURES_COIN, was {account_type}", # pragma: no cover (design-time error) # noqa ) - - async def create_listen_key(self) -> BinanceListenKey: - """ - Create a new listen key for the Binance FUTURES_USDT or FUTURES_COIN API. - - Start a new user data stream. The stream will close after 60 minutes - unless a keepalive is sent. If the account has an active listenKey, - that listenKey will be returned and its validity will be extended for 60 - minutes. - - Create a ListenKey (USER_STREAM). - - Returns - ------- - BinanceListenKey - - References - ---------- - https://binance-docs.github.io/apidocs/futures/en/#start-user-data-stream-user_stream - - """ - raw: bytes = await self.client.send_request( - http_method="POST", - url_path=self.BASE_ENDPOINT + "listenKey", - ) - - return msgspec.json.decode(raw, type=BinanceListenKey) - - async def ping_listen_key(self, key: str) -> dict[str, Any]: - """ - Ping/Keep-alive a listen key for the Binance FUTURES_USDT or FUTURES_COIN API. - - Keep-alive a user data stream to prevent a time-out. User data streams - will close after 60 minutes. It's recommended to send a ping about every - 30 minutes. - - Ping/Keep-alive a ListenKey (USER_STREAM). - - Parameters - ---------- - key : str - The listen key for the request. - - Returns - ------- - dict[str, Any] - - References - ---------- - https://binance-docs.github.io/apidocs/futures/en/#keepalive-user-data-stream-user_stream - - """ - raw: bytes = await self.client.send_request( - http_method="PUT", - url_path=self.BASE_ENDPOINT + "listenKey", - payload={"listenKey": key}, - ) - - return msgspec.json.decode(raw) - - async def close_listen_key(self, key: str) -> dict[str, Any]: - """ - Close a user data stream for the Binance FUTURES_USDT or FUTURES_COIN API. - - Parameters - ---------- - key : str - The listen key for the request. - - Returns - ------- - dict[str, Any] - - References - ---------- - https://binance-docs.github.io/apidocs/futures/en/#close-user-data-stream-user_stream - - """ - raw: bytes = await self.client.send_request( - http_method="DELETE", - url_path=self.BASE_ENDPOINT + "listenKey", - payload={"listenKey": key}, - ) - - return msgspec.json.decode(raw) diff --git a/nautilus_trader/adapters/binance/futures/http/wallet.py b/nautilus_trader/adapters/binance/futures/http/wallet.py index 9b94489f5b91..cbe18ed00ba1 100644 --- a/nautilus_trader/adapters/binance/futures/http/wallet.py +++ b/nautilus_trader/adapters/binance/futures/http/wallet.py @@ -17,7 +17,68 @@ import msgspec +from nautilus_trader.adapters.binance.common.enums import BinanceAccountType +from nautilus_trader.adapters.binance.common.enums import BinanceMethodType +from nautilus_trader.adapters.binance.common.enums import BinanceSecurityType +from nautilus_trader.adapters.binance.common.schemas.symbol import BinanceSymbol +from nautilus_trader.adapters.binance.futures.schemas.wallet import BinanceFuturesCommissionRate from nautilus_trader.adapters.binance.http.client import BinanceHttpClient +from nautilus_trader.adapters.binance.http.endpoint import BinanceHttpEndpoint +from nautilus_trader.common.clock import LiveClock + + +class BinanceFuturesCommissionRateHttp(BinanceHttpEndpoint): + """ + Endpoint of maker/taker commission rate information + + `GET /fapi/v1/commissionRate` + `GET /dapi/v1/commissionRate` + + References + ---------- + https://binance-docs.github.io/apidocs/futures/en/#user-commission-rate-user_data + https://binance-docs.github.io/apidocs/delivery/en/#user-commission-rate-user_data + + """ + + def __init__( + self, + client: BinanceHttpClient, + base_endpoint: str, + ): + methods = { + BinanceMethodType.GET: BinanceSecurityType.USER_DATA, + } + super().__init__( + client, + methods, + base_endpoint + "commissionRate", + ) + self._get_resp_decoder = msgspec.json.Decoder(BinanceFuturesCommissionRate) + + class GetParameters(msgspec.Struct, omit_defaults=True, frozen=True): + """ + GET parameters for fetching commission rate + + Parameters + ---------- + symbol : BinanceSymbol + Receive commission rate of the provided symbol + recvWindow : str + Optional number of milliseconds after timestamp the request is valid + timestamp : str + Millisecond timestamp of the request + + """ + + timestamp: str + symbol: BinanceSymbol + recvWindow: Optional[str] = None + + async def _get(self, parameters: GetParameters) -> BinanceFuturesCommissionRate: + method_type = BinanceMethodType.GET + raw = await self._method(method_type, parameters) + return self._get_resp_decoder.decode(raw) class BinanceFuturesWalletHttpAPI: @@ -30,45 +91,45 @@ class BinanceFuturesWalletHttpAPI: The Binance REST API client. """ - def __init__(self, client: BinanceHttpClient): + def __init__( + self, + client: BinanceHttpClient, + clock: LiveClock, + account_type: BinanceAccountType = BinanceAccountType.FUTURES_USDT, + ): self.client = client + self._clock = clock - async def commission_rate( - self, - symbol: Optional[str] = None, - recv_window: Optional[int] = None, - ) -> list[dict[str, str]]: - """ - Fetch trade fee. + if account_type == BinanceAccountType.FUTURES_USDT: + self.base_endpoint = "/fapi/v1/" + elif account_type == BinanceAccountType.FUTURES_COIN: + self.base_endpoint = "/dapi/v1/" - `GET /sapi/v1/asset/tradeFee` + if not account_type.is_futures: + raise RuntimeError( # pragma: no cover (design-time error) + f"`BinanceAccountType` not FUTURES_USDT or FUTURES_COIN, was {account_type}", # pragma: no cover + ) - Parameters - ---------- - symbol : str, optional - The trading pair. If None then queries for all symbols. - recv_window : int, optional - The acceptable receive window for the response. - - Returns - ------- - list[dict[str, str]] + self._endpoint_futures_commission_rate = BinanceFuturesCommissionRateHttp( + client, + self.base_endpoint, + ) - References - ---------- - https://binance-docs.github.io/apidocs/spot/en/#trade-fee-user_data + def _timestamp(self) -> str: + """Create Binance timestamp from internal clock""" + return str(self._clock.timestamp_ms()) - """ - payload: dict[str, str] = {} - if symbol is not None: - payload["symbol"] = symbol - if recv_window is not None: - payload["recv_window"] = str(recv_window) - - raw: bytes = await self.client.sign_request( - http_method="GET", - url_path="/fapi/v1/commissionRate", - payload=payload, + async def query_futures_commission_rate( + self, + symbol: str, + recv_window: Optional[str] = None, + ) -> BinanceFuturesCommissionRate: + """Get Futures commission rates for a given symbol.""" + rate = await self._endpoint_futures_commission_rate._get( + parameters=self._endpoint_futures_commission_rate.GetParameters( + timestamp=self._timestamp(), + symbol=BinanceSymbol(symbol), + recvWindow=recv_window, + ), ) - - return msgspec.json.decode(raw) + return rate diff --git a/nautilus_trader/adapters/binance/futures/parsing/__init__.py b/nautilus_trader/adapters/binance/futures/parsing/__init__.py deleted file mode 100644 index ca16b56e4794..000000000000 --- a/nautilus_trader/adapters/binance/futures/parsing/__init__.py +++ /dev/null @@ -1,14 +0,0 @@ -# ------------------------------------------------------------------------------------------------- -# Copyright (C) 2015-2023 Nautech Systems Pty Ltd. All rights reserved. -# https://nautechsystems.io -# -# Licensed under the GNU Lesser General Public License Version 3.0 (the "License"); -# You may not use this file except in compliance with the License. -# You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# ------------------------------------------------------------------------------------------------- diff --git a/nautilus_trader/adapters/binance/futures/parsing/account.py b/nautilus_trader/adapters/binance/futures/parsing/account.py deleted file mode 100644 index 6cede2ce4623..000000000000 --- a/nautilus_trader/adapters/binance/futures/parsing/account.py +++ /dev/null @@ -1,72 +0,0 @@ -# ------------------------------------------------------------------------------------------------- -# Copyright (C) 2015-2023 Nautech Systems Pty Ltd. All rights reserved. -# https://nautechsystems.io -# -# Licensed under the GNU Lesser General Public License Version 3.0 (the "License"); -# You may not use this file except in compliance with the License. -# You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# ------------------------------------------------------------------------------------------------- - -from decimal import Decimal - -from nautilus_trader.adapters.binance.futures.schemas.account import BinanceFuturesAssetInfo -from nautilus_trader.adapters.binance.futures.schemas.user import BinanceFuturesBalance -from nautilus_trader.model.currency import Currency -from nautilus_trader.model.objects import AccountBalance -from nautilus_trader.model.objects import MarginBalance -from nautilus_trader.model.objects import Money - - -def parse_account_balances_http(assets: list[BinanceFuturesAssetInfo]) -> list[AccountBalance]: - balances: list[AccountBalance] = [] - for a in assets: - currency = Currency.from_str(a.asset) - total = Decimal(a.walletBalance) - locked = Decimal(a.initialMargin) + Decimal(a.maintMargin) - free = total - locked - - balance = AccountBalance( - total=Money(total, currency), - locked=Money(locked, currency), - free=Money(free, currency), - ) - balances.append(balance) - - return balances - - -def parse_account_balances_ws(raw_balances: list[BinanceFuturesBalance]) -> list[AccountBalance]: - balances: list[AccountBalance] = [] - for b in raw_balances: - currency = Currency.from_str(b.a) - free = Decimal(b.wb) - locked = Decimal(0) # TODO(cs): Pending refactoring of accounting - total: Decimal = free + locked - - balance = AccountBalance( - total=Money(total, currency), - locked=Money(locked, currency), - free=Money(free, currency), - ) - balances.append(balance) - - return balances - - -def parse_account_margins_http(assets: list[BinanceFuturesAssetInfo]) -> list[MarginBalance]: - margins: list[MarginBalance] = [] - for a in assets: - currency: Currency = Currency.from_str(a.asset) - margin = MarginBalance( - initial=Money(Decimal(a.initialMargin), currency), - maintenance=Money(Decimal(a.maintMargin), currency), - ) - margins.append(margin) - - return margins diff --git a/nautilus_trader/adapters/binance/futures/parsing/data.py b/nautilus_trader/adapters/binance/futures/parsing/data.py deleted file mode 100644 index e623cd26b6cf..000000000000 --- a/nautilus_trader/adapters/binance/futures/parsing/data.py +++ /dev/null @@ -1,281 +0,0 @@ -# ------------------------------------------------------------------------------------------------- -# Copyright (C) 2015-2023 Nautech Systems Pty Ltd. All rights reserved. -# https://nautechsystems.io -# -# Licensed under the GNU Lesser General Public License Version 3.0 (the "License"); -# You may not use this file except in compliance with the License. -# You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# ------------------------------------------------------------------------------------------------- - -from datetime import datetime as dt -from decimal import Decimal - -import msgspec - -from nautilus_trader.adapters.binance.common.constants import BINANCE_VENUE -from nautilus_trader.adapters.binance.common.enums import BinanceAccountType -from nautilus_trader.adapters.binance.common.enums import BinanceSymbolFilterType -from nautilus_trader.adapters.binance.common.functions import parse_symbol -from nautilus_trader.adapters.binance.common.schemas import BinanceOrderBookData -from nautilus_trader.adapters.binance.futures.schemas.market import BinanceFuturesMarkPriceData -from nautilus_trader.adapters.binance.futures.schemas.market import BinanceFuturesSymbolInfo -from nautilus_trader.adapters.binance.futures.schemas.market import BinanceFuturesTradeData -from nautilus_trader.adapters.binance.futures.schemas.market import BinanceSymbolFilter -from nautilus_trader.adapters.binance.futures.types import BinanceFuturesMarkPriceUpdate -from nautilus_trader.core.correctness import PyCondition -from nautilus_trader.core.datetime import millis_to_nanos -from nautilus_trader.model.currency import Currency -from nautilus_trader.model.data.tick import TradeTick -from nautilus_trader.model.enums import AggressorSide -from nautilus_trader.model.enums import BookType -from nautilus_trader.model.enums import CurrencyType -from nautilus_trader.model.identifiers import InstrumentId -from nautilus_trader.model.identifiers import Symbol -from nautilus_trader.model.identifiers import TradeId -from nautilus_trader.model.instruments.crypto_future import CryptoFuture -from nautilus_trader.model.instruments.crypto_perpetual import CryptoPerpetual -from nautilus_trader.model.objects import PRICE_MAX -from nautilus_trader.model.objects import PRICE_MIN -from nautilus_trader.model.objects import QUANTITY_MAX -from nautilus_trader.model.objects import QUANTITY_MIN -from nautilus_trader.model.objects import Money -from nautilus_trader.model.objects import Price -from nautilus_trader.model.objects import Quantity -from nautilus_trader.model.orderbook.data import OrderBookSnapshot - - -def parse_perpetual_instrument_http( - symbol_info: BinanceFuturesSymbolInfo, - ts_event: int, - ts_init: int, -) -> CryptoPerpetual: - # Create base asset - base_currency = Currency( - code=symbol_info.baseAsset, - precision=symbol_info.baseAssetPrecision, - iso4217=0, # Currently undetermined for crypto assets - name=symbol_info.baseAsset, - currency_type=CurrencyType.CRYPTO, - ) - - # Create quote asset - quote_currency = Currency( - code=symbol_info.quoteAsset, - precision=symbol_info.quotePrecision, - iso4217=0, # Currently undetermined for crypto assets - name=symbol_info.quoteAsset, - currency_type=CurrencyType.CRYPTO, - ) - - native_symbol = Symbol(symbol_info.symbol) - symbol = parse_symbol(symbol_info.symbol, BinanceAccountType.FUTURES_USDT) - instrument_id = InstrumentId(symbol=Symbol(symbol), venue=BINANCE_VENUE) - - # Parse instrument filters - filters: dict[BinanceSymbolFilterType, BinanceSymbolFilter] = { - f.filterType: f for f in symbol_info.filters - } - price_filter: BinanceSymbolFilter = filters[BinanceSymbolFilterType.PRICE_FILTER] - lot_size_filter: BinanceSymbolFilter = filters[BinanceSymbolFilterType.LOT_SIZE] - min_notional_filter: BinanceSymbolFilter = filters[BinanceSymbolFilterType.MIN_NOTIONAL] - - tick_size = price_filter.tickSize.rstrip("0") - step_size = lot_size_filter.stepSize.rstrip("0") - PyCondition.in_range(float(tick_size), PRICE_MIN, PRICE_MAX, "tick_size") - PyCondition.in_range(float(step_size), QUANTITY_MIN, QUANTITY_MAX, "step_size") - - price_precision = abs(Decimal(tick_size).as_tuple().exponent) - size_precision = abs(Decimal(step_size).as_tuple().exponent) - price_increment = Price.from_str(tick_size) - size_increment = Quantity.from_str(step_size) - max_quantity = Quantity(float(lot_size_filter.maxQty), precision=size_precision) - min_quantity = Quantity(float(lot_size_filter.minQty), precision=size_precision) - min_notional = None - if filters.get(BinanceSymbolFilterType.MIN_NOTIONAL): - min_notional = Money(min_notional_filter.minNotional, currency=quote_currency) - max_price = Price(float(price_filter.maxPrice), precision=price_precision) - min_price = Price(float(price_filter.minPrice), precision=price_precision) - - # Futures commissions - maker_fee = Decimal("0.000200") # TODO - taker_fee = Decimal("0.000400") # TODO - - if symbol_info.marginAsset == symbol_info.baseAsset: - settlement_currency = base_currency - elif symbol_info.marginAsset == symbol_info.quoteAsset: - settlement_currency = quote_currency - else: - raise ValueError(f"Unrecognized margin asset {symbol_info.marginAsset}") - - # Create instrument - return CryptoPerpetual( - instrument_id=instrument_id, - native_symbol=native_symbol, - base_currency=base_currency, - quote_currency=quote_currency, - settlement_currency=settlement_currency, - is_inverse=False, # No inverse instruments trade on Binance - price_precision=price_precision, - size_precision=size_precision, - price_increment=price_increment, - size_increment=size_increment, - max_quantity=max_quantity, - min_quantity=min_quantity, - max_notional=None, - min_notional=min_notional, - max_price=max_price, - min_price=min_price, - margin_init=Decimal(float(symbol_info.requiredMarginPercent) / 100), - margin_maint=Decimal(float(symbol_info.maintMarginPercent) / 100), - maker_fee=maker_fee, - taker_fee=taker_fee, - ts_event=ts_event, - ts_init=ts_init, - info=msgspec.json.decode(msgspec.json.encode(symbol_info)), - ) - - -def parse_futures_instrument_http( - symbol_info: BinanceFuturesSymbolInfo, - ts_event: int, - ts_init: int, -) -> CryptoFuture: - # Create base asset - base_currency = Currency( - code=symbol_info.baseAsset, - precision=symbol_info.baseAssetPrecision, - iso4217=0, # Currently undetermined for crypto assets - name=symbol_info.baseAsset, - currency_type=CurrencyType.CRYPTO, - ) - - # Create quote asset - quote_currency = Currency( - code=symbol_info.quoteAsset, - precision=symbol_info.quotePrecision, - iso4217=0, # Currently undetermined for crypto assets - name=symbol_info.quoteAsset, - currency_type=CurrencyType.CRYPTO, - ) - - native_symbol = Symbol(symbol_info.symbol) - symbol = parse_symbol(symbol_info.symbol, BinanceAccountType.FUTURES_USDT) - instrument_id = InstrumentId(symbol=Symbol(symbol), venue=BINANCE_VENUE) - - # Parse instrument filters - filters: dict[BinanceSymbolFilterType, BinanceSymbolFilter] = { - f.filterType: f for f in symbol_info.filters - } - price_filter: BinanceSymbolFilter = filters.get(BinanceSymbolFilterType.PRICE_FILTER) - lot_size_filter: BinanceSymbolFilter = filters.get(BinanceSymbolFilterType.LOT_SIZE) - min_notional_filter: BinanceSymbolFilter = filters.get(BinanceSymbolFilterType.MIN_NOTIONAL) - - tick_size = price_filter.tickSize.rstrip("0") - step_size = lot_size_filter.stepSize.rstrip("0") - PyCondition.in_range(float(tick_size), PRICE_MIN, PRICE_MAX, "tick_size") - PyCondition.in_range(float(step_size), QUANTITY_MIN, QUANTITY_MAX, "step_size") - - price_precision = abs(Decimal(tick_size).as_tuple().exponent) - size_precision = abs(Decimal(step_size).as_tuple().exponent) - price_increment = Price.from_str(tick_size) - size_increment = Quantity.from_str(step_size) - max_quantity = Quantity(float(lot_size_filter.maxQty), precision=size_precision) - min_quantity = Quantity(float(lot_size_filter.minQty), precision=size_precision) - min_notional = None - if filters.get(BinanceSymbolFilterType.MIN_NOTIONAL): - min_notional = Money(min_notional_filter.minNotional, currency=quote_currency) - max_price = Price(float(price_filter.maxPrice), precision=price_precision) - min_price = Price(float(price_filter.minPrice), precision=price_precision) - - # Futures commissions - maker_fee = Decimal("0.000200") # TODO - taker_fee = Decimal("0.000400") # TODO - - if symbol_info.marginAsset == symbol_info.baseAsset: - settlement_currency = base_currency - elif symbol_info.marginAsset == symbol_info.quoteAsset: - settlement_currency = quote_currency - else: - raise ValueError(f"Unrecognized margin asset {symbol_info.marginAsset}") - - # Create instrument - return CryptoFuture( - instrument_id=instrument_id, - native_symbol=native_symbol, - underlying=base_currency, - quote_currency=quote_currency, - settlement_currency=settlement_currency, - expiry_date=dt.strptime(symbol_info.symbol.partition("_")[2], "%y%m%d").date(), - price_precision=price_precision, - size_precision=size_precision, - price_increment=price_increment, - size_increment=size_increment, - max_quantity=max_quantity, - min_quantity=min_quantity, - max_notional=None, - min_notional=min_notional, - max_price=max_price, - min_price=min_price, - margin_init=Decimal(float(symbol_info.requiredMarginPercent) / 100), - margin_maint=Decimal(float(symbol_info.maintMarginPercent) / 100), - maker_fee=maker_fee, - taker_fee=taker_fee, - ts_event=ts_event, - ts_init=ts_init, - info=msgspec.json.decode(msgspec.json.encode(symbol_info)), - ) - - -def parse_futures_book_snapshot( - instrument_id: InstrumentId, - data: BinanceOrderBookData, - ts_init: int, -) -> OrderBookSnapshot: - return OrderBookSnapshot( - instrument_id=instrument_id, - book_type=BookType.L2_MBP, - bids=[[float(o[0]), float(o[1])] for o in data.b], - asks=[[float(o[0]), float(o[1])] for o in data.a], - ts_event=millis_to_nanos(data.T), - ts_init=ts_init, - sequence=data.u, - ) - - -def parse_futures_mark_price_ws( - instrument_id: InstrumentId, - data: BinanceFuturesMarkPriceData, - ts_init: int, -) -> BinanceFuturesMarkPriceUpdate: - return BinanceFuturesMarkPriceUpdate( - instrument_id=instrument_id, - mark=Price.from_str(data.p), - index=Price.from_str(data.i), - estimated_settle=Price.from_str(data.P), - funding_rate=Decimal(data.r), - ts_next_funding=millis_to_nanos(data.T), - ts_event=millis_to_nanos(data.E), - ts_init=ts_init, - ) - - -def parse_futures_trade_tick_ws( - instrument_id: InstrumentId, - data: BinanceFuturesTradeData, - ts_init: int, -) -> TradeTick: - return TradeTick( - instrument_id=instrument_id, - price=Price.from_str(data.p), - size=Quantity.from_str(data.q), - aggressor_side=AggressorSide.SELLER if data.m else AggressorSide.BUYER, - trade_id=TradeId(str(data.t)), - ts_event=millis_to_nanos(data.T), - ts_init=ts_init, - ) diff --git a/nautilus_trader/adapters/binance/futures/parsing/execution.py b/nautilus_trader/adapters/binance/futures/parsing/execution.py deleted file mode 100644 index 76cd590f8586..000000000000 --- a/nautilus_trader/adapters/binance/futures/parsing/execution.py +++ /dev/null @@ -1,207 +0,0 @@ -# ------------------------------------------------------------------------------------------------- -# Copyright (C) 2015-2023 Nautech Systems Pty Ltd. All rights reserved. -# https://nautechsystems.io -# -# Licensed under the GNU Lesser General Public License Version 3.0 (the "License"); -# You may not use this file except in compliance with the License. -# You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# ------------------------------------------------------------------------------------------------- - -from decimal import Decimal - -from nautilus_trader.adapters.binance.common.enums import BinanceOrderStatus -from nautilus_trader.adapters.binance.futures.enums import BinanceFuturesOrderType -from nautilus_trader.adapters.binance.futures.enums import BinanceFuturesTimeInForce -from nautilus_trader.adapters.binance.futures.enums import BinanceFuturesWorkingType -from nautilus_trader.adapters.binance.futures.schemas.account import BinanceFuturesAccountTrade -from nautilus_trader.adapters.binance.futures.schemas.account import BinanceFuturesOrder -from nautilus_trader.adapters.binance.futures.schemas.account import BinanceFuturesPositionRisk -from nautilus_trader.core.datetime import millis_to_nanos -from nautilus_trader.core.uuid import UUID4 -from nautilus_trader.execution.reports import OrderStatusReport -from nautilus_trader.execution.reports import PositionStatusReport -from nautilus_trader.execution.reports import TradeReport -from nautilus_trader.model.currency import Currency -from nautilus_trader.model.enums import LiquiditySide -from nautilus_trader.model.enums import OrderSide -from nautilus_trader.model.enums import OrderStatus -from nautilus_trader.model.enums import OrderType -from nautilus_trader.model.enums import PositionSide -from nautilus_trader.model.enums import TimeInForce -from nautilus_trader.model.enums import TrailingOffsetType -from nautilus_trader.model.enums import TriggerType -from nautilus_trader.model.identifiers import AccountId -from nautilus_trader.model.identifiers import ClientOrderId -from nautilus_trader.model.identifiers import InstrumentId -from nautilus_trader.model.identifiers import PositionId -from nautilus_trader.model.identifiers import TradeId -from nautilus_trader.model.identifiers import VenueOrderId -from nautilus_trader.model.objects import Money -from nautilus_trader.model.objects import Price -from nautilus_trader.model.objects import Quantity -from nautilus_trader.model.orders.base import Order - - -def binance_order_type(order: Order) -> BinanceFuturesOrderType: - if order.order_type == OrderType.MARKET: - return BinanceFuturesOrderType.MARKET - elif order.order_type == OrderType.LIMIT: - return BinanceFuturesOrderType.LIMIT - elif order.order_type == OrderType.STOP_MARKET: - return BinanceFuturesOrderType.STOP_MARKET - elif order.order_type == OrderType.STOP_LIMIT: - return BinanceFuturesOrderType.STOP - elif order.order_type == OrderType.MARKET_IF_TOUCHED: - return BinanceFuturesOrderType.TAKE_PROFIT_MARKET - elif order.order_type == OrderType.LIMIT_IF_TOUCHED: - return BinanceFuturesOrderType.TAKE_PROFIT - elif order.order_type == OrderType.TRAILING_STOP_MARKET: - return BinanceFuturesOrderType.TRAILING_STOP_MARKET - else: - raise RuntimeError("invalid `OrderType`") # pragma: no cover (design-time error) - - -def parse_order_type(order_type: BinanceFuturesOrderType) -> OrderType: - if order_type == BinanceFuturesOrderType.STOP: - return OrderType.STOP_LIMIT - elif order_type == BinanceFuturesOrderType.STOP_MARKET: - return OrderType.STOP_MARKET - elif order_type == BinanceFuturesOrderType.TAKE_PROFIT: - return OrderType.LIMIT_IF_TOUCHED - elif order_type == BinanceFuturesOrderType.TAKE_PROFIT_MARKET: - return OrderType.MARKET_IF_TOUCHED - else: - return OrderType[order_type.value] - - -def parse_order_status(status: BinanceOrderStatus) -> OrderStatus: - if status == BinanceOrderStatus.NEW: - return OrderStatus.ACCEPTED - elif status == BinanceOrderStatus.CANCELED: - return OrderStatus.CANCELED - elif status == BinanceOrderStatus.PARTIALLY_FILLED: - return OrderStatus.PARTIALLY_FILLED - elif status == BinanceOrderStatus.FILLED: - return OrderStatus.FILLED - elif status == BinanceOrderStatus.NEW_ADL: - return OrderStatus.FILLED - elif status == BinanceOrderStatus.NEW_INSURANCE: - return OrderStatus.FILLED - elif status == BinanceOrderStatus.EXPIRED: - return OrderStatus.EXPIRED - else: - raise RuntimeError( # pragma: no cover (design-time error) - f"unrecognized order status, was {status}", # pragma: no cover - ) - - -def parse_time_in_force(time_in_force: BinanceFuturesTimeInForce) -> TimeInForce: - if time_in_force == BinanceFuturesTimeInForce.GTX: - return TimeInForce.GTC - else: - return TimeInForce[time_in_force.value] - - -def parse_trigger_type(working_type: BinanceFuturesWorkingType) -> TriggerType: - if working_type == BinanceFuturesWorkingType.CONTRACT_PRICE: - return TriggerType.LAST_TRADE - elif working_type == BinanceFuturesWorkingType.MARK_PRICE: - return TriggerType.MARK_PRICE - else: - return TriggerType.NO_TRIGGER # pragma: no cover (design-time error) - - -def parse_order_report_http( - account_id: AccountId, - instrument_id: InstrumentId, - data: BinanceFuturesOrder, - report_id: UUID4, - ts_init: int, -) -> OrderStatusReport: - price = Decimal(data.price) - trigger_price = Decimal(data.stopPrice) - avg_px = Decimal(data.avgPrice) - time_in_force = BinanceFuturesTimeInForce(data.timeInForce.upper()) - return OrderStatusReport( - account_id=account_id, - instrument_id=instrument_id, - client_order_id=ClientOrderId(data.clientOrderId) if data.clientOrderId != "" else None, - venue_order_id=VenueOrderId(str(data.orderId)), - order_side=OrderSide[data.side.upper()], - order_type=parse_order_type(data.type), - time_in_force=parse_time_in_force(time_in_force), - order_status=parse_order_status(data.status), - price=Price.from_str(data.price) if price is not None else None, - quantity=Quantity.from_str(data.origQty), - filled_qty=Quantity.from_str(data.executedQty), - avg_px=avg_px if avg_px > 0 else None, - post_only=time_in_force == BinanceFuturesTimeInForce.GTX, - reduce_only=data.reduceOnly, - report_id=report_id, - ts_accepted=millis_to_nanos(data.time), - ts_last=millis_to_nanos(data.updateTime), - ts_init=ts_init, - trigger_price=Price.from_str(str(trigger_price)) if trigger_price > 0 else None, - trigger_type=parse_trigger_type(data.workingType), - trailing_offset=Decimal(data.priceRate) * 100 if data.priceRate is not None else None, - trailing_offset_type=TrailingOffsetType.BASIS_POINTS - if data.priceRate is not None - else TrailingOffsetType.NO_TRAILING_OFFSET, - ) - - -def parse_trade_report_http( - account_id: AccountId, - instrument_id: InstrumentId, - data: BinanceFuturesAccountTrade, - report_id: UUID4, - ts_init: int, -) -> TradeReport: - return TradeReport( - account_id=account_id, - instrument_id=instrument_id, - venue_order_id=VenueOrderId(str(data.orderId)), - venue_position_id=PositionId(f"{instrument_id}-{data.positionSide.value}"), - trade_id=TradeId(str(data.id)), - order_side=OrderSide[data.side.value], - last_qty=Quantity.from_str(data.qty), - last_px=Price.from_str(data.price), - commission=Money(data.commission, Currency.from_str(data.commissionAsset)), - liquidity_side=LiquiditySide.MAKER if data.maker else LiquiditySide.TAKER, - report_id=report_id, - ts_event=millis_to_nanos(data.time), - ts_init=ts_init, - ) - - -def parse_position_report_http( - account_id: AccountId, - instrument_id: InstrumentId, - data: BinanceFuturesPositionRisk, - report_id: UUID4, - ts_init: int, -) -> PositionStatusReport: - net_size = Decimal(data.positionAmt) - - if net_size > 0: - position_side = PositionSide.LONG - elif net_size < 0: - position_side = PositionSide.SHORT - else: - position_side = PositionSide.FLAT - - return PositionStatusReport( - account_id=account_id, - instrument_id=instrument_id, - position_side=position_side, - quantity=Quantity.from_str(str(abs(net_size))), - report_id=report_id, - ts_last=ts_init, - ts_init=ts_init, - ) diff --git a/nautilus_trader/adapters/binance/futures/providers.py b/nautilus_trader/adapters/binance/futures/providers.py index 1674339b3120..0a2163d82fd0 100644 --- a/nautilus_trader/adapters/binance/futures/providers.py +++ b/nautilus_trader/adapters/binance/futures/providers.py @@ -13,26 +13,42 @@ # limitations under the License. # ------------------------------------------------------------------------------------------------- -import time -from typing import Any, Optional +from datetime import datetime as dt +from decimal import Decimal +from typing import Optional + +import msgspec from nautilus_trader.adapters.binance.common.constants import BINANCE_VENUE from nautilus_trader.adapters.binance.common.enums import BinanceAccountType +from nautilus_trader.adapters.binance.common.enums import BinanceSymbolFilterType +from nautilus_trader.adapters.binance.common.schemas.market import BinanceSymbolFilter +from nautilus_trader.adapters.binance.common.schemas.symbol import BinanceSymbol from nautilus_trader.adapters.binance.futures.enums import BinanceFuturesContractStatus from nautilus_trader.adapters.binance.futures.enums import BinanceFuturesContractType from nautilus_trader.adapters.binance.futures.http.market import BinanceFuturesMarketHttpAPI from nautilus_trader.adapters.binance.futures.http.wallet import BinanceFuturesWalletHttpAPI -from nautilus_trader.adapters.binance.futures.parsing.data import parse_futures_instrument_http -from nautilus_trader.adapters.binance.futures.parsing.data import parse_perpetual_instrument_http -from nautilus_trader.adapters.binance.futures.schemas.market import BinanceFuturesExchangeInfo from nautilus_trader.adapters.binance.futures.schemas.market import BinanceFuturesSymbolInfo +from nautilus_trader.adapters.binance.futures.schemas.wallet import BinanceFuturesCommissionRate from nautilus_trader.adapters.binance.http.client import BinanceHttpClient +from nautilus_trader.adapters.binance.http.error import BinanceClientError +from nautilus_trader.common.clock import LiveClock from nautilus_trader.common.logging import Logger from nautilus_trader.common.providers import InstrumentProvider from nautilus_trader.config import InstrumentProviderConfig from nautilus_trader.core.correctness import PyCondition from nautilus_trader.core.datetime import millis_to_nanos from nautilus_trader.model.identifiers import InstrumentId +from nautilus_trader.model.identifiers import Symbol +from nautilus_trader.model.instruments.crypto_future import CryptoFuture +from nautilus_trader.model.instruments.crypto_perpetual import CryptoPerpetual +from nautilus_trader.model.objects import PRICE_MAX +from nautilus_trader.model.objects import PRICE_MIN +from nautilus_trader.model.objects import QUANTITY_MAX +from nautilus_trader.model.objects import QUANTITY_MIN +from nautilus_trader.model.objects import Money +from nautilus_trader.model.objects import Price +from nautilus_trader.model.objects import Quantity class BinanceFuturesInstrumentProvider(InstrumentProvider): @@ -53,6 +69,7 @@ def __init__( self, client: BinanceHttpClient, logger: Logger, + clock: LiveClock, account_type: BinanceAccountType = BinanceAccountType.FUTURES_USDT, config: Optional[InstrumentProviderConfig] = None, ): @@ -64,22 +81,44 @@ def __init__( self._client = client self._account_type = account_type + self._clock = clock - self._http_wallet = BinanceFuturesWalletHttpAPI(self._client) + self._http_wallet = BinanceFuturesWalletHttpAPI( + self._client, + clock=self._clock, + account_type=account_type, + ) self._http_market = BinanceFuturesMarketHttpAPI(self._client, account_type=account_type) self._log_warnings = config.log_warnings if config else True + self._decoder = msgspec.json.Decoder() + self._encoder = msgspec.json.Encoder() + async def load_all_async(self, filters: Optional[dict] = None) -> None: filters_str = "..." if not filters else f" with filters {filters}..." self._log.info(f"Loading all instruments{filters_str}") # Get exchange info for all assets - exchange_info: BinanceFuturesExchangeInfo = await self._http_market.exchange_info() + exchange_info = await self._http_market.query_futures_exchange_info() + for symbol_info in exchange_info.symbols: + if self._client.base_url.__contains__("testnet.binancefuture.com"): + fee = None + else: + try: + # Get current commission rates for the symbol + fee = await self._http_wallet.query_futures_commission_rate(symbol_info.symbol) + except BinanceClientError as e: + self._log.error( + "Cannot load instruments: API key authentication failed " + f"(this is needed to fetch the applicable account fee tier). {e.message}", + ) + return + self._parse_instrument( symbol_info=symbol_info, - fees=None, + fee=fee, ts_event=millis_to_nanos(exchange_info.serverTime), ) @@ -100,18 +139,30 @@ async def load_ids_async( self._log.info(f"Loading instruments {instrument_ids}{filters_str}.") # Extract all symbol strings - symbols: list[str] = [ - instrument_id.symbol.value.replace("-PERP", "") for instrument_id in instrument_ids - ] + symbols = [instrument_id.symbol.value for instrument_id in instrument_ids] # Get exchange info for all assets - exchange_info: BinanceFuturesExchangeInfo = await self._http_market.exchange_info( - symbols=symbols, - ) - for symbol_info in exchange_info.symbols: + exchange_info = await self._http_market.query_futures_exchange_info() + symbol_info_dict: dict[str, BinanceFuturesSymbolInfo] = { + info.symbol: info for info in exchange_info.symbols + } + + for symbol in symbols: + if self._client.base_url.__contains__("testnet.binancefuture.com"): + fee = None + else: + try: + # Get current commission rates for the symbol + fee = await self._http_wallet.query_futures_commission_rate(symbol) + except BinanceClientError as e: + self._log.error( + "Cannot load instruments: API key authentication failed " + f"(this is needed to fetch the applicable account fee tier). {e.message}", + ) + self._parse_instrument( - symbol_info=symbol_info, - fees=None, + symbol_info=symbol_info_dict[symbol], + fee=fee, ts_event=millis_to_nanos(exchange_info.serverTime), ) @@ -122,24 +173,37 @@ async def load_async(self, instrument_id: InstrumentId, filters: Optional[dict] filters_str = "..." if not filters else f" with filters {filters}..." self._log.debug(f"Loading instrument {instrument_id}{filters_str}.") - symbol = instrument_id.symbol.value.replace("-PERP", "") + symbol = instrument_id.symbol.value # Get exchange info for all assets - exchange_info: BinanceFuturesExchangeInfo = await self._http_market.exchange_info( - symbol=symbol, + exchange_info = await self._http_market.query_futures_exchange_info() + symbol_info_dict: dict[str, BinanceFuturesSymbolInfo] = { + info.symbol: info for info in exchange_info.symbols + } + + if self._client.base_url.__contains__("testnet.binancefuture.com"): + fee = None + else: + try: + # Get current commission rates for the symbol + fee = await self._http_wallet.query_futures_commission_rate(symbol) + except BinanceClientError as e: + self._log.error( + "Cannot load instruments: API key authentication failed " + f"(this is needed to fetch the applicable account fee tier). {e.message}", + ) + + self._parse_instrument( + symbol_info=symbol_info_dict[symbol], + ts_event=millis_to_nanos(exchange_info.serverTime), + fee=fee, ) - for symbol_info in exchange_info.symbols: - self._parse_instrument( - symbol_info=symbol_info, - fees=None, - ts_event=millis_to_nanos(exchange_info.serverTime), - ) - def _parse_instrument( + def _parse_instrument( # noqa (C901 too complex) self, symbol_info: BinanceFuturesSymbolInfo, - fees: Optional[dict[str, Any]], ts_event: int, + fee: Optional[BinanceFuturesCommissionRate] = None, ) -> None: contract_type_str = symbol_info.contractType @@ -147,15 +211,90 @@ def _parse_instrument( contract_type_str == "" or symbol_info.status == BinanceFuturesContractStatus.PENDING_TRADING ): + self._log.debug(f"Instrument not yet defined: {symbol_info.symbol}") return # Not yet defined + ts_init = self._clock.timestamp_ns() try: + # Create quote and base assets + base_currency = symbol_info.parse_to_base_currency() + quote_currency = symbol_info.parse_to_quote_currency() + + binance_symbol = BinanceSymbol(symbol_info.symbol).parse_binance_to_internal( + self._account_type, + ) + native_symbol = Symbol(binance_symbol) + instrument_id = InstrumentId(symbol=native_symbol, venue=BINANCE_VENUE) + + # Parse instrument filters + filters: dict[BinanceSymbolFilterType, BinanceSymbolFilter] = { + f.filterType: f for f in symbol_info.filters + } + price_filter: BinanceSymbolFilter = filters.get(BinanceSymbolFilterType.PRICE_FILTER) + lot_size_filter: BinanceSymbolFilter = filters.get(BinanceSymbolFilterType.LOT_SIZE) + min_notional_filter: BinanceSymbolFilter = filters.get( + BinanceSymbolFilterType.MIN_NOTIONAL, + ) + + tick_size = price_filter.tickSize.rstrip("0") + step_size = lot_size_filter.stepSize.rstrip("0") + PyCondition.in_range(float(tick_size), PRICE_MIN, PRICE_MAX, "tick_size") + PyCondition.in_range(float(step_size), QUANTITY_MIN, QUANTITY_MAX, "step_size") + + price_precision = abs(Decimal(tick_size).as_tuple().exponent) + size_precision = abs(Decimal(step_size).as_tuple().exponent) + price_increment = Price.from_str(tick_size) + size_increment = Quantity.from_str(step_size) + max_quantity = Quantity(float(lot_size_filter.maxQty), precision=size_precision) + min_quantity = Quantity(float(lot_size_filter.minQty), precision=size_precision) + min_notional = None + if filters.get(BinanceSymbolFilterType.MIN_NOTIONAL): + min_notional = Money(min_notional_filter.minNotional, currency=quote_currency) + max_price = Price(float(price_filter.maxPrice), precision=price_precision) + min_price = Price(float(price_filter.minPrice), precision=price_precision) + + # Futures commissions + + maker_fee = Decimal(0) + taker_fee = Decimal(0) + if fee: + assert fee.symbol == symbol_info.symbol + maker_fee = Decimal(fee.makerCommissionRate) + taker_fee = Decimal(fee.takerCommissionRate) + + if symbol_info.marginAsset == symbol_info.baseAsset: + settlement_currency = base_currency + elif symbol_info.marginAsset == symbol_info.quoteAsset: + settlement_currency = quote_currency + else: + raise ValueError(f"Unrecognized margin asset {symbol_info.marginAsset}") + contract_type = BinanceFuturesContractType(contract_type_str) if contract_type == BinanceFuturesContractType.PERPETUAL: - instrument = parse_perpetual_instrument_http( - symbol_info=symbol_info, + instrument = CryptoPerpetual( + instrument_id=instrument_id, + native_symbol=native_symbol, + base_currency=base_currency, + quote_currency=quote_currency, + settlement_currency=settlement_currency, + is_inverse=False, # No inverse instruments trade on Binance + price_precision=price_precision, + size_precision=size_precision, + price_increment=price_increment, + size_increment=size_increment, + max_quantity=max_quantity, + min_quantity=min_quantity, + max_notional=None, + min_notional=min_notional, + max_price=max_price, + min_price=min_price, + margin_init=Decimal(float(symbol_info.requiredMarginPercent) / 100), + margin_maint=Decimal(float(symbol_info.maintMarginPercent) / 100), + maker_fee=maker_fee, + taker_fee=taker_fee, ts_event=ts_event, - ts_init=time.time_ns(), + ts_init=ts_init, + info=self._decoder.decode(self._encoder.encode(symbol_info)), ) self.add_currency(currency=instrument.base_currency) elif contract_type in ( @@ -164,10 +303,30 @@ def _parse_instrument( BinanceFuturesContractType.NEXT_MONTH, BinanceFuturesContractType.NEXT_QUARTER, ): - instrument = parse_futures_instrument_http( - symbol_info=symbol_info, + instrument = CryptoFuture( + instrument_id=instrument_id, + native_symbol=native_symbol, + underlying=base_currency, + quote_currency=quote_currency, + settlement_currency=settlement_currency, + expiry_date=dt.strptime(symbol_info.symbol.partition("_")[2], "%y%m%d").date(), + price_precision=price_precision, + size_precision=size_precision, + price_increment=price_increment, + size_increment=size_increment, + max_quantity=max_quantity, + min_quantity=min_quantity, + max_notional=None, + min_notional=min_notional, + max_price=max_price, + min_price=min_price, + margin_init=Decimal(float(symbol_info.requiredMarginPercent) / 100), + margin_maint=Decimal(float(symbol_info.maintMarginPercent) / 100), + maker_fee=maker_fee, + taker_fee=taker_fee, ts_event=ts_event, - ts_init=time.time_ns(), + ts_init=ts_init, + info=self._decoder.decode(self._encoder.encode(symbol_info)), ) self.add_currency(currency=instrument.underlying) else: diff --git a/nautilus_trader/adapters/binance/futures/rules.py b/nautilus_trader/adapters/binance/futures/rules.py deleted file mode 100644 index 2145847a58ee..000000000000 --- a/nautilus_trader/adapters/binance/futures/rules.py +++ /dev/null @@ -1,35 +0,0 @@ -# ------------------------------------------------------------------------------------------------- -# Copyright (C) 2015-2023 Nautech Systems Pty Ltd. All rights reserved. -# https://nautechsystems.io -# -# Licensed under the GNU Lesser General Public License Version 3.0 (the "License"); -# You may not use this file except in compliance with the License. -# You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# ------------------------------------------------------------------------------------------------- - -from nautilus_trader.model.enums import OrderType -from nautilus_trader.model.enums import TimeInForce - - -BINANCE_FUTURES_VALID_TIF = ( - TimeInForce.GTC, - TimeInForce.GTD, # Will be transformed to GTC with warning - TimeInForce.FOK, - TimeInForce.IOC, -) - -BINANCE_FUTURES_VALID_ORDER_TYPES = ( - OrderType.MARKET, - OrderType.LIMIT, - OrderType.STOP_MARKET, - OrderType.STOP_LIMIT, - OrderType.MARKET_IF_TOUCHED, - OrderType.LIMIT_IF_TOUCHED, - OrderType.TRAILING_STOP_MARKET, -) diff --git a/nautilus_trader/adapters/binance/futures/schemas/account.py b/nautilus_trader/adapters/binance/futures/schemas/account.py index c9dbee5ffccc..95c013019ffb 100644 --- a/nautilus_trader/adapters/binance/futures/schemas/account.py +++ b/nautilus_trader/adapters/binance/futures/schemas/account.py @@ -13,15 +13,22 @@ # limitations under the License. # ------------------------------------------------------------------------------------------------- +from decimal import Decimal from typing import Optional import msgspec -from nautilus_trader.adapters.binance.common.enums import BinanceOrderSide -from nautilus_trader.adapters.binance.common.enums import BinanceOrderStatus -from nautilus_trader.adapters.binance.futures.enums import BinanceFuturesOrderType +from nautilus_trader.adapters.binance.futures.enums import BinanceFuturesEnumParser from nautilus_trader.adapters.binance.futures.enums import BinanceFuturesPositionSide -from nautilus_trader.adapters.binance.futures.enums import BinanceFuturesWorkingType +from nautilus_trader.core.uuid import UUID4 +from nautilus_trader.execution.reports import PositionStatusReport +from nautilus_trader.model.currency import Currency +from nautilus_trader.model.identifiers import AccountId +from nautilus_trader.model.identifiers import InstrumentId +from nautilus_trader.model.objects import AccountBalance +from nautilus_trader.model.objects import MarginBalance +from nautilus_trader.model.objects import Money +from nautilus_trader.model.objects import Quantity ################################################################################ @@ -29,7 +36,7 @@ ################################################################################ -class BinanceFuturesAssetInfo(msgspec.Struct): +class BinanceFuturesBalanceInfo(msgspec.Struct, frozen=True): """ HTTP response 'inner struct' from `Binance Futures` GET /fapi/v2/account (HMAC SHA256). """ @@ -50,8 +57,26 @@ class BinanceFuturesAssetInfo(msgspec.Struct): marginAvailable: Optional[bool] = None updateTime: Optional[int] = None # last update time - -class BinanceFuturesAccountInfo(msgspec.Struct, kw_only=True): + def parse_to_account_balance(self) -> AccountBalance: + currency = Currency.from_str(self.asset) + total = Decimal(self.walletBalance) + locked = Decimal(self.initialMargin) + Decimal(self.maintMargin) + free = total - locked + return AccountBalance( + total=Money(total, currency), + locked=Money(locked, currency), + free=Money(free, currency), + ) + + def parse_to_margin_balance(self) -> MarginBalance: + currency: Currency = Currency.from_str(self.asset) + return MarginBalance( + initial=Money(Decimal(self.initialMargin), currency), + maintenance=Money(Decimal(self.maintMargin), currency), + ) + + +class BinanceFuturesAccountInfo(msgspec.Struct, kw_only=True, frozen=True): """ HTTP response from `Binance Futures` GET /fapi/v2/account (HMAC SHA256). """ @@ -77,61 +102,16 @@ class BinanceFuturesAccountInfo(msgspec.Struct, kw_only=True): totalCrossUnPnl: Optional[str] = None availableBalance: Optional[str] = None # available balance, only for USDT asset maxWithdrawAmount: Optional[str] = None # maximum amount for transfer out, only for USDT asset - assets: list[BinanceFuturesAssetInfo] + assets: list[BinanceFuturesBalanceInfo] + def parse_to_account_balances(self) -> list[AccountBalance]: + return [asset.parse_to_account_balance() for asset in self.assets] -class BinanceFuturesOrder(msgspec.Struct, kw_only=True): - """ - HTTP response from `Binance Futures` GET /fapi/v1/order (HMAC SHA256). - """ + def parse_to_margin_balances(self) -> list[MarginBalance]: + return [asset.parse_to_margin_balance() for asset in self.assets] - avgPrice: str - clientOrderId: str - cumQuote: str - executedQty: str - orderId: int - origQty: str - origType: str - price: str - reduceOnly: bool - side: str - positionSide: str - status: BinanceOrderStatus - stopPrice: str - closePosition: bool - symbol: str - time: int - timeInForce: str - type: BinanceFuturesOrderType - activatePrice: Optional[str] = None - priceRate: Optional[str] = None - updateTime: int - workingType: BinanceFuturesWorkingType - priceProtect: bool - -class BinanceFuturesAccountTrade(msgspec.Struct): - """ - HTTP response from ` Binance Futures` GET /fapi/v1/userTrades (HMAC SHA256). - """ - - buyer: bool - commission: str - commissionAsset: str - id: int - maker: bool - orderId: int - price: str - qty: str - quoteQty: str - realizedPnl: str - side: BinanceOrderSide - positionSide: BinanceFuturesPositionSide - symbol: str - time: int - - -class BinanceFuturesPositionRisk(msgspec.Struct, kw_only=True): +class BinanceFuturesPositionRisk(msgspec.Struct, kw_only=True, frozen=True): """ HTTP response from ` Binance Futures` GET /fapi/v2/positionRisk (HMAC SHA256). """ @@ -149,3 +129,33 @@ class BinanceFuturesPositionRisk(msgspec.Struct, kw_only=True): unRealizedProfit: str positionSide: BinanceFuturesPositionSide updateTime: int + + def parse_to_position_status_report( + self, + account_id: AccountId, + instrument_id: InstrumentId, + enum_parser: BinanceFuturesEnumParser, + report_id: UUID4, + ts_init: int, + ) -> PositionStatusReport: + position_side = enum_parser.parse_futures_position_side( + self.positionSide, + ) + net_size = Decimal(self.positionAmt) + return PositionStatusReport( + account_id=account_id, + instrument_id=instrument_id, + position_side=position_side, + quantity=Quantity.from_str(str(abs(net_size))), + report_id=report_id, + ts_last=ts_init, + ts_init=ts_init, + ) + + +class BinanceFuturesDualSidePosition(msgspec.Struct, frozen=True): + """ + HTTP response from `Binance Futures` GET /fapi/v1/positionSide/dual (HMAC SHA256) + """ + + dualSidePosition: bool diff --git a/nautilus_trader/adapters/binance/futures/schemas/market.py b/nautilus_trader/adapters/binance/futures/schemas/market.py index 07a1e87bf163..d99c9b07d68a 100644 --- a/nautilus_trader/adapters/binance/futures/schemas/market.py +++ b/nautilus_trader/adapters/binance/futures/schemas/market.py @@ -13,17 +13,27 @@ # limitations under the License. # ------------------------------------------------------------------------------------------------- +from decimal import Decimal from typing import Optional import msgspec -from nautilus_trader.adapters.binance.common.enums import BinanceExchangeFilterType -from nautilus_trader.adapters.binance.common.enums import BinanceRateLimitInterval -from nautilus_trader.adapters.binance.common.enums import BinanceRateLimitType -from nautilus_trader.adapters.binance.common.enums import BinanceSymbolFilterType +from nautilus_trader.adapters.binance.common.enums import BinanceOrderType +from nautilus_trader.adapters.binance.common.enums import BinanceTimeInForce +from nautilus_trader.adapters.binance.common.schemas.market import BinanceExchangeFilter +from nautilus_trader.adapters.binance.common.schemas.market import BinanceRateLimit +from nautilus_trader.adapters.binance.common.schemas.market import BinanceSymbolFilter from nautilus_trader.adapters.binance.futures.enums import BinanceFuturesContractStatus -from nautilus_trader.adapters.binance.futures.enums import BinanceFuturesOrderType -from nautilus_trader.adapters.binance.futures.enums import BinanceFuturesTimeInForce +from nautilus_trader.adapters.binance.futures.types import BinanceFuturesMarkPriceUpdate +from nautilus_trader.core.datetime import millis_to_nanos +from nautilus_trader.model.currency import Currency +from nautilus_trader.model.data.tick import TradeTick +from nautilus_trader.model.enums import AggressorSide +from nautilus_trader.model.enums import CurrencyType +from nautilus_trader.model.identifiers import InstrumentId +from nautilus_trader.model.identifiers import TradeId +from nautilus_trader.model.objects import Price +from nautilus_trader.model.objects import Quantity ################################################################################ @@ -31,50 +41,7 @@ ################################################################################ -class BinanceExchangeFilter(msgspec.Struct): - """HTTP response 'inner struct' from `Binance Futures` GET /fapi/v1/exchangeInfo.""" - - filterType: BinanceExchangeFilterType - maxNumOrders: Optional[int] = None - maxNumAlgoOrders: Optional[int] = None - - -class BinanceSymbolFilter(msgspec.Struct): - """HTTP response 'inner struct' from `Binance Futures` GET /fapi/v1/exchangeInfo.""" - - filterType: BinanceSymbolFilterType - minPrice: Optional[str] = None - maxPrice: Optional[str] = None - tickSize: Optional[str] = None - multiplierUp: Optional[str] = None - multiplierDown: Optional[str] = None - avgPriceMins: Optional[int] = None - bidMultiplierUp: Optional[str] = None - bidMultiplierDown: Optional[str] = None - askMultiplierUp: Optional[str] = None - askMultiplierDown: Optional[str] = None - minQty: Optional[str] = None - maxQty: Optional[str] = None - stepSize: Optional[str] = None - minNotional: Optional[str] = None - applyToMarket: Optional[bool] = None - limit: Optional[int] = None - maxNumOrders: Optional[int] = None - maxNumAlgoOrders: Optional[int] = None - maxNumIcebergOrders: Optional[int] = None - maxPosition: Optional[str] = None - - -class BinanceRateLimit(msgspec.Struct): - """HTTP response 'inner struct' from `Binance Futures` GET /fapi/v1/exchangeInfo.""" - - rateLimitType: BinanceRateLimitType - interval: BinanceRateLimitInterval - intervalNum: int - limit: int - - -class BinanceFuturesAsset(msgspec.Struct): +class BinanceFuturesAsset(msgspec.Struct, frozen=True): """HTTP response 'inner struct' from `Binance Futures` GET /fapi/v1/exchangeInfo.""" asset: str @@ -82,7 +49,7 @@ class BinanceFuturesAsset(msgspec.Struct): autoAssetExchange: str -class BinanceFuturesSymbolInfo(msgspec.Struct, kw_only=True): +class BinanceFuturesSymbolInfo(msgspec.Struct, kw_only=True, frozen=True): """HTTP response 'inner struct' from `Binance Futures` GET /fapi/v1/exchangeInfo.""" symbol: str @@ -107,11 +74,29 @@ class BinanceFuturesSymbolInfo(msgspec.Struct, kw_only=True): liquidationFee: str marketTakeBound: str filters: list[BinanceSymbolFilter] - orderTypes: list[BinanceFuturesOrderType] - timeInForce: list[BinanceFuturesTimeInForce] - - -class BinanceFuturesExchangeInfo(msgspec.Struct, kw_only=True): + orderTypes: list[BinanceOrderType] + timeInForce: list[BinanceTimeInForce] + + def parse_to_base_currency(self): + return Currency( + code=self.baseAsset, + precision=self.baseAssetPrecision, + iso4217=0, # Currently undetermined for crypto assets + name=self.baseAsset, + currency_type=CurrencyType.CRYPTO, + ) + + def parse_to_quote_currency(self): + return Currency( + code=self.quoteAsset, + precision=self.quotePrecision, + iso4217=0, # Currently undetermined for crypto assets + name=self.quoteAsset, + currency_type=CurrencyType.CRYPTO, + ) + + +class BinanceFuturesExchangeInfo(msgspec.Struct, kw_only=True, frozen=True): """HTTP response from `Binance Futures` GET /fapi/v1/exchangeInfo.""" timezone: str @@ -122,7 +107,7 @@ class BinanceFuturesExchangeInfo(msgspec.Struct, kw_only=True): symbols: list[BinanceFuturesSymbolInfo] -class BinanceFuturesMarkFunding(msgspec.Struct): +class BinanceFuturesMarkFunding(msgspec.Struct, frozen=True): """HTTP response from `Binance Future` GET /fapi/v1/premiumIndex.""" symbol: str @@ -135,7 +120,7 @@ class BinanceFuturesMarkFunding(msgspec.Struct): time: int -class BinanceFuturesFundRate(msgspec.Struct): +class BinanceFuturesFundRate(msgspec.Struct, frozen=True): """HTTP response from `Binance Future` GET /fapi/v1/fundingRate.""" symbol: str @@ -148,7 +133,7 @@ class BinanceFuturesFundRate(msgspec.Struct): ################################################################################ -class BinanceFuturesTradeData(msgspec.Struct): +class BinanceFuturesTradeData(msgspec.Struct, frozen=True): """ WebSocket message 'inner struct' for `Binance Futures` Trade Streams. @@ -173,18 +158,33 @@ class BinanceFuturesTradeData(msgspec.Struct): t: int # Trade ID p: str # Price q: str # Quantity - X: BinanceFuturesOrderType # Buyer order type + X: BinanceOrderType # Buyer order type m: bool # Is the buyer the market maker? - -class BinanceFuturesTradeMsg(msgspec.Struct): + def parse_to_trade_tick( + self, + instrument_id: InstrumentId, + ts_init: int, + ) -> TradeTick: + return TradeTick( + instrument_id=instrument_id, + price=Price.from_str(self.p), + size=Quantity.from_str(self.q), + aggressor_side=AggressorSide.SELLER if self.m else AggressorSide.BUYER, + trade_id=TradeId(str(self.t)), + ts_event=millis_to_nanos(self.T), + ts_init=ts_init, + ) + + +class BinanceFuturesTradeMsg(msgspec.Struct, frozen=True): """WebSocket message from `Binance Futures` Trade Streams.""" stream: str data: BinanceFuturesTradeData -class BinanceFuturesMarkPriceData(msgspec.Struct): +class BinanceFuturesMarkPriceData(msgspec.Struct, frozen=True): """WebSocket message 'inner struct' for `Binance Futures` Mark Price Update events.""" e: str # Event type @@ -196,8 +196,24 @@ class BinanceFuturesMarkPriceData(msgspec.Struct): r: str # Funding rate T: int # Next funding time - -class BinanceFuturesMarkPriceMsg(msgspec.Struct): + def parse_to_binance_futures_mark_price_update( + self, + instrument_id: InstrumentId, + ts_init: int, + ) -> BinanceFuturesMarkPriceUpdate: + return BinanceFuturesMarkPriceUpdate( + instrument_id=instrument_id, + mark=Price.from_str(self.p), + index=Price.from_str(self.i), + estimated_settle=Price.from_str(self.P), + funding_rate=Decimal(self.r), + ts_next_funding=millis_to_nanos(self.T), + ts_event=millis_to_nanos(self.E), + ts_init=ts_init, + ) + + +class BinanceFuturesMarkPriceMsg(msgspec.Struct, frozen=True): """WebSocket message from `Binance Futures` Mark Price Update events.""" stream: str diff --git a/nautilus_trader/adapters/binance/futures/schemas/user.py b/nautilus_trader/adapters/binance/futures/schemas/user.py index 4ecd5f2430d2..fe4868e2f273 100644 --- a/nautilus_trader/adapters/binance/futures/schemas/user.py +++ b/nautilus_trader/adapters/binance/futures/schemas/user.py @@ -13,19 +13,40 @@ # limitations under the License. # ------------------------------------------------------------------------------------------------- +from decimal import Decimal from typing import Optional import msgspec +from nautilus_trader.adapters.binance.common.enums import BinanceEnumParser from nautilus_trader.adapters.binance.common.enums import BinanceExecutionType from nautilus_trader.adapters.binance.common.enums import BinanceOrderSide from nautilus_trader.adapters.binance.common.enums import BinanceOrderStatus +from nautilus_trader.adapters.binance.common.enums import BinanceOrderType +from nautilus_trader.adapters.binance.common.enums import BinanceTimeInForce +from nautilus_trader.adapters.binance.common.execution import BinanceCommonExecutionClient from nautilus_trader.adapters.binance.futures.enums import BinanceFuturesEventType -from nautilus_trader.adapters.binance.futures.enums import BinanceFuturesOrderType from nautilus_trader.adapters.binance.futures.enums import BinanceFuturesPositionSide from nautilus_trader.adapters.binance.futures.enums import BinanceFuturesPositionUpdateReason -from nautilus_trader.adapters.binance.futures.enums import BinanceFuturesTimeInForce from nautilus_trader.adapters.binance.futures.enums import BinanceFuturesWorkingType +from nautilus_trader.core.datetime import millis_to_nanos +from nautilus_trader.core.uuid import UUID4 +from nautilus_trader.execution.reports import OrderStatusReport +from nautilus_trader.model.currency import Currency +from nautilus_trader.model.enums import LiquiditySide +from nautilus_trader.model.enums import OrderSide +from nautilus_trader.model.enums import OrderStatus +from nautilus_trader.model.enums import TrailingOffsetType +from nautilus_trader.model.identifiers import AccountId +from nautilus_trader.model.identifiers import ClientOrderId +from nautilus_trader.model.identifiers import InstrumentId +from nautilus_trader.model.identifiers import PositionId +from nautilus_trader.model.identifiers import TradeId +from nautilus_trader.model.identifiers import VenueOrderId +from nautilus_trader.model.objects import AccountBalance +from nautilus_trader.model.objects import Money +from nautilus_trader.model.objects import Price +from nautilus_trader.model.objects import Quantity ################################################################################ @@ -33,7 +54,7 @@ ################################################################################ -class BinanceFuturesUserMsgData(msgspec.Struct): +class BinanceFuturesUserMsgData(msgspec.Struct, frozen=True): """ Inner struct for execution WebSocket messages from `Binance` """ @@ -41,7 +62,7 @@ class BinanceFuturesUserMsgData(msgspec.Struct): e: BinanceFuturesEventType -class BinanceFuturesUserMsgWrapper(msgspec.Struct): +class BinanceFuturesUserMsgWrapper(msgspec.Struct, frozen=True): """ Provides a wrapper for execution WebSocket messages from `Binance`. """ @@ -50,7 +71,7 @@ class BinanceFuturesUserMsgWrapper(msgspec.Struct): data: BinanceFuturesUserMsgData -class MarginCallPosition(msgspec.Struct): +class MarginCallPosition(msgspec.Struct, frozen=True): """Inner struct position for `Binance Futures` Margin Call events.""" s: str # Symbol @@ -63,7 +84,7 @@ class MarginCallPosition(msgspec.Struct): mm: str # Maintenance Margin Required -class BinanceFuturesMarginCallMsg(msgspec.Struct): +class BinanceFuturesMarginCallMsg(msgspec.Struct, frozen=True): """WebSocket message for `Binance Futures` Margin Call events.""" e: str # Event Type @@ -72,7 +93,7 @@ class BinanceFuturesMarginCallMsg(msgspec.Struct): p: list[MarginCallPosition] -class BinanceFuturesBalance(msgspec.Struct): +class BinanceFuturesBalance(msgspec.Struct, frozen=True): """Inner struct balance for `Binance Futures` Balance and Position update event.""" a: str # Asset @@ -80,8 +101,20 @@ class BinanceFuturesBalance(msgspec.Struct): cw: str # Cross Wallet Balance bc: str # Balance Change except PnL and Commission + def parse_to_account_balance(self) -> AccountBalance: + currency = Currency.from_str(self.a) + free = Decimal(self.wb) + locked = Decimal(0) # TODO(cs): Pending refactoring of accounting + total: Decimal = free + locked -class BinanceFuturesPosition(msgspec.Struct): + return AccountBalance( + total=Money(total, currency), + locked=Money(locked, currency), + free=Money(free, currency), + ) + + +class BinanceFuturesPosition(msgspec.Struct, frozen=True): """Inner struct position for `Binance Futures` Balance and Position update event.""" s: str # Symbol @@ -94,15 +127,18 @@ class BinanceFuturesPosition(msgspec.Struct): ps: BinanceFuturesPositionSide -class BinanceFuturesAccountUpdateData(msgspec.Struct): +class BinanceFuturesAccountUpdateData(msgspec.Struct, frozen=True): """WebSocket message for `Binance Futures` Balance and Position Update events.""" m: BinanceFuturesPositionUpdateReason B: list[BinanceFuturesBalance] P: list[BinanceFuturesPosition] + def parse_to_account_balances(self) -> list[AccountBalance]: + return [balance.parse_to_account_balance() for balance in self.B] + -class BinanceFuturesAccountUpdateMsg(msgspec.Struct): +class BinanceFuturesAccountUpdateMsg(msgspec.Struct, frozen=True): """WebSocket message for `Binance Futures` Balance and Position Update events.""" e: str # Event Type @@ -110,15 +146,24 @@ class BinanceFuturesAccountUpdateMsg(msgspec.Struct): T: int # Transaction Time a: BinanceFuturesAccountUpdateData + def handle_account_update(self, exec_client: BinanceCommonExecutionClient): + """Handle BinanceFuturesAccountUpdateMsg as payload of ACCOUNT_UPDATE.""" + exec_client.generate_account_state( + balances=self.a.parse_to_account_balances(), + margins=[], + reported=True, + ts_event=millis_to_nanos(self.T), + ) -class BinanceFuturesAccountUpdateWrapper(msgspec.Struct): + +class BinanceFuturesAccountUpdateWrapper(msgspec.Struct, frozen=True): """WebSocket message wrapper for `Binance Futures` Balance and Position Update events.""" stream: str data: BinanceFuturesAccountUpdateMsg -class BinanceFuturesOrderData(msgspec.Struct, kw_only=True): +class BinanceFuturesOrderData(msgspec.Struct, kw_only=True, frozen=True): """ WebSocket message 'inner struct' for `Binance Futures` Order Update events. @@ -130,8 +175,8 @@ class BinanceFuturesOrderData(msgspec.Struct, kw_only=True): s: str # Symbol c: str # Client Order ID S: BinanceOrderSide - o: BinanceFuturesOrderType - f: BinanceFuturesTimeInForce + o: BinanceOrderType + f: BinanceTimeInForce q: str # Original Quantity p: str # Original Price ap: str # Average Price @@ -151,7 +196,7 @@ class BinanceFuturesOrderData(msgspec.Struct, kw_only=True): m: bool # Is trade the maker side R: bool # Is reduce only wt: BinanceFuturesWorkingType - ot: BinanceFuturesOrderType + ot: BinanceOrderType ps: BinanceFuturesPositionSide cp: Optional[bool] = None # If Close-All, pushed with conditional order AP: Optional[str] = None # Activation Price, only pushed with TRAILING_STOP_MARKET order @@ -161,8 +206,126 @@ class BinanceFuturesOrderData(msgspec.Struct, kw_only=True): ss: int # ignore rp: str # Realized Profit of the trade - -class BinanceFuturesOrderUpdateMsg(msgspec.Struct): + def parse_to_order_status_report( + self, + account_id: AccountId, + instrument_id: InstrumentId, + client_order_id: ClientOrderId, + venue_order_id: VenueOrderId, + ts_event: int, + ts_init: int, + enum_parser: BinanceEnumParser, + ) -> OrderStatusReport: + price = Price.from_str(self.p) if self.p is not None else None + trigger_price = Price.from_str(self.sp) if self.sp is not None else None + trailing_offset = Decimal(self.cr) * 100 if self.cr is not None else None + order_side = (OrderSide.BUY if self.S == BinanceOrderSide.BUY else OrderSide.SELL,) + post_only = self.f == BinanceTimeInForce.GTX + + return OrderStatusReport( + account_id=account_id, + instrument_id=instrument_id, + client_order_id=client_order_id, + venue_order_id=venue_order_id, + order_side=order_side, + order_type=enum_parser.parse_binance_order_type(self.o), + time_in_force=enum_parser.parse_binance_time_in_force(self.f), + order_status=OrderStatus.ACCEPTED, + price=price, + trigger_price=trigger_price, + trigger_type=enum_parser.parse_binance_trigger_type(self.wt.value), + trailing_offset=trailing_offset, + trailing_offset_type=TrailingOffsetType.BASIS_POINTS, + quantity=Quantity.from_str(self.q), + filled_qty=Quantity.from_str(self.z), + avg_px=None, + post_only=post_only, + reduce_only=self.R, + report_id=UUID4(), + ts_accepted=ts_event, + ts_last=ts_event, + ts_init=ts_init, + ) + + def handle_order_trade_update( + self, + exec_client: BinanceCommonExecutionClient, + ): + """Handle BinanceFuturesOrderData as payload of ORDER_TRADE_UPDATE event.""" + client_order_id = ClientOrderId(self.c) if self.c != "" else None + ts_event = millis_to_nanos(self.T) + venue_order_id = VenueOrderId(str(self.i)) + instrument_id = exec_client._get_cached_instrument_id(self.s) + strategy_id = exec_client._cache.strategy_id_for_order(client_order_id) + if strategy_id is None: + report = self.parse_to_order_status_report( + account_id=exec_client.account_id, + instrument_id=instrument_id, + client_order_id=client_order_id, + venue_order_id=venue_order_id, + ts_event=ts_event, + ts_init=exec_client._clock.timestamp_ns(), + enum_parser=exec_client._enum_parser, + ) + exec_client._send_order_status_report(report) + elif self.x == BinanceExecutionType.NEW: + exec_client.generate_order_accepted( + strategy_id=strategy_id, + instrument_id=instrument_id, + client_order_id=client_order_id, + venue_order_id=venue_order_id, + ts_event=ts_event, + ) + elif self.x == BinanceExecutionType.TRADE: + instrument = exec_client._instrument_provider.find(instrument_id=instrument_id) + + # Determine commission + commission_asset: str = self.N + commission_amount: str = self.n + if commission_asset is not None: + commission = Money.from_str(f"{commission_amount} {commission_asset}") + else: + # Commission in margin collateral currency + commission = Money(0, instrument.quote_currency) + + exec_client.generate_order_filled( + strategy_id=strategy_id, + instrument_id=instrument_id, + client_order_id=client_order_id, + venue_order_id=venue_order_id, + venue_position_id=PositionId(f"{instrument_id}-{self.ps.value}"), + trade_id=TradeId(str(self.t)), # Trade ID + order_side=exec_client._enum_parser.parse_binance_order_side(self.S), + order_type=exec_client._enum_parser.parse_binance_order_type(self.o), + last_qty=Quantity.from_str(self.l), + last_px=Price.from_str(self.L), + quote_currency=instrument.quote_currency, + commission=commission, + liquidity_side=LiquiditySide.MAKER if self.m else LiquiditySide.TAKER, + ts_event=ts_event, + ) + elif self.x == BinanceExecutionType.CANCELED: + exec_client.generate_order_canceled( + strategy_id=strategy_id, + instrument_id=instrument_id, + client_order_id=client_order_id, + venue_order_id=venue_order_id, + ts_event=ts_event, + ) + elif self.x == BinanceExecutionType.EXPIRED: + exec_client.generate_order_expired( + strategy_id=strategy_id, + instrument_id=instrument_id, + client_order_id=client_order_id, + venue_order_id=venue_order_id, + ts_event=ts_event, + ) + else: + # Event not handled + exec_client._log.warning(f"Received unhandled {self}") + + +class BinanceFuturesOrderUpdateMsg(msgspec.Struct, frozen=True): """WebSocket message for `Binance Futures` Order Update events.""" e: str # Event Type @@ -171,7 +334,7 @@ class BinanceFuturesOrderUpdateMsg(msgspec.Struct): o: BinanceFuturesOrderData -class BinanceFuturesOrderUpdateWrapper(msgspec.Struct): +class BinanceFuturesOrderUpdateWrapper(msgspec.Struct, frozen=True): """WebSocket message wrapper for `Binance Futures` Order Update events.""" stream: str diff --git a/nautilus_trader/adapters/binance/spot/rules.py b/nautilus_trader/adapters/binance/futures/schemas/wallet.py similarity index 68% rename from nautilus_trader/adapters/binance/spot/rules.py rename to nautilus_trader/adapters/binance/futures/schemas/wallet.py index dd6c51006254..0ae36c32926a 100644 --- a/nautilus_trader/adapters/binance/spot/rules.py +++ b/nautilus_trader/adapters/binance/futures/schemas/wallet.py @@ -13,20 +13,17 @@ # limitations under the License. # ------------------------------------------------------------------------------------------------- -from nautilus_trader.model.enums import OrderType -from nautilus_trader.model.enums import TimeInForce +import msgspec -BINANCE_SPOT_VALID_TIF = ( - TimeInForce.GTC, - TimeInForce.GTD, # Will be transformed to GTC with warning - TimeInForce.FOK, - TimeInForce.IOC, -) +################################################################################ +# HTTP responses +################################################################################ -BINANCE_SPOT_VALID_ORDER_TYPES = ( - OrderType.MARKET, - OrderType.LIMIT, - OrderType.LIMIT_IF_TOUCHED, - OrderType.STOP_LIMIT, -) + +class BinanceFuturesCommissionRate(msgspec.Struct, frozen=True): + """Schema of a single `Binance Futures` commissionRate""" + + symbol: str + makerCommissionRate: str + takerCommissionRate: str diff --git a/nautilus_trader/adapters/binance/http/account.py b/nautilus_trader/adapters/binance/http/account.py new file mode 100644 index 000000000000..50c8b4619952 --- /dev/null +++ b/nautilus_trader/adapters/binance/http/account.py @@ -0,0 +1,660 @@ +# ------------------------------------------------------------------------------------------------- +# Copyright (C) 2015-2023 Nautech Systems Pty Ltd. All rights reserved. +# https://nautechsystems.io +# +# Licensed under the GNU Lesser General Public License Version 3.0 (the "License"); +# You may not use this file except in compliance with the License. +# You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ------------------------------------------------------------------------------------------------- + +from typing import Optional + +import msgspec + +from nautilus_trader.adapters.binance.common.enums import BinanceAccountType +from nautilus_trader.adapters.binance.common.enums import BinanceMethodType +from nautilus_trader.adapters.binance.common.enums import BinanceNewOrderRespType +from nautilus_trader.adapters.binance.common.enums import BinanceOrderSide +from nautilus_trader.adapters.binance.common.enums import BinanceOrderType +from nautilus_trader.adapters.binance.common.enums import BinanceSecurityType +from nautilus_trader.adapters.binance.common.enums import BinanceTimeInForce +from nautilus_trader.adapters.binance.common.schemas.account import BinanceOrder +from nautilus_trader.adapters.binance.common.schemas.account import BinanceUserTrade +from nautilus_trader.adapters.binance.common.schemas.symbol import BinanceSymbol +from nautilus_trader.adapters.binance.http.client import BinanceHttpClient +from nautilus_trader.adapters.binance.http.endpoint import BinanceHttpEndpoint +from nautilus_trader.common.clock import LiveClock +from nautilus_trader.core.correctness import PyCondition + + +class BinanceOrderHttp(BinanceHttpEndpoint): + """ + Endpoint for managing orders + + `GET /api/v3/order` + `GET /api/v3/order/test` + `GET /fapi/v1/order` + `GET /dapi/v1/order` + + `POST /api/v3/order` + `POST /fapi/v1/order` + `POST /dapi/v1/order` + + `DELETE /api/v3/order` + `DELETE /fapi/v1/order` + `DELETE /dapi/v1/order` + + References + ---------- + https://binance-docs.github.io/apidocs/spot/en/#new-order-trade + https://binance-docs.github.io/apidocs/futures/en/#new-order-trade + https://binance-docs.github.io/apidocs/delivery/en/#new-order-trade + + """ + + def __init__( + self, + client: BinanceHttpClient, + base_endpoint: str, + testing_endpoint: Optional[bool] = False, + ): + methods = { + BinanceMethodType.GET: BinanceSecurityType.USER_DATA, + BinanceMethodType.POST: BinanceSecurityType.TRADE, + BinanceMethodType.DELETE: BinanceSecurityType.TRADE, + } + url_path = base_endpoint + "order" + if testing_endpoint: + url_path = url_path + "/test" + super().__init__( + client, + methods, + url_path, + ) + self._resp_decoder = msgspec.json.Decoder(BinanceOrder) + + class GetDeleteParameters(msgspec.Struct, omit_defaults=True, frozen=True): + """ + Order management GET & DELETE endpoint parameters + + Parameters + ---------- + symbol : BinanceSymbol + The symbol of the order + timestamp : str + The millisecond timestamp of the request + orderId : str, optional + the order identifier + origClientOrderId : str, optional + the client specified order identifier + recvWindow : str, optional + the millisecond timeout window. + + NOTE: Either orderId or origClientOrderId must be sent. + + """ + + symbol: BinanceSymbol + timestamp: str + orderId: Optional[str] = None + origClientOrderId: Optional[str] = None + recvWindow: Optional[str] = None + + class PostParameters(msgspec.Struct, omit_defaults=True, frozen=True): + """ + Order creation POST endpoint parameters + + Parameters + ---------- + symbol : BinanceSymbol + The symbol of the order + timestamp : str + The millisecond timestamp of the request + side : BinanceOrderSide + The market side of the order (BUY, SELL) + type : BinanceOrderType + The type of the order (LIMIT, STOP_LOSS..) + timeInForce : BinanceTimeInForce, optional + Mandatory for LIMIT, STOP_LOSS_LIMIT, TAKE_PROFIT_LIMIT orders. + The time in force of the order (GTC, IOC..) + quantity : str, optional + Mandatory for all order types, except STOP_MARKET/TAKE_PROFIT_MARKET + and TRAILING_STOP_MARKET orders + The order quantity in base asset units for the request + quoteOrderQty : str, optional + Only for SPOT/MARGIN orders. + Can be used alternatively to `quantity` for MARKET orders + The order quantity in quote asset units for the request + price : str, optional + Mandatory for LIMIT, STOP_LOSS_LIMIT, TAKE_PROFIT_LIMIT, LIMIT_MAKER, + STOP, TAKE_PROFIT orders + The order price for the request + newClientOrderId : str, optional + The client order ID for the request. A unique ID among open orders. + Automatically generated if not provided. + strategyId : int, optional + Only for SPOT/MARGIN orders. + The client strategy ID for the request. + strategyType : int, optional + Only for SPOT/MARGIN orders + The client strategy type for thr request. Cannot be less than 1000000 + stopPrice : str, optional + Mandatory for STOP_LOSS, STOP_LOSS_LIMIT, TAKE_PROFIT, TAKE_PROFIT_LIMIT, + STOP, STOP_MARKET, TAKE_PROFIT_MARKET. + The order stop price for the request. + trailingDelta : str, optional + Only for SPOT/MARGIN orders + Can be used instead of or in addition to stopPrice for STOP_LOSS, + STOP_LOSS_LIMIT, TAKE_PROFIT, TAKE_PROFIT_LIMIT orders. + The order trailing delta of the request. + icebergQty : str, optional + Only for SPOT/MARGIN orders + Can be used with LIMIT, STOP_LOSS_LIMIT, and TAKE_PROFIT_LIMIT to + create an iceberg order. + reduceOnly : str ('true', 'false'), optional + Only for FUTURES orders + Cannot be sent in Hedge Mode, cannot be sent with closePosition = 'true' + closePosition : str ('true', 'false'), optional + Only for FUTURES orders + Can be used with STOP_MARKET or TAKE_PROFIT_MARKET orders + Whether to close all open positions for the given symbol. + activationPrice : str, optional + Only for FUTURES orders + Can be used with TRAILING_STOP_MARKET orders. + Defaults to the latest price. + callbackRate : str, optional + Only for FUTURES orders + Mandatory for TRAILING_STOP_MARKET orders. + The order trailing delta of the request. + workingType : str ("MARK_PRICE", "CONTRACT_PRICE"), optional + Only for FUTURES orders + The trigger type for the order. + Defaults to "CONTRACT_PRICE" + priceProtect : str ('true', 'false'), optional + Only for FUTURES orders + Whether price protection is active. + Defaults to 'false' + newOrderRespType : NewOrderRespType, optional + The response type for the order request. + SPOT/MARGIN MARKET, LIMIT orders default to FULL. + All others default to ACK. + FULL response only for SPOT/MARGIN orders. + recvWindow : str, optional + The response receive window in milliseconds for the request. + Cannot exceed 60000. + + """ + + symbol: BinanceSymbol + timestamp: str + side: BinanceOrderSide + type: BinanceOrderType + timeInForce: Optional[BinanceTimeInForce] = None + quantity: Optional[str] = None + quoteOrderQty: Optional[str] = None + price: Optional[str] = None + newClientOrderId: Optional[str] = None + strategyId: Optional[int] = None + strategyType: Optional[int] = None + stopPrice: Optional[str] = None + trailingDelta: Optional[str] = None + icebergQty: Optional[str] = None + reduceOnly: Optional[str] = None + closePosition: Optional[str] = None + activationPrice: Optional[str] = None + callbackRate: Optional[str] = None + workingType: Optional[str] = None + priceProtect: Optional[str] = None + newOrderRespType: Optional[BinanceNewOrderRespType] = None + recvWindow: Optional[str] = None + + async def _get(self, parameters: GetDeleteParameters) -> BinanceOrder: + method_type = BinanceMethodType.GET + raw = await self._method(method_type, parameters) + return self._resp_decoder.decode(raw) + + async def _delete(self, parameters: GetDeleteParameters) -> BinanceOrder: + method_type = BinanceMethodType.DELETE + raw = await self._method(method_type, parameters) + return self._resp_decoder.decode(raw) + + async def _post(self, parameters: PostParameters) -> BinanceOrder: + method_type = BinanceMethodType.POST + raw = await self._method(method_type, parameters) + return self._resp_decoder.decode(raw) + + +class BinanceAllOrdersHttp(BinanceHttpEndpoint): + """ + Endpoint of all account orders, active, cancelled or filled. + + `GET /api/v3/allOrders` + `GET /fapi/v1/allOrders` + `GET /dapi/v1/allOrders` + + References + ---------- + https://binance-docs.github.io/apidocs/spot/en/#all-orders-user_data + https://binance-docs.github.io/apidocs/futures/en/#all-orders-user_data + https://binance-docs.github.io/apidocs/delivery/en/#all-orders-user_data + + """ + + def __init__( + self, + client: BinanceHttpClient, + base_endpoint: str, + ): + methods = { + BinanceMethodType.GET: BinanceSecurityType.USER_DATA, + } + url_path = base_endpoint + "allOrders" + super().__init__( + client, + methods, + url_path, + ) + self._get_resp_decoder = msgspec.json.Decoder(list[BinanceOrder]) + + class GetParameters(msgspec.Struct, omit_defaults=True, frozen=True): + """ + Parameters of allOrders GET request + + Parameters + ---------- + symbol : BinanceSymbol + The symbol of the orders + timestamp : str + The millisecond timestamp of the request + orderId : str, optional + The order ID for the request. + If included, request will return orders from this orderId INCLUSIVE + startTime : str, optional + The start time (UNIX milliseconds) filter for the request. + endTime : str, optional + The end time (UNIX milliseconds) filter for the request. + limit : int, optional + The limit for the response. + Default 500, max 1000 + recvWindow : str, optional + The response receive window for the request (cannot be greater than 60000). + + """ + + symbol: BinanceSymbol + timestamp: str + orderId: Optional[str] = None + startTime: Optional[str] = None + endTime: Optional[str] = None + limit: Optional[int] = None + recvWindow: Optional[str] = None + + async def _get(self, parameters: GetParameters) -> list[BinanceOrder]: + method_type = BinanceMethodType.GET + raw = await self._method(method_type, parameters) + return self._get_resp_decoder.decode(raw) + + +class BinanceOpenOrdersHttp(BinanceHttpEndpoint): + """ + Endpoint of all open orders on a symbol. + + `GET /api/v3/openOrders` + `GET /fapi/v1/openOrders` + `GET /dapi/v1/openOrders` + + Warnings + -------- + Care should be taken when accessing this endpoint with no symbol specified. + The weight usage can be very large, which may cause rate limits to be hit. + + References + ---------- + https://binance-docs.github.io/apidocs/spot/en/#current-open-orders-user_data + https://binance-docs.github.io/apidocs/futures/en/#current-all-open-orders-user_data + https://binance-docs.github.io/apidocs/futures/en/#current-all-open-orders-user_data + + """ + + def __init__( + self, + client: BinanceHttpClient, + base_endpoint: str, + methods: Optional[dict[BinanceMethodType, BinanceSecurityType]] = None, + ): + if methods is None: + methods = { + BinanceMethodType.GET: BinanceSecurityType.USER_DATA, + } + url_path = base_endpoint + "openOrders" + super().__init__( + client, + methods, + url_path, + ) + self._get_resp_decoder = msgspec.json.Decoder(list[BinanceOrder]) + + class GetParameters(msgspec.Struct, omit_defaults=True, frozen=True): + """ + Parameters of openOrders GET request + + Parameters + ---------- + timestamp : str + The millisecond timestamp of the request + symbol : BinanceSymbol, optional + The symbol of the orders + recvWindow : str, optional + The response receive window for the request (cannot be greater than 60000). + + """ + + timestamp: str + symbol: Optional[BinanceSymbol] = None + recvWindow: Optional[str] = None + + async def _get(self, parameters: GetParameters) -> list[BinanceOrder]: + method_type = BinanceMethodType.GET + raw = await self._method(method_type, parameters) + return self._get_resp_decoder.decode(raw) + + +class BinanceUserTradesHttp(BinanceHttpEndpoint): + """ + Endpoint of trades for a specific account and symbol + + `GET /api/v3/myTrades` + `GET /fapi/v1/userTrades` + `GET /dapi/v1/userTrades` + + References + ---------- + https://binance-docs.github.io/apidocs/spot/en/#account-trade-list-user_data + https://binance-docs.github.io/apidocs/futures/en/#account-trade-list-user_data + https://binance-docs.github.io/apidocs/delivery/en/#account-trade-list-user_data + + """ + + def __init__( + self, + client: BinanceHttpClient, + url_path: str, + ): + methods = { + BinanceMethodType.GET: BinanceSecurityType.USER_DATA, + } + super().__init__( + client, + methods, + url_path, + ) + self._get_resp_decoder = msgspec.json.Decoder(list[BinanceUserTrade]) + + class GetParameters(msgspec.Struct, omit_defaults=True, frozen=True): + """ + Parameters of allOrders GET request + + Parameters + ---------- + symbol : BinanceSymbol + The symbol of the orders + timestamp : str + The millisecond timestamp of the request + orderId : str, optional + The order ID for the request. + If included, request will return orders from this orderId INCLUSIVE + startTime : str, optional + The start time (UNIX milliseconds) filter for the request. + endTime : str, optional + The end time (UNIX milliseconds) filter for the request. + fromId : str, optional + TradeId to fetch from. Default gets most recent trades. + limit : int, optional + The limit for the response. + Default 500, max 1000 + recvWindow : str, optional + The response receive window for the request (cannot be greater than 60000). + + """ + + symbol: BinanceSymbol + timestamp: str + orderId: Optional[str] = None + startTime: Optional[str] = None + endTime: Optional[str] = None + fromId: Optional[str] = None + limit: Optional[int] = None + recvWindow: Optional[str] = None + + async def _get(self, parameters: GetParameters) -> list[BinanceUserTrade]: + method_type = BinanceMethodType.GET + raw = await self._method(method_type, parameters) + return self._get_resp_decoder.decode(raw) + + +class BinanceAccountHttpAPI: + """ + Provides access to the Binance Account/Trade HTTP REST API. + + Parameters + ---------- + client : BinanceHttpClient + The Binance REST API client. + account_type : BinanceAccountType + The Binance account type, used to select the endpoint prefix + + Warnings + -------- + This class should not be used directly, but through a concrete subclass. + """ + + def __init__( + self, + client: BinanceHttpClient, + clock: LiveClock, + account_type: BinanceAccountType, + ): + PyCondition.not_none(client, "client") + self.client = client + self._clock = clock + + if account_type.is_spot_or_margin: + self.base_endpoint = "/api/v3/" + user_trades_url = self.base_endpoint + "myTrades" + elif account_type == BinanceAccountType.FUTURES_USDT: + self.base_endpoint = "/fapi/v1/" + user_trades_url = self.base_endpoint + "userTrades" + elif account_type == BinanceAccountType.FUTURES_COIN: + self.base_endpoint = "/dapi/v1/" + user_trades_url = self.base_endpoint + "userTrades" + else: + raise RuntimeError( # pragma: no cover (design-time error) + f"invalid `BinanceAccountType`, was {account_type}", # pragma: no cover + ) + + # Create endpoints + self._endpoint_order = BinanceOrderHttp(client, self.base_endpoint) + self._endpoint_all_orders = BinanceAllOrdersHttp(client, self.base_endpoint) + self._endpoint_open_orders = BinanceOpenOrdersHttp(client, self.base_endpoint) + self._endpoint_user_trades = BinanceUserTradesHttp(client, user_trades_url) + + def _timestamp(self) -> str: + """Create Binance timestamp from internal clock""" + return str(self._clock.timestamp_ms()) + + async def query_order( + self, + symbol: str, + order_id: Optional[str] = None, + orig_client_order_id: Optional[str] = None, + recv_window: Optional[str] = None, + ) -> BinanceOrder: + """Check an order status""" + if order_id is None and orig_client_order_id is None: + raise RuntimeError( + "Either orderId or origClientOrderId must be sent.", + ) + binance_order = await self._endpoint_order._get( + parameters=self._endpoint_order.GetDeleteParameters( + symbol=BinanceSymbol(symbol), + timestamp=self._timestamp(), + orderId=order_id, + origClientOrderId=orig_client_order_id, + recvWindow=recv_window, + ), + ) + return binance_order + + async def cancel_all_open_orders( + self, + symbol: str, + recv_window: Optional[str] = None, + ) -> bool: + # Implement in child class + raise NotImplementedError + + async def cancel_order( + self, + symbol: str, + order_id: Optional[str] = None, + orig_client_order_id: Optional[str] = None, + recv_window: Optional[str] = None, + ) -> BinanceOrder: + """Cancel an active order""" + if order_id is None and orig_client_order_id is None: + raise RuntimeError( + "Either orderId or origClientOrderId must be sent.", + ) + binance_order = await self._endpoint_order._delete( + parameters=self._endpoint_order.GetDeleteParameters( + symbol=BinanceSymbol(symbol), + timestamp=self._timestamp(), + orderId=order_id, + origClientOrderId=orig_client_order_id, + recvWindow=recv_window, + ), + ) + return binance_order + + async def new_order( + self, + symbol: str, + side: BinanceOrderSide, + order_type: BinanceOrderType, + time_in_force: Optional[BinanceTimeInForce] = None, + quantity: Optional[str] = None, + quote_order_qty: Optional[str] = None, + price: Optional[str] = None, + new_client_order_id: Optional[str] = None, + strategy_id: Optional[int] = None, + strategy_type: Optional[int] = None, + stop_price: Optional[str] = None, + trailing_delta: Optional[str] = None, + iceberg_qty: Optional[str] = None, + reduce_only: Optional[str] = None, + close_position: Optional[str] = None, + activation_price: Optional[str] = None, + callback_rate: Optional[str] = None, + working_type: Optional[str] = None, + price_protect: Optional[str] = None, + new_order_resp_type: Optional[BinanceNewOrderRespType] = None, + recv_window: Optional[str] = None, + ) -> BinanceOrder: + """Send in a new order to Binance""" + binance_order = await self._endpoint_order._post( + parameters=self._endpoint_order.PostParameters( + symbol=BinanceSymbol(symbol), + timestamp=self._timestamp(), + side=side, + type=order_type, + timeInForce=time_in_force, + quantity=quantity, + quoteOrderQty=quote_order_qty, + price=price, + newClientOrderId=new_client_order_id, + strategyId=strategy_id, + strategyType=strategy_type, + stopPrice=stop_price, + trailingDelta=trailing_delta, + icebergQty=iceberg_qty, + reduceOnly=reduce_only, + closePosition=close_position, + activationPrice=activation_price, + callbackRate=callback_rate, + workingType=working_type, + priceProtect=price_protect, + newOrderRespType=new_order_resp_type, + recvWindow=recv_window, + ), + ) + return binance_order + + async def query_all_orders( + self, + symbol: str, + order_id: Optional[str] = None, + start_time: Optional[str] = None, + end_time: Optional[str] = None, + limit: Optional[int] = None, + recv_window: Optional[str] = None, + ) -> list[BinanceOrder]: + """Query all orders, active or filled.""" + return await self._endpoint_all_orders._get( + parameters=self._endpoint_all_orders.GetParameters( + symbol=BinanceSymbol(symbol), + timestamp=self._timestamp(), + orderId=order_id, + startTime=start_time, + endTime=end_time, + limit=limit, + recvWindow=recv_window, + ), + ) + + async def query_open_orders( + self, + symbol: Optional[str] = None, + recv_window: Optional[str] = None, + ) -> list[BinanceOrder]: + """Query open orders.""" + return await self._endpoint_open_orders._get( + parameters=self._endpoint_open_orders.GetParameters( + symbol=BinanceSymbol(symbol), + timestamp=self._timestamp(), + recvWindow=recv_window, + ), + ) + + async def query_user_trades( + self, + symbol: str, + order_id: Optional[str] = None, + start_time: Optional[str] = None, + end_time: Optional[str] = None, + from_id: Optional[str] = None, + limit: Optional[int] = None, + recv_window: Optional[str] = None, + ) -> list[BinanceUserTrade]: + """Query user's trade history for a symbol, with provided filters.""" + if (order_id or from_id) is not None and (start_time or end_time) is not None: + raise RuntimeError( + "Cannot specify both order_id/from_id AND start_time/end_time parameters.", + ) + return await self._endpoint_user_trades._get( + parameters=self._endpoint_user_trades.GetParameters( + symbol=BinanceSymbol(symbol), + timestamp=self._timestamp(), + orderId=order_id, + startTime=start_time, + endTime=end_time, + fromId=from_id, + limit=limit, + recvWindow=recv_window, + ), + ) diff --git a/nautilus_trader/adapters/binance/http/client.py b/nautilus_trader/adapters/binance/http/client.py index 94eaf684968a..79fe2d914190 100644 --- a/nautilus_trader/adapters/binance/http/client.py +++ b/nautilus_trader/adapters/binance/http/client.py @@ -106,7 +106,6 @@ async def sign_request( ) -> Any: if payload is None: payload = {} - payload["timestamp"] = str(self._clock.timestamp_ms()) query_string = self._prepare_params(payload) signature = self._get_sign(query_string) payload["signature"] = signature @@ -130,7 +129,6 @@ async def limited_encoded_sign_request( """ if payload is None: payload = {} - payload["timestamp"] = str(self._clock.timestamp_ms()) query_string = self._prepare_params(payload) signature = self._get_sign(query_string) url_path = url_path + "?" + query_string + "&signature=" + signature diff --git a/nautilus_trader/adapters/binance/http/endpoint.py b/nautilus_trader/adapters/binance/http/endpoint.py new file mode 100644 index 000000000000..aca31fee7472 --- /dev/null +++ b/nautilus_trader/adapters/binance/http/endpoint.py @@ -0,0 +1,78 @@ +# ------------------------------------------------------------------------------------------------- +# Copyright (C) 2015-2023 Nautech Systems Pty Ltd. All rights reserved. +# https://nautechsystems.io +# +# Licensed under the GNU Lesser General Public License Version 3.0 (the "License"); +# You may not use this file except in compliance with the License. +# You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ------------------------------------------------------------------------------------------------- + +from typing import Any + +import msgspec + +from nautilus_trader.adapters.binance.common.enums import BinanceMethodType +from nautilus_trader.adapters.binance.common.enums import BinanceSecurityType +from nautilus_trader.adapters.binance.common.schemas.symbol import BinanceSymbol +from nautilus_trader.adapters.binance.common.schemas.symbol import BinanceSymbols +from nautilus_trader.adapters.binance.http.client import BinanceHttpClient + + +def enc_hook(obj: Any) -> Any: + if isinstance(obj, BinanceSymbol): + return str(obj) # serialize BinanceSymbol as string. + elif isinstance(obj, BinanceSymbols): + return str(obj) # serialize BinanceSymbol as string. + else: + raise TypeError(f"Objects of type {type(obj)} are not supported") + + +class BinanceHttpEndpoint: + """ + Base functionality of endpoints connecting to the Binance REST API. + + Warnings: + -------- + This class should not be used directly, but through a concrete subclass. + """ + + def __init__( + self, + client: BinanceHttpClient, + methods_desc: dict[BinanceMethodType, BinanceSecurityType], + url_path: str, + ): + self.client = client + self.methods_desc = methods_desc + self.url_path = url_path + + self.decoder = msgspec.json.Decoder() + self.encoder = msgspec.json.Encoder(enc_hook=enc_hook) + + self._method_request = { + BinanceSecurityType.NONE: self.client.send_request, + BinanceSecurityType.USER_STREAM: self.client.send_request, + BinanceSecurityType.MARKET_DATA: self.client.send_request, + BinanceSecurityType.TRADE: self.client.sign_request, + BinanceSecurityType.MARGIN: self.client.sign_request, + BinanceSecurityType.USER_DATA: self.client.sign_request, + } + + async def _method(self, method_type: BinanceMethodType, parameters: Any) -> bytes: + payload: dict = self.decoder.decode(self.encoder.encode(parameters)) + if self.methods_desc[method_type] is None: + raise RuntimeError( + f"{method_type.name} not available for {self.url_path}", + ) + raw: bytes = await self._method_request[self.methods_desc[method_type]]( + http_method=method_type.name, + url_path=self.url_path, + payload=payload, + ) + return raw diff --git a/nautilus_trader/adapters/binance/http/market.py b/nautilus_trader/adapters/binance/http/market.py new file mode 100644 index 000000000000..ccf7303158a9 --- /dev/null +++ b/nautilus_trader/adapters/binance/http/market.py @@ -0,0 +1,855 @@ +# ------------------------------------------------------------------------------------------------- +# Copyright (C) 2015-2023 Nautech Systems Pty Ltd. All rights reserved. +# https://nautechsystems.io +# +# Licensed under the GNU Lesser General Public License Version 3.0 (the "License"); +# You may not use this file except in compliance with the License. +# You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ------------------------------------------------------------------------------------------------- + +from typing import Optional + +import msgspec + +from nautilus_trader.adapters.binance.common.enums import BinanceAccountType +from nautilus_trader.adapters.binance.common.enums import BinanceKlineInterval +from nautilus_trader.adapters.binance.common.enums import BinanceMethodType +from nautilus_trader.adapters.binance.common.enums import BinanceSecurityType +from nautilus_trader.adapters.binance.common.schemas.market import BinanceAggTrade +from nautilus_trader.adapters.binance.common.schemas.market import BinanceDepth +from nautilus_trader.adapters.binance.common.schemas.market import BinanceKline +from nautilus_trader.adapters.binance.common.schemas.market import BinanceTicker24hr +from nautilus_trader.adapters.binance.common.schemas.market import BinanceTickerBook +from nautilus_trader.adapters.binance.common.schemas.market import BinanceTickerPrice +from nautilus_trader.adapters.binance.common.schemas.market import BinanceTime +from nautilus_trader.adapters.binance.common.schemas.market import BinanceTrade +from nautilus_trader.adapters.binance.common.schemas.symbol import BinanceSymbol +from nautilus_trader.adapters.binance.common.schemas.symbol import BinanceSymbols +from nautilus_trader.adapters.binance.common.types import BinanceBar +from nautilus_trader.adapters.binance.http.client import BinanceHttpClient +from nautilus_trader.adapters.binance.http.endpoint import BinanceHttpEndpoint +from nautilus_trader.core.correctness import PyCondition +from nautilus_trader.model.data.bar import BarType +from nautilus_trader.model.data.tick import TradeTick +from nautilus_trader.model.identifiers import InstrumentId +from nautilus_trader.model.orderbook.data import OrderBookSnapshot + + +class BinancePingHttp(BinanceHttpEndpoint): + """ + Endpoint for testing connectivity to the REST API. + + `GET /api/v3/ping` + `GET /fapi/v1/ping` + `GET /dapi/v1/ping` + + References + ---------- + https://binance-docs.github.io/apidocs/spot/en/#test-connectivity + https://binance-docs.github.io/apidocs/futures/en/#test-connectivity + https://binance-docs.github.io/apidocs/delivery/en/#test-connectivity + + """ + + def __init__( + self, + client: BinanceHttpClient, + base_endpoint: str, + ): + methods = { + BinanceMethodType.GET: BinanceSecurityType.NONE, + } + url_path = base_endpoint + "ping" + super().__init__( + client, + methods, + url_path, + ) + self._get_resp_decoder = msgspec.json.Decoder() + + async def _get(self) -> dict: + method_type = BinanceMethodType.GET + raw = await self._method(method_type, None) + return self._get_resp_decoder.decode(raw) + + +class BinanceTimeHttp(BinanceHttpEndpoint): + """ + Endpoint for testing connectivity to the REST API and receiving current server time. + + `GET /api/v3/time` + `GET /fapi/v1/time` + `GET /dapi/v1/time` + + References + ---------- + https://binance-docs.github.io/apidocs/spot/en/#check-server-time + https://binance-docs.github.io/apidocs/futures/en/#check-server-time + https://binance-docs.github.io/apidocs/delivery/en/#check-server-time + + """ + + def __init__( + self, + client: BinanceHttpClient, + base_endpoint: str, + ): + methods = { + BinanceMethodType.GET: BinanceSecurityType.NONE, + } + url_path = base_endpoint + "time" + super().__init__(client, methods, url_path) + self._get_resp_decoder = msgspec.json.Decoder(BinanceTime) + + async def _get(self) -> BinanceTime: + method_type = BinanceMethodType.GET + raw = await self._method(method_type, None) + return self._get_resp_decoder.decode(raw) + + +class BinanceDepthHttp(BinanceHttpEndpoint): + """ + Endpoint of orderbook depth + + `GET /api/v3/depth` + `GET /fapi/v1/depth` + `GET /dapi/v1/depth` + + References + ---------- + https://binance-docs.github.io/apidocs/spot/en/#order-book + https://binance-docs.github.io/apidocs/futures/en/#order-book + https://binance-docs.github.io/apidocs/delivery/en/#order-book + + """ + + def __init__( + self, + client: BinanceHttpClient, + base_endpoint: str, + ): + methods = { + BinanceMethodType.GET: BinanceSecurityType.NONE, + } + url_path = base_endpoint + "depth" + super().__init__( + client, + methods, + url_path, + ) + self._get_resp_decoder = msgspec.json.Decoder(BinanceDepth) + + class GetParameters(msgspec.Struct, omit_defaults=True, frozen=True): + """ + Orderbook depth GET endpoint parameters + + Parameters + ---------- + symbol : BinanceSymbol + The trading pair. + limit : int, optional, default 100 + The limit for the response. + SPOT/MARGIN (GET /api/v3/depth) + Default 100; max 5000. + FUTURES (GET /*api/v1/depth) + Default 500; max 1000. + Valid limits:[5, 10, 20, 50, 100, 500, 1000]. + """ + + symbol: BinanceSymbol + limit: Optional[int] = None + + async def _get(self, parameters: GetParameters) -> BinanceDepth: + method_type = BinanceMethodType.GET + raw = await self._method(method_type, parameters) + return self._get_resp_decoder.decode(raw) + + +class BinanceTradesHttp(BinanceHttpEndpoint): + """ + Endpoint of recent market trades. + + `GET /api/v3/trades` + `GET /fapi/v1/trades` + `GET /dapi/v1/trades` + + Parameters + ---------- + symbol : str + The trading pair. + limit : int, optional + The limit for the response. Default 500; max 1000. + + References + ---------- + https://binance-docs.github.io/apidocs/spot/en/#recent-trades-list + https://binance-docs.github.io/apidocs/futures/en/#recent-trades-list + https://binance-docs.github.io/apidocs/delivery/en/#recent-trades-list + + """ + + def __init__( + self, + client: BinanceHttpClient, + base_endpoint: str, + ): + methods = { + BinanceMethodType.GET: BinanceSecurityType.NONE, + } + url_path = base_endpoint + "trades" + super().__init__( + client, + methods, + url_path, + ) + self._get_resp_decoder = msgspec.json.Decoder(list[BinanceTrade]) + + class GetParameters(msgspec.Struct, omit_defaults=True, frozen=True): + """ + GET parameters for recent trades + + Parameters + ---------- + symbol : BinanceSymbol + The trading pair. + limit : int, optional + The limit for the response. Default 500; max 1000. + """ + + symbol: BinanceSymbol + limit: Optional[int] = None + + async def _get(self, parameters: GetParameters) -> list[BinanceTrade]: + method_type = BinanceMethodType.GET + raw = await self._method(method_type, parameters) + return self._get_resp_decoder.decode(raw) + + +class BinanceHistoricalTradesHttp(BinanceHttpEndpoint): + """ + Endpoint of older market historical trades + + `GET /api/v3/historicalTrades` + `GET /fapi/v1/historicalTrades` + `GET /dapi/v1/historicalTrades` + + References + ---------- + https://binance-docs.github.io/apidocs/spot/en/#old-trade-lookup-market_data + https://binance-docs.github.io/apidocs/futures/en/#old-trades-lookup-market_data + https://binance-docs.github.io/apidocs/delivery/en/#old-trades-lookup-market_data + + """ + + def __init__( + self, + client: BinanceHttpClient, + base_endpoint: str, + ): + methods = { + BinanceMethodType.GET: BinanceSecurityType.MARKET_DATA, + } + url_path = base_endpoint + "historicalTrades" + super().__init__( + client, + methods, + url_path, + ) + self._get_resp_decoder = msgspec.json.Decoder(list[BinanceTrade]) + + class GetParameters(msgspec.Struct, omit_defaults=True, frozen=True): + """ + GET parameters for historical trades + + Parameters + ---------- + symbol : BinanceSymbol + The trading pair. + limit : int, optional + The limit for the response. Default 500; max 1000. + fromId : str, optional + Trade id to fetch from. Default gets most recent trades + """ + + symbol: BinanceSymbol + limit: Optional[int] = None + fromId: Optional[str] = None + + async def _get(self, parameters: GetParameters) -> list[BinanceTrade]: + method_type = BinanceMethodType.GET + raw = await self._method(method_type, parameters) + return self._get_resp_decoder.decode(raw) + + +class BinanceAggTradesHttp(BinanceHttpEndpoint): + """ + Endpoint of compressed and aggregated market trades. + Market trades that fill in 100ms with the same price and same taking side + will have the quantity aggregated. + + `GET /api/v3/aggTrades` + `GET /fapi/v1/aggTrades` + `GET /dapi/v1/aggTrades` + + References + ---------- + https://binance-docs.github.io/apidocs/spot/en/#compressed-aggregate-trades-list + https://binance-docs.github.io/apidocs/futures/en/#compressed-aggregate-trades-list + https://binance-docs.github.io/apidocs/delivery/en/#compressed-aggregate-trades-list + """ + + def __init__( + self, + client: BinanceHttpClient, + base_endpoint: str, + ): + methods = { + BinanceMethodType.GET: BinanceSecurityType.NONE, + } + url_path = base_endpoint + "aggTrades" + super().__init__( + client, + methods, + url_path, + ) + self._get_resp_decoder = msgspec.json.Decoder(list[BinanceAggTrade]) + + class GetParameters(msgspec.Struct, omit_defaults=True, frozen=True): + """ + GET parameters for aggregate trades + + Parameters + ---------- + symbol : BinanceSymbol + The trading pair. + limit : int, optional + The limit for the response. Default 500; max 1000. + fromId : str, optional + Trade id to fetch from INCLUSIVE + startTime : str, optional + Timestamp in ms to get aggregate trades from INCLUSIVE + endTime : str, optional + Timestamp in ms to get aggregate trades until INCLUSIVE + """ + + symbol: BinanceSymbol + limit: Optional[int] = None + fromId: Optional[str] = None + startTime: Optional[str] = None + endTime: Optional[str] = None + + async def _get(self, parameters: GetParameters) -> list[BinanceAggTrade]: + method_type = BinanceMethodType.GET + raw = await self._method(method_type, parameters) + return self._get_resp_decoder.decode(raw) + + +class BinanceKlinesHttp(BinanceHttpEndpoint): + """ + Endpoint of Kline/candlestick bars for a symbol. + Klines are uniquely identified by their open time. + + `GET /api/v3/klines` + `GET /fapi/v1/klines` + `GET /dapi/v1/klines` + + References + ---------- + https://binance-docs.github.io/apidocs/spot/en/#kline-candlestick-data + https://binance-docs.github.io/apidocs/futures/en/#kline-candlestick-data + https://binance-docs.github.io/apidocs/delivery/en/#kline-candlestick-data + + """ + + def __init__( + self, + client: BinanceHttpClient, + base_endpoint: str, + ): + methods = { + BinanceMethodType.GET: BinanceSecurityType.NONE, + } + url_path = base_endpoint + "klines" + super().__init__( + client, + methods, + url_path, + ) + self._get_resp_decoder = msgspec.json.Decoder(list[BinanceKline]) + + class GetParameters(msgspec.Struct, omit_defaults=True, frozen=True): + """ + GET parameters for klines + + Parameters + ---------- + symbol : BinanceSymbol + The trading pair. + interval : str + The interval of kline, e.g 1m, 5m, 1h, 1d, etc. + limit : int, optional + The limit for the response. Default 500; max 1000. + startTime : str, optional + Timestamp in ms to get klines from INCLUSIVE + endTime : str, optional + Timestamp in ms to get klines until INCLUSIVE + """ + + symbol: BinanceSymbol + interval: BinanceKlineInterval + limit: Optional[int] = None + startTime: Optional[str] = None + endTime: Optional[str] = None + + async def _get(self, parameters: GetParameters) -> list[BinanceKline]: + method_type = BinanceMethodType.GET + raw = await self._method(method_type, parameters) + return self._get_resp_decoder.decode(raw) + + +class BinanceTicker24hrHttp(BinanceHttpEndpoint): + """ + Endpoint of 24 hour rolling window price change statistics + + `GET /api/v3/ticker/24hr` + `GET /fapi/v1/ticker/24hr` + `GET /dapi/v1/ticker/24hr` + + Warnings + -------- + Care should be taken when accessing this endpoint with no symbol specified. + The weight usage can be very large, which may cause rate limits to be hit. + + References + ---------- + https://binance-docs.github.io/apidocs/spot/en/#24hr-ticker-price-change-statistics + https://binance-docs.github.io/apidocs/futures/en/#24hr-ticker-price-change-statistics + https://binance-docs.github.io/apidocs/delivery/en/#24hr-ticker-price-change-statistics + """ + + def __init__( + self, + client: BinanceHttpClient, + base_endpoint: str, + ): + methods = { + BinanceMethodType.GET: BinanceSecurityType.NONE, + } + url_path = base_endpoint + "ticker/24hr" + super().__init__( + client, + methods, + url_path, + ) + self._get_obj_resp_decoder = msgspec.json.Decoder(BinanceTicker24hr) + self._get_arr_resp_decoder = msgspec.json.Decoder(list[BinanceTicker24hr]) + + class GetParameters(msgspec.Struct, omit_defaults=True, frozen=True): + """ + GET parameters for 24hr ticker + + Parameters + ---------- + symbol : BinanceSymbol + The trading pair. When given, endpoint will return a single BinanceTicker24hr + When omitted, endpoint will return a list of BinanceTicker24hr for all trading pairs. + symbols : BinanceSymbols + SPOT/MARGIN only! + List of trading pairs. When given, endpoint will return a list of BinanceTicker24hr + type : str + SPOT/MARGIN only! + Select between FULL and MINI 24hr ticker responses to save bandwidth. + """ + + symbol: Optional[BinanceSymbol] = None + symbols: Optional[BinanceSymbols] = None # SPOT/MARGIN only + type: Optional[str] = None # SPOT/MARIN only + + async def _get(self, parameters: GetParameters) -> list[BinanceTicker24hr]: + method_type = BinanceMethodType.GET + raw = await self._method(method_type, parameters) + if parameters.symbol is not None: + return [self._get_obj_resp_decoder.decode(raw)] + else: + return self._get_arr_resp_decoder.decode(raw) + + +class BinanceTickerPriceHttp(BinanceHttpEndpoint): + """ + Endpoint of latest price for a symbol or symbols + + `GET /api/v3/ticker/price` + `GET /fapi/v1/ticker/price` + `GET /dapi/v1/ticker/price` + + References + ---------- + https://binance-docs.github.io/apidocs/spot/en/#symbol-price-ticker + https://binance-docs.github.io/apidocs/futures/en/#symbol-price-ticker + https://binance-docs.github.io/apidocs/delivery/en/#symbol-price-ticker + """ + + def __init__( + self, + client: BinanceHttpClient, + base_endpoint: str, + ): + methods = { + BinanceMethodType.GET: BinanceSecurityType.NONE, + } + url_path = base_endpoint + "ticker/price" + super().__init__( + client, + methods, + url_path, + ) + self._get_obj_resp_decoder = msgspec.json.Decoder(BinanceTickerPrice) + self._get_arr_resp_decoder = msgspec.json.Decoder(list[BinanceTickerPrice]) + + class GetParameters(msgspec.Struct, omit_defaults=True, frozen=True): + """ + GET parameters for price ticker + + Parameters + ---------- + symbol : BinanceSymbol + The trading pair. When given, endpoint will return a single BinanceTickerPrice + When omitted, endpoint will return a list of BinanceTickerPrice for all trading pairs. + symbols : str + SPOT/MARGIN only! + List of trading pairs. When given, endpoint will return a list of BinanceTickerPrice + """ + + symbol: Optional[BinanceSymbol] = None + symbols: Optional[BinanceSymbols] = None # SPOT/MARGIN only + + async def _get(self, parameters: GetParameters) -> list[BinanceTickerPrice]: + method_type = BinanceMethodType.GET + raw = await self._method(method_type, parameters) + if parameters.symbol is not None: + return [self._get_obj_resp_decoder.decode(raw)] + else: + return self._get_arr_resp_decoder.decode(raw) + + +class BinanceTickerBookHttp(BinanceHttpEndpoint): + """ + Endpoint of best price/qty on the order book for a symbol or symbols + + `GET /api/v3/ticker/bookTicker` + `GET /fapi/v1/ticker/bookTicker` + `GET /dapi/v1/ticker/bookTicker` + + References + ---------- + https://binance-docs.github.io/apidocs/spot/en/#symbol-order-book-ticker + https://binance-docs.github.io/apidocs/futures/en/#symbol-order-book-ticker + https://binance-docs.github.io/apidocs/delivery/en/#symbol-order-book-ticker + """ + + def __init__( + self, + client: BinanceHttpClient, + base_endpoint: str, + ): + methods = { + BinanceMethodType.GET: BinanceSecurityType.NONE, + } + url_path = base_endpoint + "ticker/price" + super().__init__( + client, + methods, + url_path, + ) + self._get_arr_resp_decoder = msgspec.json.Decoder(list[BinanceTickerBook]) + self._get_obj_resp_decoder = msgspec.json.Decoder(BinanceTickerBook) + + class GetParameters(msgspec.Struct, omit_defaults=True, frozen=True): + """ + GET parameters for order book ticker + + Parameters + ---------- + symbol : str + The trading pair. When given, endpoint will return a single BinanceTickerBook + When omitted, endpoint will return a list of BinanceTickerBook for all trading pairs. + symbols : str + SPOT/MARGIN only! + List of trading pairs. When given, endpoint will return a list of BinanceTickerBook + """ + + symbol: Optional[BinanceSymbol] = None + symbols: Optional[BinanceSymbols] = None # SPOT/MARGIN only + + async def _get(self, parameters: GetParameters) -> list[BinanceTickerBook]: + method_type = BinanceMethodType.GET + raw = await self._method(method_type, parameters) + if parameters.symbol is not None: + return [self._get_obj_resp_decoder.decode(raw)] + else: + return self._get_arr_resp_decoder.decode(raw) + + +class BinanceMarketHttpAPI: + """ + Provides access to the Binance Market HTTP REST API. + + Parameters + ---------- + client : BinanceHttpClient + The Binance REST API client. + account_type : BinanceAccountType + The Binance account type, used to select the endpoint prefix + + Warnings + -------- + This class should not be used directly, but through a concrete subclass. + """ + + def __init__( + self, + client: BinanceHttpClient, + account_type: BinanceAccountType, + ): + PyCondition.not_none(client, "client") + self.client = client + + if account_type.is_spot_or_margin: + self.base_endpoint = "/api/v3/" + elif account_type == BinanceAccountType.FUTURES_USDT: + self.base_endpoint = "/fapi/v1/" + elif account_type == BinanceAccountType.FUTURES_COIN: + self.base_endpoint = "/dapi/v1/" + else: + raise RuntimeError( # pragma: no cover (design-time error) + f"invalid `BinanceAccountType`, was {account_type}", # pragma: no cover + ) + + # Create Endpoints + self._endpoint_ping = BinancePingHttp(client, self.base_endpoint) + self._endpoint_time = BinanceTimeHttp(client, self.base_endpoint) + self._endpoint_depth = BinanceDepthHttp(client, self.base_endpoint) + self._endpoint_trades = BinanceTradesHttp(client, self.base_endpoint) + self._endpoint_historical_trades = BinanceHistoricalTradesHttp(client, self.base_endpoint) + self._endpoint_agg_trades = BinanceAggTradesHttp(client, self.base_endpoint) + self._endpoint_klines = BinanceKlinesHttp(client, self.base_endpoint) + self._endpoint_ticker_24hr = BinanceTicker24hrHttp(client, self.base_endpoint) + self._endpoint_ticker_price = BinanceTickerPriceHttp(client, self.base_endpoint) + self._endpoint_ticker_book = BinanceTickerBookHttp(client, self.base_endpoint) + + async def ping(self) -> dict: + """Ping Binance REST API""" + return await self._endpoint_ping._get() + + async def request_server_time(self) -> int: + """Request server time from Binance""" + response = await self._endpoint_time._get() + return response.serverTime + + async def query_depth( + self, + symbol: str, + limit: Optional[int] = None, + ) -> BinanceDepth: + """Query order book depth for a symbol.""" + return await self._endpoint_depth._get( + parameters=self._endpoint_depth.GetParameters( + symbol=BinanceSymbol(symbol), + limit=limit, + ), + ) + + async def request_order_book_snapshot( + self, + instrument_id: InstrumentId, + ts_init: int, + limit: Optional[int] = None, + ) -> OrderBookSnapshot: + """Request snapshot of order book depth.""" + depth = await self.query_depth(instrument_id.symbol.value, limit) + return depth.parse_to_order_book_snapshot( + instrument_id=instrument_id, + ts_init=ts_init, + ) + + async def query_trades( + self, + symbol: str, + limit: Optional[int] = None, + ) -> list[BinanceTrade]: + """Query trades for symbol""" + return await self._endpoint_trades._get( + parameters=self._endpoint_trades.GetParameters( + symbol=BinanceSymbol(symbol), + limit=limit, + ), + ) + + async def request_trade_ticks( + self, + instrument_id: InstrumentId, + ts_init: int, + limit: Optional[int] = None, + ) -> list[TradeTick]: + """Request TradeTicks from Binance""" + trades = await self.query_trades(instrument_id.symbol.value, limit) + return [ + trade.parse_to_trade_tick( + instrument_id=instrument_id, + ts_init=ts_init, + ) + for trade in trades + ] + + async def query_agg_trades( + self, + symbol: str, + limit: Optional[int] = None, + start_time: Optional[str] = None, + end_time: Optional[str] = None, + from_id: Optional[str] = None, + ) -> list[BinanceAggTrade]: + """Query trades for symbol""" + return await self._endpoint_agg_trades._get( + parameters=self._endpoint_agg_trades.GetParameters( + symbol=BinanceSymbol(symbol), + limit=limit, + startTime=start_time, + endTime=end_time, + fromId=from_id, + ), + ) + + async def query_historical_trades( + self, + symbol: str, + limit: Optional[int] = None, + from_id: Optional[str] = None, + ) -> list[BinanceTrade]: + """Query historical trades for symbol""" + return await self._endpoint_historical_trades._get( + parameters=self._endpoint_historical_trades.GetParameters( + symbol=BinanceSymbol(symbol), + limit=limit, + fromId=from_id, + ), + ) + + async def request_historical_trade_ticks( + self, + instrument_id: InstrumentId, + ts_init: int, + limit: Optional[int] = None, + from_id: Optional[str] = None, + ) -> list[TradeTick]: + """Request historical TradeTicks from Binance""" + historical_trades = await self.query_historical_trades( + symbol=instrument_id.symbol.value, + limit=limit, + from_id=from_id, + ) + return [ + trade.parse_to_trade_tick( + instrument_id=instrument_id, + ts_init=ts_init, + ) + for trade in historical_trades + ] + + async def query_klines( + self, + symbol: str, + interval: BinanceKlineInterval, + limit: Optional[int] = None, + start_time: Optional[str] = None, + end_time: Optional[str] = None, + ) -> list[BinanceKline]: + """Query klines for a symbol over an interval.""" + return await self._endpoint_klines._get( + parameters=self._endpoint_klines.GetParameters( + symbol=BinanceSymbol(symbol), + interval=interval, + limit=limit, + startTime=start_time, + endTime=end_time, + ), + ) + + async def request_binance_bars( + self, + bar_type: BarType, + ts_init: int, + interval: BinanceKlineInterval, + limit: Optional[int] = None, + start_time: Optional[str] = None, + end_time: Optional[str] = None, + ) -> list[BinanceBar]: + """Request Binance Bars from Klines""" + klines = await self.query_klines( + symbol=bar_type.instrument_id.symbol.value, + interval=interval, + limit=limit, + start_time=start_time, + end_time=end_time, + ) + bars: list[BinanceBar] = [kline.parse_to_binance_bar(bar_type, ts_init) for kline in klines] + return bars + + async def query_ticker_24hr( + self, + symbol: Optional[str] = None, + symbols: Optional[list[str]] = None, + response_type: Optional[str] = None, + ) -> list[BinanceTicker24hr]: + """Query 24hr ticker for symbol or symbols.""" + if symbol is not None and symbols is not None: + raise RuntimeError( + "Cannot specify both symbol and symbols parameters.", + ) + return await self._endpoint_ticker_24hr._get( + parameters=self._endpoint_ticker_24hr.GetParameters( + symbol=BinanceSymbol(symbol), + symbols=BinanceSymbols(symbols), + type=response_type, + ), + ) + + async def query_ticker_price( + self, + symbol: Optional[str] = None, + symbols: Optional[list[str]] = None, + ) -> list[BinanceTickerPrice]: + """Query price ticker for symbol or symbols.""" + if symbol is not None and symbols is not None: + raise RuntimeError( + "Cannot specify both symbol and symbols parameters.", + ) + return await self._endpoint_ticker_price._get( + parameters=self._endpoint_ticker_price.GetParameters( + symbol=BinanceSymbol(symbol), + symbols=BinanceSymbols(symbols), + ), + ) + + async def query_ticker_book( + self, + symbol: Optional[str] = None, + symbols: Optional[list[str]] = None, + ) -> list[BinanceTickerBook]: + """Query book ticker for symbol or symbols.""" + if symbol is not None and symbols is not None: + raise RuntimeError( + "Cannot specify both symbol and symbols parameters.", + ) + return await self._endpoint_ticker_book._get( + parameters=self._endpoint_ticker_book.GetParameters( + symbol=BinanceSymbol(symbol), + symbols=BinanceSymbols(symbols), + ), + ) diff --git a/nautilus_trader/adapters/binance/http/user.py b/nautilus_trader/adapters/binance/http/user.py new file mode 100644 index 000000000000..a2cdd599aeca --- /dev/null +++ b/nautilus_trader/adapters/binance/http/user.py @@ -0,0 +1,206 @@ +# ------------------------------------------------------------------------------------------------- +# Copyright (C) 2015-2023 Nautech Systems Pty Ltd. All rights reserved. +# https://nautechsystems.io +# +# Licensed under the GNU Lesser General Public License Version 3.0 (the "License"); +# You may not use this file except in compliance with the License. +# You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ------------------------------------------------------------------------------------------------- + +from typing import Optional + +import msgspec + +from nautilus_trader.adapters.binance.common.enums import BinanceAccountType +from nautilus_trader.adapters.binance.common.enums import BinanceMethodType +from nautilus_trader.adapters.binance.common.enums import BinanceSecurityType +from nautilus_trader.adapters.binance.common.schemas.symbol import BinanceSymbol +from nautilus_trader.adapters.binance.common.schemas.user import BinanceListenKey +from nautilus_trader.adapters.binance.http.client import BinanceHttpClient +from nautilus_trader.adapters.binance.http.endpoint import BinanceHttpEndpoint +from nautilus_trader.core.correctness import PyCondition + + +class BinanceListenKeyHttp(BinanceHttpEndpoint): + """ + Endpoint for managing user data streams (listenKey) + + `POST /api/v3/userDataStream` + `POST /sapi/v3/userDataStream` + `POST /sapi/v3/userDataStream/isolated` + `POST /fapi/v1/listenKey` + `POST /dapi/v1/listenKey` + + `PUT /api/v3/userDataStream` + `PUT /sapi/v3/userDataStream` + `PUT /sapi/v3/userDataStream/isolated` + `PUT /fapi/v1/listenKey` + `PUT /dapi/v1/listenKey` + + `DELETE /api/v3/userDataStream` + `DELETE /sapi/v3/userDataStream` + `DELETE /sapi/v3/userDataStream/isolated` + `DELETE /fapi/v1/listenKey` + `DELETE /dapi/v1/listenKey` + + References + ---------- + https://binance-docs.github.io/apidocs/spot/en/#listen-key-spot + https://binance-docs.github.io/apidocs/spot/en/#listen-key-margin + https://binance-docs.github.io/apidocs/futures/en/#start-user-data-stream-user_stream + https://binance-docs.github.io/apidocs/delivery/en/#start-user-data-stream-user_stream + + """ + + def __init__( + self, + client: BinanceHttpClient, + url_path: str, + ): + methods = { + BinanceMethodType.POST: BinanceSecurityType.USER_STREAM, + BinanceMethodType.PUT: BinanceSecurityType.USER_STREAM, + BinanceMethodType.DELETE: BinanceSecurityType.USER_STREAM, + } + super().__init__( + client, + methods, + url_path, + ) + self._post_resp_decoder = msgspec.json.Decoder(BinanceListenKey) + self._put_resp_decoder = msgspec.json.Decoder() + self._delete_resp_decoder = msgspec.json.Decoder() + + class PostParameters(msgspec.Struct, omit_defaults=True, frozen=True): + """ + POST parameters for creating listenkeys + + Parameters + ---------- + symbol : BinanceSymbol + The trading pair. Only required for ISOLATED MARGIN accounts! + """ + + symbol: Optional[BinanceSymbol] = None # MARGIN_ISOLATED only, mandatory + + class PutDeleteParameters(msgspec.Struct, omit_defaults=True, frozen=True): + """ + PUT & DELETE parameters for managing listenkeys + + Parameters + ---------- + symbol : BinanceSymbol + The trading pair. Only required for ISOLATED MARGIN accounts! + listenKey : str + The listenkey to manage. Only required for SPOT/MARGIN accounts! + """ + + symbol: Optional[BinanceSymbol] = None # MARGIN_ISOLATED only, mandatory + listenKey: Optional[str] = None # SPOT/MARGIN only, mandatory + + async def _post(self, parameters: Optional[PostParameters] = None) -> BinanceListenKey: + method_type = BinanceMethodType.POST + raw = await self._method(method_type, parameters) + return self._post_resp_decoder.decode(raw) + + async def _put(self, parameters: Optional[PutDeleteParameters] = None) -> dict: + method_type = BinanceMethodType.PUT + raw = await self._method(method_type, parameters) + return self._put_resp_decoder.decode(raw) + + async def _delete(self, parameters: Optional[PutDeleteParameters] = None) -> dict: + method_type = BinanceMethodType.DELETE + raw = await self._method(method_type, parameters) + return self._delete_resp_decoder.decode(raw) + + +class BinanceUserDataHttpAPI: + """ + Provides access to the `Binance` User HTTP REST API. + + Parameters + ---------- + client : BinanceHttpClient + The Binance REST API client. + account_type : BinanceAccountType + The Binance account type, used to select the endpoint + + Warnings + -------- + This class should not be used directly, but through a concrete subclass. + """ + + def __init__( + self, + client: BinanceHttpClient, + account_type: BinanceAccountType, + ): + PyCondition.not_none(client, "client") + self.client = client + self.account_type = account_type + + if account_type == BinanceAccountType.SPOT: + self.base_endpoint = "/api/v3/" + listen_key_url = self.base_endpoint + "userDataStream" + elif account_type == BinanceAccountType.MARGIN_CROSS: + self.base_endpoint = "/sapi/v1/" + listen_key_url = self.base_endpoint + "userDataStream" + elif account_type == BinanceAccountType.MARGIN_ISOLATED: + self.base_endpoint = "/sapi/v1/" + listen_key_url = self.base_endpoint + "userDataStream/isolated" + elif account_type == BinanceAccountType.FUTURES_USDT: + self.base_endpoint = "/fapi/v1/" + listen_key_url = self.base_endpoint + "listenKey" + elif account_type == BinanceAccountType.FUTURES_COIN: + self.base_endpoint = "/dapi/v1/" + listen_key_url = self.base_endpoint + "listenKey" + else: + raise RuntimeError( # pragma: no cover (design-time error) + f"invalid `BinanceAccountType`, was {account_type}", # pragma: no cover (design-time error) # noqa + ) + + self._endpoint_listenkey = BinanceListenKeyHttp(client, listen_key_url) + + async def create_listen_key( + self, + symbol: Optional[str] = None, + ) -> BinanceListenKey: + """Create Binance ListenKey.""" + key = await self._endpoint_listenkey._post( + parameters=self._endpoint_listenkey.PostParameters( + symbol=BinanceSymbol(symbol), + ), + ) + return key + + async def keepalive_listen_key( + self, + symbol: Optional[str] = None, + listen_key: Optional[str] = None, + ): + """Ping/Keepalive Binance ListenKey.""" + await self._endpoint_listenkey._put( + parameters=self._endpoint_listenkey.PutDeleteParameters( + symbol=BinanceSymbol(symbol), + listenKey=listen_key, + ), + ) + + async def delete_listen_key( + self, + symbol: Optional[str] = None, + listen_key: Optional[str] = None, + ): + """Delete Binance ListenKey.""" + await self._endpoint_listenkey._delete( + parameters=self._endpoint_listenkey.PutDeleteParameters( + symbol=BinanceSymbol(symbol), + listenKey=listen_key, + ), + ) diff --git a/nautilus_trader/adapters/binance/spot/data.py b/nautilus_trader/adapters/binance/spot/data.py index ea03e0670483..201f78c4b2fc 100644 --- a/nautilus_trader/adapters/binance/spot/data.py +++ b/nautilus_trader/adapters/binance/spot/data.py @@ -14,63 +14,29 @@ # ------------------------------------------------------------------------------------------------- import asyncio -from typing import Any, Optional +from typing import Optional import msgspec -import pandas as pd -from nautilus_trader.adapters.binance.common.constants import BINANCE_VENUE +from nautilus_trader.adapters.binance.common.data import BinanceCommonDataClient from nautilus_trader.adapters.binance.common.enums import BinanceAccountType -from nautilus_trader.adapters.binance.common.functions import parse_symbol -from nautilus_trader.adapters.binance.common.parsing.data import parse_bar_http -from nautilus_trader.adapters.binance.common.parsing.data import parse_bar_ws -from nautilus_trader.adapters.binance.common.parsing.data import parse_diff_depth_stream_ws -from nautilus_trader.adapters.binance.common.parsing.data import parse_quote_tick_ws -from nautilus_trader.adapters.binance.common.parsing.data import parse_ticker_24hr_ws -from nautilus_trader.adapters.binance.common.parsing.data import parse_trade_tick_http -from nautilus_trader.adapters.binance.common.schemas import BinanceCandlestickMsg -from nautilus_trader.adapters.binance.common.schemas import BinanceDataMsgWrapper -from nautilus_trader.adapters.binance.common.schemas import BinanceOrderBookMsg -from nautilus_trader.adapters.binance.common.schemas import BinanceQuoteMsg -from nautilus_trader.adapters.binance.common.schemas import BinanceTickerMsg -from nautilus_trader.adapters.binance.common.schemas import BinanceTrade -from nautilus_trader.adapters.binance.common.types import BinanceBar -from nautilus_trader.adapters.binance.common.types import BinanceTicker from nautilus_trader.adapters.binance.http.client import BinanceHttpClient +from nautilus_trader.adapters.binance.spot.enums import BinanceSpotEnumParser from nautilus_trader.adapters.binance.spot.http.market import BinanceSpotMarketHttpAPI -from nautilus_trader.adapters.binance.spot.parsing.data import parse_spot_book_snapshot -from nautilus_trader.adapters.binance.spot.parsing.data import parse_spot_trade_tick_ws -from nautilus_trader.adapters.binance.spot.schemas.market import BinanceSpotOrderBookMsg +from nautilus_trader.adapters.binance.spot.schemas.market import BinanceSpotOrderBookPartialDepthMsg from nautilus_trader.adapters.binance.spot.schemas.market import BinanceSpotTradeMsg -from nautilus_trader.adapters.binance.websocket.client import BinanceWebSocketClient from nautilus_trader.cache.cache import Cache from nautilus_trader.common.clock import LiveClock -from nautilus_trader.common.enums import LogColor from nautilus_trader.common.logging import Logger from nautilus_trader.common.providers import InstrumentProvider -from nautilus_trader.core.asynchronous import sleep0 -from nautilus_trader.core.datetime import secs_to_millis -from nautilus_trader.core.uuid import UUID4 -from nautilus_trader.live.data_client import LiveMarketDataClient -from nautilus_trader.model.data.bar import BarType -from nautilus_trader.model.data.base import DataType -from nautilus_trader.model.data.tick import QuoteTick from nautilus_trader.model.data.tick import TradeTick -from nautilus_trader.model.enums import BarAggregation -from nautilus_trader.model.enums import BookType -from nautilus_trader.model.enums import PriceType -from nautilus_trader.model.enums import bar_aggregation_to_str -from nautilus_trader.model.identifiers import ClientId from nautilus_trader.model.identifiers import InstrumentId -from nautilus_trader.model.identifiers import Symbol -from nautilus_trader.model.instruments.base import Instrument from nautilus_trader.model.orderbook.data import OrderBookData -from nautilus_trader.model.orderbook.data import OrderBookDeltas from nautilus_trader.model.orderbook.data import OrderBookSnapshot from nautilus_trader.msgbus.bus import MessageBus -class BinanceSpotDataClient(LiveMarketDataClient): +class BinanceSpotDataClient(BinanceCommonDataClient): """ Provides a data client for the `Binance Spot/Margin` exchange. @@ -108,470 +74,46 @@ def __init__( account_type: BinanceAccountType = BinanceAccountType.SPOT, base_url_ws: Optional[str] = None, ): - super().__init__( - loop=loop, - client_id=ClientId(BINANCE_VENUE.value), - venue=BINANCE_VENUE, - instrument_provider=instrument_provider, - msgbus=msgbus, - cache=cache, - clock=clock, - logger=logger, - ) - - assert account_type.is_spot or account_type.is_margin, "account type is not for spot/margin" - self._binance_account_type = account_type - self._log.info(f"Account type: {self._binance_account_type.value}.", LogColor.BLUE) + if not account_type.is_spot_or_margin: + raise RuntimeError( # pragma: no cover (design-time error) + f"`BinanceAccountType` not SPOT, MARGIN_CROSS or MARGIN_ISOLATED, was {account_type}", # pragma: no cover + ) - self._update_instruments_interval: int = 60 * 60 # Once per hour (hardcode) - self._update_instruments_task: Optional[asyncio.Task] = None + # Spot HTTP API + self._spot_http_market = BinanceSpotMarketHttpAPI(client, account_type) - # HTTP API - self._http_client = client - self._http_market = BinanceSpotMarketHttpAPI(client=self._http_client) + # Spot enum parser + self._spot_enum_parser = BinanceSpotEnumParser() - # WebSocket API - self._ws_client = BinanceWebSocketClient( + super().__init__( loop=loop, + client=client, + market=self._spot_http_market, + enum_parser=self._spot_enum_parser, + msgbus=msgbus, + cache=cache, clock=clock, logger=logger, - handler=self._handle_ws_message, - base_url=base_url_ws, - ) - - # Hot caches - self._instrument_ids: dict[str, InstrumentId] = {} - self._book_buffer: dict[InstrumentId, list[OrderBookData]] = {} - - self._log.info(f"Base URL HTTP {self._http_client.base_url}.", LogColor.BLUE) - self._log.info(f"Base URL WebSocket {base_url_ws}.", LogColor.BLUE) - - async def _connect(self) -> None: - # Connect HTTP client - if not self._http_client.connected: - await self._http_client.connect() - - await self._instrument_provider.initialize() - - self._send_all_instruments_to_data_engine() - self._update_instruments_task = self.create_task(self._update_instruments()) - - # Connect WebSocket clients - self.create_task(self._connect_websockets()) - - async def _connect_websockets(self) -> None: - self._log.info("Awaiting subscriptions...") - await asyncio.sleep(4) - if self._ws_client.has_subscriptions: - await self._ws_client.connect() - - async def _update_instruments(self) -> None: - try: - while True: - self._log.debug( - f"Scheduled `update_instruments` to run in " - f"{self._update_instruments_interval}s.", - ) - await asyncio.sleep(self._update_instruments_interval) - await self._instrument_provider.load_all_async() - self._send_all_instruments_to_data_engine() - except asyncio.CancelledError: - self._log.debug("`update_instruments` task was canceled.") - - async def _disconnect(self) -> None: - # Cancel tasks - if self._update_instruments_task: - self._log.debug("Canceling `update_instruments` task...") - self._update_instruments_task.cancel() - self._update_instruments_task.done() - - # Disconnect WebSocket client - if self._ws_client.is_connected: - await self._ws_client.disconnect() - - # Disconnect HTTP client - if self._http_client.connected: - await self._http_client.disconnect() - - # -- SUBSCRIPTIONS ---------------------------------------------------------------------------- - - async def _subscribe_instruments(self) -> None: - pass # Do nothing further - - async def _subscribe_instrument(self, instrument_id: InstrumentId) -> None: - pass # Do nothing further - - async def _subscribe_order_book_deltas( - self, - instrument_id: InstrumentId, - book_type: BookType, - depth: Optional[int] = None, - kwargs: Optional[dict] = None, - ) -> None: - await self._subscribe_order_book( - instrument_id=instrument_id, - book_type=book_type, - depth=depth, - ) - - async def _subscribe_order_book_snapshots( - self, - instrument_id: InstrumentId, - book_type: BookType, - depth: Optional[int] = None, - kwargs: Optional[dict] = None, - ) -> None: - await self._subscribe_order_book( - instrument_id=instrument_id, - book_type=book_type, - depth=depth, - ) - - async def _subscribe_order_book( - self, - instrument_id: InstrumentId, - book_type: BookType, - depth: Optional[int] = None, - ) -> None: - if book_type == BookType.L3_MBO: - self._log.error( - "Cannot subscribe to order book deltas: " - "L3_MBO data is not published by Binance. " - "Valid book types are L1_TBBO, L2_MBP.", - ) - return - - if depth is None or depth == 0: - depth = 20 - - # Add delta stream buffer - self._book_buffer[instrument_id] = [] - - if 0 < depth <= 20: - if depth not in (5, 10, 20): - self._log.error( - "Cannot subscribe to order book snapshots: " - f"invalid `depth`, was {depth}. " - "Valid depths are 5, 10 or 20.", - ) - return - self._ws_client.subscribe_partial_book_depth( - symbol=instrument_id.symbol.value, - depth=depth, - speed=100, - ) - else: - self._ws_client.subscribe_diff_book_depth( - symbol=instrument_id.symbol.value, - speed=100, - ) - - while not self._ws_client.is_connected: - await sleep0() - - data: dict[str, Any] = await self._http_market.depth( - symbol=instrument_id.symbol.value, - limit=depth, - ) - - ts_event: int = self._clock.timestamp_ns() - last_update_id: int = data.get("lastUpdateId", 0) - - snapshot = OrderBookSnapshot( - instrument_id=instrument_id, - book_type=BookType.L2_MBP, - bids=[[float(o[0]), float(o[1])] for o in data.get("bids", [])], - asks=[[float(o[0]), float(o[1])] for o in data.get("asks", [])], - ts_event=ts_event, - ts_init=ts_event, - sequence=last_update_id, - ) - - self._handle_data(snapshot) - - book_buffer = self._book_buffer.pop(instrument_id, []) - for deltas in book_buffer: - if deltas.sequence <= last_update_id: - continue - self._handle_data(deltas) - - async def _subscribe_ticker(self, instrument_id: InstrumentId) -> None: - self._ws_client.subscribe_ticker(instrument_id.symbol.value) - - async def _subscribe_quote_ticks(self, instrument_id: InstrumentId) -> None: - self._ws_client.subscribe_book_ticker(instrument_id.symbol.value) - - async def _subscribe_trade_ticks(self, instrument_id: InstrumentId) -> None: - self._ws_client.subscribe_trades(instrument_id.symbol.value) - - async def _subscribe_bars(self, bar_type: BarType) -> None: - if not bar_type.spec.is_time_aggregated(): - self._log.error( - f"Cannot subscribe to {bar_type}: only time bars are aggregated by Binance.", - ) - return - - if bar_type.spec.aggregation == BarAggregation.MILLISECOND: - self._log.error( - f"Cannot subscribe to {bar_type}: " - f"{bar_aggregation_to_str(bar_type.spec.aggregation)} " - f"bars are not aggregated by Binance.", - ) - return - - if bar_type.spec.aggregation == BarAggregation.SECOND: - resolution = "s" - elif bar_type.spec.aggregation == BarAggregation.MINUTE: - resolution = "m" - elif bar_type.spec.aggregation == BarAggregation.HOUR: - resolution = "h" - elif bar_type.spec.aggregation == BarAggregation.DAY: - resolution = "d" - else: - raise RuntimeError( # pragma: no cover (design-time error) - f"invalid `BarAggregation`, " # pragma: no cover - f"was {bar_aggregation_to_str(bar_type.spec.aggregation)}", # pragma: no cover - ) - - self._ws_client.subscribe_bars( - symbol=bar_type.instrument_id.symbol.value, - interval=f"{bar_type.spec.step}{resolution}", - ) - - async def _unsubscribe_instruments(self) -> None: - pass # Do nothing further - - async def _unsubscribe_instrument(self, instrument_id: InstrumentId) -> None: - pass # Do nothing further - - async def _unsubscribe_order_book_deltas(self, instrument_id: InstrumentId) -> None: - pass # TODO: Unsubscribe from Binance if no other subscriptions - - async def _unsubscribe_order_book_snapshots(self, instrument_id: InstrumentId) -> None: - pass # TODO: Unsubscribe from Binance if no other subscriptions - - async def _unsubscribe_ticker(self, instrument_id: InstrumentId) -> None: - pass # TODO: Unsubscribe from Binance if no other subscriptions - - async def _unsubscribe_quote_ticks(self, instrument_id: InstrumentId) -> None: - pass # TODO: Unsubscribe from Binance if no other subscriptions - - async def _unsubscribe_trade_ticks(self, instrument_id: InstrumentId) -> None: - pass # TODO: Unsubscribe from Binance if no other subscriptions - - async def _unsubscribe_bars(self, bar_type: BarType) -> None: - pass # TODO: Unsubscribe from Binance if no other subscriptions - - # -- REQUESTS --------------------------------------------------------------------------------- - - async def _request_instrument(self, instrument_id: InstrumentId, correlation_id: UUID4) -> None: - instrument: Optional[Instrument] = self._instrument_provider.find(instrument_id) - if instrument is None: - self._log.error(f"Cannot find instrument for {instrument_id}.") - return - - data_type = DataType( - type=Instrument, - metadata={"instrument_id": instrument_id}, - ) - - self._handle_data_response( - data_type=data_type, - data=[instrument], # Data engine handles lists of instruments - correlation_id=correlation_id, - ) - - async def _request_quote_ticks( - self, - instrument_id: InstrumentId, - limit: int, - correlation_id: UUID4, - from_datetime: Optional[pd.Timestamp] = None, - to_datetime: Optional[pd.Timestamp] = None, - ) -> None: - self._log.error( - "Cannot request historical quote ticks: not published by Binance.", + instrument_provider=instrument_provider, + account_type=account_type, + base_url_ws=base_url_ws, ) - async def _request_trade_ticks( - self, - instrument_id: InstrumentId, - limit: int, - correlation_id: UUID4, - from_datetime: Optional[pd.Timestamp] = None, - to_datetime: Optional[pd.Timestamp] = None, - ) -> None: - if limit == 0 or limit > 1000: - limit = 1000 - - if from_datetime is not None or to_datetime is not None: - self._log.warning( - "Trade ticks have been requested with a from/to time range, " - f"however the request will be for the most recent {limit}.", - ) - - response: list[BinanceTrade] = await self._http_market.trades( - instrument_id.symbol.value, - limit, + # Websocket msgspec decoders + self._decoder_spot_trade = msgspec.json.Decoder(BinanceSpotTradeMsg) + self._decoder_spot_order_book_partial_depth = msgspec.json.Decoder( + BinanceSpotOrderBookPartialDepthMsg, ) - ticks: list[TradeTick] = [ - parse_trade_tick_http( - trade=trade, - instrument_id=instrument_id, - ts_init=self._clock.timestamp_ns(), - ) - for trade in response - ] - - self._handle_trade_ticks(instrument_id, ticks, correlation_id) - - async def _request_bars( # noqa (too complex) - self, - bar_type: BarType, - limit: int, - correlation_id: UUID4, - from_datetime: Optional[pd.Timestamp] = None, - to_datetime: Optional[pd.Timestamp] = None, - ) -> None: - if bar_type.is_internally_aggregated(): - self._log.error( - f"Cannot request {bar_type}: " - f"only historical bars with EXTERNAL aggregation available from Binance.", - ) - return - - if not bar_type.spec.is_time_aggregated(): - self._log.error( - f"Cannot request {bar_type}: only time bars are aggregated by Binance.", - ) - return - - if bar_type.spec.aggregation == BarAggregation.MILLISECOND: - self._log.error( - f"Cannot request {bar_type}: " - f"{bar_aggregation_to_str(bar_type.spec.aggregation)} " - f"bars are not aggregated by Binance.", - ) - return - - if bar_type.spec.price_type != PriceType.LAST: - self._log.error( - f"Cannot request {bar_type}: " - f"only historical bars for LAST price type available from Binance.", - ) - return - - if limit == 0 or limit > 1000: - limit = 1000 - - if bar_type.spec.aggregation == BarAggregation.SECOND: - resolution = "s" - elif bar_type.spec.aggregation == BarAggregation.MINUTE: - resolution = "m" - elif bar_type.spec.aggregation == BarAggregation.HOUR: - resolution = "h" - elif bar_type.spec.aggregation == BarAggregation.DAY: - resolution = "d" - else: - raise RuntimeError( # pragma: no cover (design-time error) - f"invalid `BarAggregation`, " # pragma: no cover - f"was {bar_aggregation_to_str(bar_type.spec.aggregation)}", # pragma: no cover - ) - - start_time_ms = None - if from_datetime is not None: - start_time_ms = secs_to_millis(from_datetime.timestamp()) - - end_time_ms = None - if to_datetime is not None: - end_time_ms = secs_to_millis(to_datetime.timestamp()) + # -- WEBSOCKET HANDLERS --------------------------------------------------------------------------------- - data: list[list[Any]] = await self._http_market.klines( - symbol=bar_type.instrument_id.symbol.value, - interval=f"{bar_type.spec.step}{resolution}", - start_time_ms=start_time_ms, - end_time_ms=end_time_ms, - limit=limit, - ) - - bars: list[BinanceBar] = [ - parse_bar_http( - bar_type, - values=b, - ts_init=self._clock.timestamp_ns(), - ) - for b in data - ] - partial: BinanceBar = bars.pop() - - self._handle_bars(bar_type, bars, partial, correlation_id) - - def _send_all_instruments_to_data_engine(self) -> None: - for instrument in self._instrument_provider.get_all().values(): - self._handle_data(instrument) - - for currency in self._instrument_provider.currencies().values(): - self._cache.add_currency(currency) - - def _get_cached_instrument_id(self, symbol: str) -> InstrumentId: - # Parse instrument ID - nautilus_symbol: str = parse_symbol(symbol, account_type=self._binance_account_type) - instrument_id: Optional[InstrumentId] = self._instrument_ids.get(nautilus_symbol) - if not instrument_id: - instrument_id = InstrumentId(Symbol(nautilus_symbol), BINANCE_VENUE) - self._instrument_ids[nautilus_symbol] = instrument_id - return instrument_id - - def _handle_ws_message(self, raw: bytes) -> None: - # TODO(cs): Uncomment for development - # self._log.info(str(raw), LogColor.CYAN) - - wrapper = msgspec.json.decode(raw, type=BinanceDataMsgWrapper) - - try: - if "@depth@" in wrapper.stream: - self._handle_book_diff_update(raw) - elif "@depth" in wrapper.stream: - self._handle_book_update(raw) - elif "@bookTicker" in wrapper.stream: - self._handle_book_ticker(raw) - elif "@trade" in wrapper.stream: - self._handle_trade(raw) - elif "@ticker" in wrapper.stream: - self._handle_ticker(raw) - elif "@kline" in wrapper.stream: - self._handle_kline(raw) - else: - self._log.error( - f"Unrecognized websocket message type: {msgspec.json.decode(raw)['stream']}", - ) - return - except Exception as e: - self._log.error(f"Error handling websocket message, {e}") - - def _handle_book_diff_update(self, raw: bytes) -> None: - msg: BinanceOrderBookMsg = msgspec.json.decode(raw, type=BinanceOrderBookMsg) - instrument_id: InstrumentId = self._get_cached_instrument_id(msg.data.s) - book_deltas: OrderBookDeltas = parse_diff_depth_stream_ws( - instrument_id=instrument_id, - data=msg.data, - ts_init=self._clock.timestamp_ns(), - ) - book_buffer: Optional[list[OrderBookData]] = self._book_buffer.get(instrument_id) - if book_buffer is not None: - book_buffer.append(book_deltas) - else: - self._handle_data(book_deltas) - - def _handle_book_update(self, raw: bytes) -> None: - msg: BinanceSpotOrderBookMsg = msgspec.json.decode(raw, type=BinanceSpotOrderBookMsg) + def _handle_book_partial_update(self, raw: bytes) -> None: + msg = self._decoder_spot_order_book_partial_depth.decode(raw) instrument_id: InstrumentId = self._get_cached_instrument_id( - msg.stream.partition("@")[0].upper(), + msg.stream.partition("@")[0], ) - book_snapshot: OrderBookSnapshot = parse_spot_book_snapshot( + book_snapshot: OrderBookSnapshot = msg.data.parse_to_order_book_snapshot( instrument_id=instrument_id, - data=msg.data, ts_init=self._clock.timestamp_ns(), ) # Check if book buffer active @@ -581,45 +123,11 @@ def _handle_book_update(self, raw: bytes) -> None: else: self._handle_data(book_snapshot) - def _handle_book_ticker(self, raw: bytes) -> None: - msg: BinanceQuoteMsg = msgspec.json.decode(raw, type=BinanceQuoteMsg) - instrument_id: InstrumentId = self._get_cached_instrument_id(msg.data.s) - quote_tick: QuoteTick = parse_quote_tick_ws( - instrument_id=instrument_id, - data=msg.data, - ts_init=self._clock.timestamp_ns(), - ) - self._handle_data(quote_tick) - def _handle_trade(self, raw: bytes) -> None: - msg: BinanceSpotTradeMsg = msgspec.json.decode(raw, type=BinanceSpotTradeMsg) + msg = self._decoder_spot_trade.decode(raw) instrument_id: InstrumentId = self._get_cached_instrument_id(msg.data.s) - trade_tick: TradeTick = parse_spot_trade_tick_ws( + trade_tick: TradeTick = msg.data.parse_to_trade_tick( instrument_id=instrument_id, - data=msg.data, ts_init=self._clock.timestamp_ns(), ) self._handle_data(trade_tick) - - def _handle_ticker(self, raw: bytes) -> None: - msg: BinanceTickerMsg = msgspec.json.decode(raw, type=BinanceTickerMsg) - instrument_id: InstrumentId = self._get_cached_instrument_id(msg.data.s) - ticker: BinanceTicker = parse_ticker_24hr_ws( - instrument_id=instrument_id, - data=msg.data, - ts_init=self._clock.timestamp_ns(), - ) - self._handle_data(ticker) - - def _handle_kline(self, raw: bytes) -> None: - msg: BinanceCandlestickMsg = msgspec.json.decode(raw, type=BinanceCandlestickMsg) - if not msg.data.k.x: - return # Not closed yet - - instrument_id: InstrumentId = self._get_cached_instrument_id(msg.data.s) - bar: BinanceBar = parse_bar_ws( - instrument_id=instrument_id, - data=msg.data.k, - ts_init=self._clock.timestamp_ns(), - ) - self._handle_data(bar) diff --git a/nautilus_trader/adapters/binance/spot/enums.py b/nautilus_trader/adapters/binance/spot/enums.py index e2e65b0d26d3..452169a8e6fb 100644 --- a/nautilus_trader/adapters/binance/spot/enums.py +++ b/nautilus_trader/adapters/binance/spot/enums.py @@ -16,6 +16,12 @@ from enum import Enum from enum import unique +from nautilus_trader.adapters.binance.common.enums import BinanceEnumParser +from nautilus_trader.adapters.binance.common.enums import BinanceOrderType +from nautilus_trader.model.enums import OrderType +from nautilus_trader.model.enums import TimeInForce +from nautilus_trader.model.orders.base import Order + """ Defines `Binance` Spot/Margin specific enums. @@ -56,15 +62,6 @@ class BinanceSpotSymbolStatus(Enum): BREAK = "BREAK" -@unique -class BinanceSpotTimeInForce(Enum): - """Represents a `Binance Spot/Margin` order time in force.""" - - GTC = "GTC" - IOC = "IOC" - FOK = "FOK" - - @unique class BinanceSpotEventType(Enum): """Represents a `Binance Spot/Margin` event type.""" @@ -75,27 +72,61 @@ class BinanceSpotEventType(Enum): listStatus = "listStatus" -@unique -class BinanceSpotOrderType(Enum): - """Represents a `Binance Spot/Margin` order type.""" - - LIMIT = "LIMIT" - MARKET = "MARKET" - STOP = "STOP" - STOP_LOSS = "STOP_LOSS" - STOP_LOSS_LIMIT = "STOP_LOSS_LIMIT" - TAKE_PROFIT = "TAKE_PROFIT" - TAKE_PROFIT_LIMIT = "TAKE_PROFIT_LIMIT" - LIMIT_MAKER = "LIMIT_MAKER" - - -@unique -class BinanceSpotOrderStatus(Enum): - """Represents a `Binance` order status.""" - - NEW = "NEW" - PARTIALLY_FILLED = "PARTIALLY_FILLED" - FILLED = "FILLED" - CANCELED = "CANCELED" - REJECTED = "REJECTED" - EXPIRED = "EXPIRED" +class BinanceSpotEnumParser(BinanceEnumParser): + """ + Provides parsing methods for enums used by the 'Binance Spot/Margin' exchange. + """ + + def __init__(self) -> None: + super().__init__() + + # Spot specific order type conversion + self.spot_ext_to_int_order_type = { + BinanceOrderType.LIMIT: OrderType.LIMIT, + BinanceOrderType.MARKET: OrderType.MARKET, + BinanceOrderType.STOP: OrderType.STOP_MARKET, + BinanceOrderType.STOP_LOSS: OrderType.STOP_MARKET, + BinanceOrderType.STOP_LOSS_LIMIT: OrderType.STOP_LIMIT, + BinanceOrderType.TAKE_PROFIT: OrderType.LIMIT, + BinanceOrderType.TAKE_PROFIT_LIMIT: OrderType.STOP_LIMIT, + BinanceOrderType.LIMIT_MAKER: OrderType.LIMIT, + } + + self.spot_valid_time_in_force = { + TimeInForce.GTC, + TimeInForce.GTD, # Will be transformed to GTC with warning + TimeInForce.FOK, + TimeInForce.IOC, + } + + self.spot_valid_order_types = { + OrderType.MARKET, + OrderType.LIMIT, + OrderType.LIMIT_IF_TOUCHED, + OrderType.STOP_LIMIT, + } + + def parse_binance_order_type(self, order_type: BinanceOrderType) -> OrderType: + try: + return self.spot_ext_to_int_order_type[order_type] + except KeyError: + raise RuntimeError( # pragma: no cover (design-time error) + f"unrecognized Binance Spot/Margin order type, was {order_type}", # pragma: no cover + ) + + def parse_internal_order_type(self, order: Order) -> BinanceOrderType: + if order.order_type == OrderType.MARKET: + return BinanceOrderType.MARKET + elif order.order_type == OrderType.LIMIT: + if order.is_post_only: + return BinanceOrderType.LIMIT_MAKER + else: + return BinanceOrderType.LIMIT + elif order.order_type == OrderType.STOP_LIMIT: + return BinanceOrderType.STOP_LOSS_LIMIT + elif order.order_type == OrderType.LIMIT_IF_TOUCHED: + return BinanceOrderType.TAKE_PROFIT_LIMIT + else: + raise RuntimeError( # pragma: no cover (design-time error) + f"invalid or unsupported `OrderType`, was {order.order_type}", # pragma: no cover + ) diff --git a/nautilus_trader/adapters/binance/spot/execution.py b/nautilus_trader/adapters/binance/spot/execution.py index 7986412bc7d7..cc2ef4f743c8 100644 --- a/nautilus_trader/adapters/binance/spot/execution.py +++ b/nautilus_trader/adapters/binance/spot/execution.py @@ -14,92 +14,37 @@ # ------------------------------------------------------------------------------------------------- import asyncio -from decimal import Decimal -from typing import Any, Optional +from typing import Optional import msgspec -import pandas as pd -from nautilus_trader.adapters.binance.common.constants import BINANCE_VENUE from nautilus_trader.adapters.binance.common.enums import BinanceAccountType -from nautilus_trader.adapters.binance.common.enums import BinanceExecutionType -from nautilus_trader.adapters.binance.common.enums import BinanceOrderSide -from nautilus_trader.adapters.binance.common.functions import format_symbol -from nautilus_trader.adapters.binance.common.functions import parse_symbol -from nautilus_trader.adapters.binance.futures.enums import BinanceFuturesTimeInForce +from nautilus_trader.adapters.binance.common.execution import BinanceCommonExecutionClient from nautilus_trader.adapters.binance.http.client import BinanceHttpClient -from nautilus_trader.adapters.binance.http.error import BinanceError +from nautilus_trader.adapters.binance.spot.enums import BinanceSpotEnumParser from nautilus_trader.adapters.binance.spot.enums import BinanceSpotEventType from nautilus_trader.adapters.binance.spot.http.account import BinanceSpotAccountHttpAPI from nautilus_trader.adapters.binance.spot.http.market import BinanceSpotMarketHttpAPI from nautilus_trader.adapters.binance.spot.http.user import BinanceSpotUserDataHttpAPI -from nautilus_trader.adapters.binance.spot.parsing.account import parse_account_balances_http -from nautilus_trader.adapters.binance.spot.parsing.account import parse_account_balances_ws -from nautilus_trader.adapters.binance.spot.parsing.execution import binance_order_type -from nautilus_trader.adapters.binance.spot.parsing.execution import parse_order_report_http -from nautilus_trader.adapters.binance.spot.parsing.execution import parse_order_type -from nautilus_trader.adapters.binance.spot.parsing.execution import parse_time_in_force -from nautilus_trader.adapters.binance.spot.parsing.execution import parse_trade_report_http from nautilus_trader.adapters.binance.spot.providers import BinanceSpotInstrumentProvider -from nautilus_trader.adapters.binance.spot.rules import BINANCE_SPOT_VALID_ORDER_TYPES -from nautilus_trader.adapters.binance.spot.rules import BINANCE_SPOT_VALID_TIF from nautilus_trader.adapters.binance.spot.schemas.account import BinanceSpotAccountInfo -from nautilus_trader.adapters.binance.spot.schemas.user import BinanceSpotAccountUpdateMsg from nautilus_trader.adapters.binance.spot.schemas.user import BinanceSpotAccountUpdateWrapper -from nautilus_trader.adapters.binance.spot.schemas.user import BinanceSpotOrderUpdateData from nautilus_trader.adapters.binance.spot.schemas.user import BinanceSpotOrderUpdateWrapper from nautilus_trader.adapters.binance.spot.schemas.user import BinanceSpotUserMsgWrapper -from nautilus_trader.adapters.binance.websocket.client import BinanceWebSocketClient from nautilus_trader.cache.cache import Cache from nautilus_trader.common.clock import LiveClock from nautilus_trader.common.enums import LogColor from nautilus_trader.common.logging import Logger -from nautilus_trader.core.correctness import PyCondition from nautilus_trader.core.datetime import millis_to_nanos -from nautilus_trader.core.datetime import secs_to_millis -from nautilus_trader.core.uuid import UUID4 -from nautilus_trader.execution.messages import CancelAllOrders -from nautilus_trader.execution.messages import CancelOrder -from nautilus_trader.execution.messages import ModifyOrder -from nautilus_trader.execution.messages import SubmitOrder -from nautilus_trader.execution.messages import SubmitOrderList -from nautilus_trader.execution.reports import OrderStatusReport from nautilus_trader.execution.reports import PositionStatusReport -from nautilus_trader.execution.reports import TradeReport -from nautilus_trader.live.execution_client import LiveExecutionClient -from nautilus_trader.model.enums import AccountType -from nautilus_trader.model.enums import LiquiditySide -from nautilus_trader.model.enums import OmsType -from nautilus_trader.model.enums import OrderSide -from nautilus_trader.model.enums import OrderStatus from nautilus_trader.model.enums import OrderType -from nautilus_trader.model.enums import TimeInForce -from nautilus_trader.model.enums import TrailingOffsetType -from nautilus_trader.model.enums import TriggerType -from nautilus_trader.model.enums import order_side_from_str -from nautilus_trader.model.enums import order_side_to_str from nautilus_trader.model.enums import order_type_to_str from nautilus_trader.model.enums import time_in_force_to_str -from nautilus_trader.model.identifiers import AccountId -from nautilus_trader.model.identifiers import ClientId -from nautilus_trader.model.identifiers import ClientOrderId -from nautilus_trader.model.identifiers import InstrumentId -from nautilus_trader.model.identifiers import StrategyId -from nautilus_trader.model.identifiers import Symbol -from nautilus_trader.model.identifiers import TradeId -from nautilus_trader.model.identifiers import VenueOrderId -from nautilus_trader.model.instruments.base import Instrument -from nautilus_trader.model.objects import Money -from nautilus_trader.model.objects import Price -from nautilus_trader.model.objects import Quantity from nautilus_trader.model.orders.base import Order -from nautilus_trader.model.orders.limit import LimitOrder -from nautilus_trader.model.orders.market import MarketOrder -from nautilus_trader.model.orders.stop_limit import StopLimitOrder from nautilus_trader.msgbus.bus import MessageBus -class BinanceSpotExecutionClient(LiveExecutionClient): +class BinanceSpotExecutionClient(BinanceCommonExecutionClient): """ Provides an execution client for the `Binance Spot/Margin` exchange. @@ -117,7 +62,7 @@ class BinanceSpotExecutionClient(LiveExecutionClient): The clock for the client. logger : Logger The logger for the client. - instrument_provider : BinanceInstrumentProvider + instrument_provider : BinanceSpotInstrumentProvider The instrument provider. account_type : BinanceAccountType The account type for the client. @@ -144,370 +89,111 @@ def __init__( clock_sync_interval_secs: int = 900, warn_gtd_to_gtc: bool = True, ): + if not account_type.is_spot_or_margin: + raise RuntimeError( # pragma: no cover (design-time error) + f"`BinanceAccountType` not SPOT, MARGIN_CROSS or MARGIN_ISOLATED, was {account_type}", # pragma: no cover + ) + + # Spot HTTP API + self._spot_http_account = BinanceSpotAccountHttpAPI(client, clock, account_type) + self._spot_http_market = BinanceSpotMarketHttpAPI(client, account_type) + self._spot_http_user = BinanceSpotUserDataHttpAPI(client, account_type) + + # Spot enum parser + self._spot_enum_parser = BinanceSpotEnumParser() + + # Instantiate common base class super().__init__( loop=loop, - client_id=ClientId(BINANCE_VENUE.value), - venue=BINANCE_VENUE, - oms_type=OmsType.NETTING, - instrument_provider=instrument_provider, - account_type=AccountType.CASH, - base_currency=None, + client=client, + account=self._spot_http_account, + market=self._spot_http_market, + user=self._spot_http_user, + enum_parser=self._spot_enum_parser, msgbus=msgbus, cache=cache, clock=clock, logger=logger, - ) - - self._binance_account_type = account_type - self._log.info(f"Account type: {self._binance_account_type.value}.", LogColor.BLUE) - - self._set_account_id(AccountId(f"{BINANCE_VENUE.value}-spot-master")) - - # Settings - self._warn_gtd_to_gtc = warn_gtd_to_gtc - - # Clock sync - self._clock_sync_interval_secs = clock_sync_interval_secs - - # Tasks - self._task_clock_sync: Optional[asyncio.Task] = None - - # HTTP API - self._http_client = client - self._http_account = BinanceSpotAccountHttpAPI(client=client) - self._http_market = BinanceSpotMarketHttpAPI(client=client) - self._http_user = BinanceSpotUserDataHttpAPI(client=client, account_type=account_type) - - # Listen keys - self._ping_listen_keys_interval: int = 60 * 5 # Once every 5 mins (hardcode) - self._ping_listen_keys_task: Optional[asyncio.Task] = None - self._listen_key: Optional[str] = None + instrument_provider=instrument_provider, + account_type=account_type, + base_url_ws=base_url_ws, + clock_sync_interval_secs=clock_sync_interval_secs, + warn_gtd_to_gtc=warn_gtd_to_gtc, + ) + + # Register spot websocket user data event handlers + self._spot_user_ws_handlers = { + BinanceSpotEventType.outboundAccountPosition: self._handle_account_update, + BinanceSpotEventType.executionReport: self._handle_execution_report, + BinanceSpotEventType.listStatus: self._handle_list_status, + BinanceSpotEventType.balanceUpdate: self._handle_balance_update, + } - # WebSocket API - self._ws_client = BinanceWebSocketClient( - loop=loop, - clock=clock, - logger=logger, - handler=self._handle_user_ws_message, - base_url=base_url_ws, + # Websocket spot schema decoders + self._decoder_spot_user_msg_wrapper = msgspec.json.Decoder(BinanceSpotUserMsgWrapper) + self._decoder_spot_order_update_wrapper = msgspec.json.Decoder( + BinanceSpotOrderUpdateWrapper, + ) + self._decoder_spot_account_update_wrapper = msgspec.json.Decoder( + BinanceSpotAccountUpdateWrapper, ) - # Hot caches - self._instrument_ids: dict[str, InstrumentId] = {} - - self._log.info(f"Base URL HTTP {self._http_client.base_url}.", LogColor.BLUE) - self._log.info(f"Base URL WebSocket {base_url_ws}.", LogColor.BLUE) - - async def _connect(self) -> None: - # Connect HTTP client - if not self._http_client.connected: - await self._http_client.connect() - try: - await self._instrument_provider.initialize() - except BinanceError as e: - self._log.exception(f"Error on connect: {e.message}", e) - return - - # Authenticate API key and update account(s) - info: BinanceSpotAccountInfo = await self._http_account.account(recv_window=5000) - - self._authenticate_api_key(info=info) - self._update_account_state(info=info) - - # Get listen keys - response = await self._http_user.create_listen_key() - - self._listen_key = response["listenKey"] - self._log.info(f"Listen key {self._listen_key}") - self._ping_listen_keys_task = self.create_task(self._ping_listen_keys()) - - # Setup clock sync - if self._clock_sync_interval_secs > 0: - self._task_clock_sync = self.create_task(self._sync_clock_with_binance_server()) - - # Connect WebSocket client - self._ws_client.subscribe(key=self._listen_key) - await self._ws_client.connect() - - def _authenticate_api_key(self, info: BinanceSpotAccountInfo) -> None: - if info.canTrade: + async def _update_account_state(self) -> None: + account_info: BinanceSpotAccountInfo = ( + await self._spot_http_account.query_spot_account_info( + recv_window=str(5000), + ) + ) + if account_info.canTrade: self._log.info("Binance API key authenticated.", LogColor.GREEN) self._log.info(f"API key {self._http_client.api_key} has trading permissions.") else: self._log.error("Binance API key does not have trading permissions.") - - def _update_account_state(self, info: BinanceSpotAccountInfo) -> None: self.generate_account_state( - balances=parse_account_balances_http(raw_balances=info.balances), + balances=account_info.parse_to_account_balances(), margins=[], reported=True, - ts_event=millis_to_nanos(info.updateTime), + ts_event=millis_to_nanos(account_info.updateTime), ) - - async def _update_account_state_async(self) -> None: - info: BinanceSpotAccountInfo = await self._http_account.account(recv_window=5000) - self._update_account_state(info=info) - - async def _ping_listen_keys(self) -> None: - try: - while True: - self._log.debug( - f"Scheduled `ping_listen_keys` to run in " - f"{self._ping_listen_keys_interval}s.", - ) - await asyncio.sleep(self._ping_listen_keys_interval) - if self._listen_key: - self._log.debug(f"Pinging WebSocket listen key {self._listen_key}...") - await self._http_user.ping_listen_key(self._listen_key) - except asyncio.CancelledError: - self._log.debug("`ping_listen_keys` task was canceled.") - - async def _sync_clock_with_binance_server(self) -> None: - try: - while True: - # self._log.debug( - # f"Syncing Nautilus clock with Binance server...", - # ) - response: dict[str, int] = await self._http_market.time() - server_time: int = response["serverTime"] - self._log.info(f"Binance server time {server_time} UNIX (ms).") - - nautilus_time = self._clock.timestamp_ms() - self._log.info(f"Nautilus clock time {nautilus_time} UNIX (ms).") - - # offset_ns = millis_to_nanos(nautilus_time - server_time) - # self._log.info(f"Setting Nautilus clock offset {offset_ns} (ns).") - # self._clock.set_offset(offset_ns) - - await asyncio.sleep(self._clock_sync_interval_secs) - except asyncio.CancelledError: - self._log.debug("`sync_clock_with_binance_server` task was canceled.") - - async def _disconnect(self) -> None: - # Cancel tasks - if self._ping_listen_keys_task: - self._log.debug("Canceling `ping_listen_keys` task...") - self._ping_listen_keys_task.cancel() - self._ping_listen_keys_task.done() - - if self._task_clock_sync: - self._log.debug("Canceling `task_clock_sync` task...") - self._task_clock_sync.cancel() - self._task_clock_sync.done() - - # Disconnect WebSocket clients - if self._ws_client.is_connected: - await self._ws_client.disconnect() - - # Disconnect HTTP client - if self._http_client.connected: - await self._http_client.disconnect() + while self.get_account() is None: + await asyncio.sleep(0.1) # -- EXECUTION REPORTS ------------------------------------------------------------------------ - async def generate_order_status_report( - self, - instrument_id: InstrumentId, - client_order_id: Optional[ClientOrderId] = None, - venue_order_id: Optional[VenueOrderId] = None, - ) -> Optional[OrderStatusReport]: - PyCondition.false( - client_order_id is None and venue_order_id is None, - "both `client_order_id` and `venue_order_id` were `None`", - ) - - self._log.info( - f"Generating OrderStatusReport for " - f"{repr(client_order_id) if client_order_id else ''} " - f"{repr(venue_order_id) if venue_order_id else ''}...", - ) - - try: - if venue_order_id is not None: - response = await self._http_account.get_order( - symbol=instrument_id.symbol.value, - order_id=venue_order_id.value, - ) - else: - response = await self._http_account.get_order( - symbol=instrument_id.symbol.value, - orig_client_order_id=client_order_id.value, - ) - except BinanceError as e: - self._log.exception( - f"Cannot generate order status report for {venue_order_id}: {e.message}", - e, - ) - return None - - report: OrderStatusReport = parse_order_report_http( - account_id=self.account_id, - instrument_id=self._get_cached_instrument_id(response["symbol"]), - data=response, - report_id=UUID4(), - ts_init=self._clock.timestamp_ns(), - ) - - self._log.debug(f"Received {report}.") - return report - - async def generate_order_status_reports( # noqa (C901 too complex) - self, - instrument_id: InstrumentId = None, - start: Optional[pd.Timestamp] = None, - end: Optional[pd.Timestamp] = None, - open_only: bool = False, - ) -> list[OrderStatusReport]: - self._log.info(f"Generating OrderStatusReports for {self.id}...") - - open_orders = self._cache.orders_open(venue=self.venue) - active_symbols: set[str] = { - format_symbol(o.instrument_id.symbol.value) for o in open_orders - } - - order_msgs = [] - reports: dict[VenueOrderId, OrderStatusReport] = {} - - try: - open_order_msgs: list[dict[str, Any]] = await self._http_account.get_open_orders( - symbol=instrument_id.symbol.value if instrument_id is not None else None, - ) - if open_order_msgs: - order_msgs.extend(open_order_msgs) - # Add to active symbols - for o in open_order_msgs: - active_symbols.add(o["symbol"]) - - for symbol in active_symbols: - response = await self._http_account.get_orders( - symbol=symbol, - start_time=secs_to_millis(start.timestamp()) if start is not None else None, - end_time=secs_to_millis(end.timestamp()) if end is not None else None, - ) - order_msgs.extend(response) - except BinanceError as e: - self._log.exception(f"Cannot generate order status report: {e.message}", e) - return [] - - for msg in order_msgs: - # Apply filter (always report open orders regardless of start, end filter) - # TODO(cs): Time filter is WIP - # timestamp = pd.to_datetime(data["time"], utc=True) - # if data["status"] not in ("NEW", "PARTIALLY_FILLED", "PENDING_CANCEL"): - # if start is not None and timestamp < start: - # continue - # if end is not None and timestamp > end: - # continue - - report: OrderStatusReport = parse_order_report_http( - account_id=self.account_id, - instrument_id=self._get_cached_instrument_id(msg["symbol"]), - data=msg, - report_id=UUID4(), - ts_init=self._clock.timestamp_ns(), - ) - - self._log.debug(f"Received {report}.") - reports[report.venue_order_id] = report # One report per order - - len_reports = len(reports) - plural = "" if len_reports == 1 else "s" - self._log.info(f"Generated {len(reports)} OrderStatusReport{plural}.") - - return list(reports.values()) - - async def generate_trade_reports( # noqa (C901 too complex) + async def _get_binance_position_status_reports( self, - instrument_id: InstrumentId = None, - venue_order_id: VenueOrderId = None, - start: Optional[pd.Timestamp] = None, - end: Optional[pd.Timestamp] = None, - ) -> list[TradeReport]: - self._log.info(f"Generating TradeReports for {self.id}...") - - open_orders = self._cache.orders_open(venue=self.venue) - active_symbols: set[str] = { - format_symbol(o.instrument_id.symbol.value) for o in open_orders - } - - reports_raw: list[dict[str, Any]] = [] - reports: list[TradeReport] = [] - - try: - for symbol in active_symbols: - response = await self._http_account.get_account_trades( - symbol=symbol, - start_time=secs_to_millis(start.timestamp()) if start is not None else None, - end_time=secs_to_millis(end.timestamp()) if end is not None else None, - ) - reports_raw.extend(response) - except BinanceError as e: - self._log.exception(f"Cannot generate trade report: {e.message}", e) - return [] - - for data in reports_raw: - # Apply filter - # TODO(cs): Time filter is WIP - # timestamp = pd.to_datetime(data["time"], utc=True) - # if start is not None and timestamp < start: - # continue - # if end is not None and timestamp > end: - # continue - - report: TradeReport = parse_trade_report_http( - account_id=self.account_id, - instrument_id=self._get_cached_instrument_id(data["symbol"]), - data=data, - report_id=UUID4(), - ts_init=self._clock.timestamp_ns(), - ) - - self._log.debug(f"Received {report}.") - reports.append(report) - - # Sort in ascending order - reports = sorted(reports, key=lambda x: x.trade_id) - - len_reports = len(reports) - plural = "" if len_reports == 1 else "s" - self._log.info(f"Generated {len(reports)} TradeReport{plural}.") - - return reports - - async def generate_position_status_reports( - self, - instrument_id: InstrumentId = None, - start: Optional[pd.Timestamp] = None, - end: Optional[pd.Timestamp] = None, + symbol: str = None, ) -> list[PositionStatusReport]: # Never cash positions + return [] + async def _get_binance_active_position_symbols( + self, + symbol: str = None, + ) -> list[str]: + # Never cash positions return [] # -- COMMAND HANDLERS ------------------------------------------------------------------------- - async def _submit_order(self, command: SubmitOrder) -> None: - order: Order = command.order - + def _check_order_validity(self, order: Order): # Check order type valid - if order.order_type not in BINANCE_SPOT_VALID_ORDER_TYPES: + if order.order_type not in self._spot_enum_parser.spot_valid_order_types: self._log.error( f"Cannot submit order: {order_type_to_str(order.order_type)} " f"orders not supported by the Binance Spot/Margin exchange. " - f"Use any of {[order_type_to_str(t) for t in BINANCE_SPOT_VALID_ORDER_TYPES]}", + f"Use any of {[order_type_to_str(t) for t in self._spot_enum_parser.spot_valid_order_types]}", ) return - # Check time in force valid - if order.time_in_force not in BINANCE_SPOT_VALID_TIF: + if order.time_in_force not in self._spot_enum_parser.spot_valid_time_in_force: self._log.error( f"Cannot submit order: " f"{time_in_force_to_str(order.time_in_force)} " f"not supported by the Binance Spot/Margin exchange. " - f"Use any of {BINANCE_SPOT_VALID_TIF}.", + f"Use any of {[time_in_force_to_str(t) for t in self._spot_enum_parser.spot_valid_time_in_force]}.", ) return - # Check post-only if order.order_type == OrderType.STOP_LIMIT and order.is_post_only: self._log.error( @@ -517,321 +203,27 @@ async def _submit_order(self, command: SubmitOrder) -> None: ) return - self._log.debug(f"Submitting {order}.") - - # Generate event here to ensure correct ordering of events - self.generate_order_submitted( - strategy_id=order.strategy_id, - instrument_id=order.instrument_id, - client_order_id=order.client_order_id, - ts_event=self._clock.timestamp_ns(), - ) - - try: - if order.order_type == OrderType.MARKET: - await self._submit_market_order(order) - elif order.order_type == OrderType.LIMIT: - await self._submit_limit_order(order) - elif order.order_type in (OrderType.STOP_LIMIT, OrderType.LIMIT_IF_TOUCHED): - await self._submit_stop_limit_order(order) - except BinanceError as e: - self.generate_order_rejected( - strategy_id=order.strategy_id, - instrument_id=order.instrument_id, - client_order_id=order.client_order_id, - reason=e.message, - ts_event=self._clock.timestamp_ns(), - ) - - async def _submit_market_order(self, order: MarketOrder) -> None: - await self._http_account.new_order( - symbol=format_symbol(order.instrument_id.symbol.value), - side=order_side_to_str(order.side), - type="MARKET", - quantity=str(order.quantity), - new_client_order_id=order.client_order_id.value, - recv_window=5000, - ) - - async def _submit_limit_order(self, order: LimitOrder) -> None: - time_in_force_str: Optional[str] = self._convert_time_in_force_to_str(order.time_in_force) - if order.is_post_only: - time_in_force_str = None - - await self._http_account.new_order( - symbol=format_symbol(order.instrument_id.symbol.value), - side=order_side_to_str(order.side), - type=binance_order_type(order).value, - time_in_force=time_in_force_str, - quantity=str(order.quantity), - price=str(order.price), - iceberg_qty=str(order.display_qty) if order.display_qty is not None else None, - new_client_order_id=order.client_order_id.value, - recv_window=5000, - ) - - async def _submit_stop_limit_order(self, order: StopLimitOrder) -> None: - time_in_force_str: str = self._convert_time_in_force_to_str(order.time_in_force) - - await self._http_account.new_order( - symbol=format_symbol(order.instrument_id.symbol.value), - side=order_side_to_str(order.side), - type=binance_order_type(order).value, - time_in_force=time_in_force_str, - quantity=str(order.quantity), - price=str(order.price), - stop_price=str(order.trigger_price), - iceberg_qty=str(order.display_qty) if order.display_qty is not None else None, - new_client_order_id=order.client_order_id.value, - recv_window=5000, - ) - - async def _submit_order_list(self, command: SubmitOrderList) -> None: - for order in command.order_list: - self.generate_order_submitted( - strategy_id=order.strategy_id, - instrument_id=order.instrument_id, - client_order_id=order.client_order_id, - ts_event=self._clock.timestamp_ns(), - ) - - for order in command.order_list: - if order.linked_order_ids: # TODO(cs): Implement - self._log.warning(f"Cannot yet handle OCO conditional orders, {order}.") - await self._submit_order(order) - - async def _modify_order(self, command: ModifyOrder) -> None: - self._log.error( # pragma: no cover - "Cannot modify order: Not supported by the exchange.", # pragma: no cover - ) - - async def _cancel_order(self, command: CancelOrder) -> None: - self.generate_order_pending_cancel( - strategy_id=command.strategy_id, - instrument_id=command.instrument_id, - client_order_id=command.client_order_id, - venue_order_id=command.venue_order_id, - ts_event=self._clock.timestamp_ns(), - ) - - await self._cancel_order_single( - instrument_id=command.instrument_id, - client_order_id=command.client_order_id, - venue_order_id=command.venue_order_id, - ) - - async def _cancel_all_orders(self, command: CancelAllOrders) -> None: - open_orders_strategy = self._cache.orders_open( - instrument_id=command.instrument_id, - strategy_id=command.strategy_id, - ) - for order in open_orders_strategy: - if order.is_pending_cancel: - continue # Already pending cancel - self.generate_order_pending_cancel( - strategy_id=order.strategy_id, - instrument_id=order.instrument_id, - client_order_id=order.client_order_id, - venue_order_id=order.venue_order_id, - ts_event=self._clock.timestamp_ns(), - ) - - # Check total orders for instrument - open_orders_total_count = self._cache.orders_open_count( - instrument_id=command.instrument_id, - ) - - try: - if open_orders_total_count == len(open_orders_strategy): - await self._http_account.cancel_open_orders( - symbol=format_symbol(command.instrument_id.symbol.value), - ) - else: - for order in open_orders_strategy: - await self._cancel_order_single( - instrument_id=order.instrument_id, - client_order_id=order.client_order_id, - venue_order_id=order.venue_order_id, - ) - except BinanceError as e: - self._log.exception(f"Cannot cancel open orders: {e.message}", e) - - async def _cancel_order_single( - self, - instrument_id: InstrumentId, - client_order_id: ClientOrderId, - venue_order_id: Optional[VenueOrderId], - ) -> None: - try: - if venue_order_id is not None: - await self._http_account.cancel_order( - symbol=format_symbol(instrument_id.symbol.value), - order_id=venue_order_id.value, - ) - else: - await self._http_account.cancel_order( - symbol=format_symbol(instrument_id.symbol.value), - orig_client_order_id=client_order_id.value, - ) - except BinanceError as e: - self._log.exception( - f"Cannot cancel order " - f"{repr(client_order_id)}, " - f"{repr(venue_order_id)}: " - f"{e.message}", - e, - ) - - def _convert_time_in_force_to_str(self, time_in_force: TimeInForce): - time_in_force_str: str = time_in_force_to_str(time_in_force) - if time_in_force_str == TimeInForce.GTD.name: - if self._warn_gtd_to_gtc: - self._log.warning("Converting GTD `time_in_force` to GTC.") - time_in_force_str = TimeInForce.GTC.name - return time_in_force_str - - def _get_cached_instrument_id(self, symbol: str) -> InstrumentId: - # Parse instrument ID - nautilus_symbol: str = parse_symbol(symbol, account_type=self._binance_account_type) - instrument_id: Optional[InstrumentId] = self._instrument_ids.get(nautilus_symbol) - if not instrument_id: - instrument_id = InstrumentId(Symbol(nautilus_symbol), BINANCE_VENUE) - self._instrument_ids[nautilus_symbol] = instrument_id - return instrument_id + # -- WEBSOCKET EVENT HANDLERS -------------------------------------------------------------------- def _handle_user_ws_message(self, raw: bytes) -> None: # TODO(cs): Uncomment for development # self._log.info(str(json.dumps(msgspec.json.decode(raw), indent=4)), color=LogColor.MAGENTA) - - wrapper = msgspec.json.decode(raw, type=BinanceSpotUserMsgWrapper) - + wrapper = self._decoder_spot_user_msg_wrapper.decode(raw) try: - if wrapper.data.e == BinanceSpotEventType.outboundAccountPosition: - account_msg = msgspec.json.decode(raw, type=BinanceSpotAccountUpdateWrapper) - self._handle_account_update(account_msg.data) - elif wrapper.data.e == BinanceSpotEventType.executionReport: - order_msg = msgspec.json.decode(raw, type=BinanceSpotOrderUpdateWrapper) - self._handle_execution_report(order_msg.data) - elif wrapper.data.e == BinanceSpotEventType.listStatus: - pass # Implement (OCO order status) - elif wrapper.data.e == BinanceSpotEventType.balanceUpdate: - self.create_task(self._update_account_state_async()) + self._spot_user_ws_handlers[wrapper.data.e](raw) except Exception as e: self._log.exception(f"Error on handling {repr(raw)}", e) - def _handle_account_update(self, msg: BinanceSpotAccountUpdateMsg) -> None: - self.generate_account_state( - balances=parse_account_balances_ws(raw_balances=msg.B), - margins=[], - reported=True, - ts_event=millis_to_nanos(msg.u), - ) - - def _handle_execution_report(self, data: BinanceSpotOrderUpdateData) -> None: - instrument_id: InstrumentId = self._get_cached_instrument_id(data.s) - venue_order_id = VenueOrderId(str(data.i)) - ts_event = millis_to_nanos(data.T) + def _handle_account_update(self, raw: bytes) -> None: + account_msg = self._decoder_spot_account_update_wrapper.decode(raw) + account_msg.data.handle_account_update(self) - # Parse client order ID - client_order_id_str: str = data.c - if not client_order_id_str or not client_order_id_str.startswith("O"): - client_order_id_str = data.C - client_order_id = ClientOrderId(client_order_id_str) + def _handle_execution_report(self, raw: bytes) -> None: + order_msg = self._decoder_spot_order_update_wrapper.decode(raw) + order_msg.data.handle_execution_report(self) - # Fetch strategy ID - strategy_id: StrategyId = self._cache.strategy_id_for_order(client_order_id) - if strategy_id is None: - if strategy_id is None: - self._generate_external_order_report( - instrument_id, - client_order_id, - venue_order_id, - data, - ts_event, - ) - return - - if data.x == BinanceExecutionType.NEW: - self.generate_order_accepted( - strategy_id=strategy_id, - instrument_id=instrument_id, - client_order_id=client_order_id, - venue_order_id=venue_order_id, - ts_event=ts_event, - ) - elif data.x == BinanceExecutionType.TRADE: - instrument: Instrument = self._instrument_provider.find(instrument_id=instrument_id) - - # Determine commission - commission_asset: str = data.N - commission_amount: str = data.n - if commission_asset is not None: - commission = Money.from_str(f"{commission_amount} {commission_asset}") - else: - # Binance typically charges commission as base asset or BNB - commission = Money(0, instrument.base_currency) - - self.generate_order_filled( - strategy_id=strategy_id, - instrument_id=instrument_id, - client_order_id=client_order_id, - venue_order_id=venue_order_id, - venue_position_id=None, # NETTING accounts - trade_id=TradeId(str(data.t)), # Trade ID - order_side=order_side_from_str(data.S.value), - order_type=parse_order_type(data.o), - last_qty=Quantity.from_str(data.l), - last_px=Price.from_str(data.L), - quote_currency=instrument.quote_currency, - commission=commission, - liquidity_side=LiquiditySide.MAKER if data.m else LiquiditySide.TAKER, - ts_event=ts_event, - ) - elif data.x in (BinanceExecutionType.CANCELED, BinanceExecutionType.EXPIRED): - self.generate_order_canceled( - strategy_id=strategy_id, - instrument_id=instrument_id, - client_order_id=client_order_id, - venue_order_id=venue_order_id, - ts_event=ts_event, - ) - else: - self._log.warning(f"Received unhandled {data}") - - def _generate_external_order_report( - self, - instrument_id: InstrumentId, - client_order_id: ClientOrderId, - venue_order_id: VenueOrderId, - data: BinanceSpotOrderUpdateData, - ts_event: int, - ) -> None: - report = OrderStatusReport( - account_id=self.account_id, - instrument_id=instrument_id, - client_order_id=client_order_id, - venue_order_id=venue_order_id, - order_side=OrderSide.BUY if data.S == BinanceOrderSide.BUY else OrderSide.SELL, - order_type=parse_order_type(data.o), - time_in_force=parse_time_in_force(data.f.value), - order_status=OrderStatus.ACCEPTED, - price=Price.from_str(data.p) if data.p is not None else None, - trigger_price=Price.from_str(data.P) if data.P is not None else None, - trigger_type=TriggerType.LAST_TRADE, - trailing_offset=None, - trailing_offset_type=TrailingOffsetType.NO_TRAILING_OFFSET, - quantity=Quantity.from_str(data.q), - filled_qty=Quantity.from_str(data.z), - display_qty=Quantity.from_str(str(Decimal(data.q) - Decimal(data.F))) - if data.F is not None - else None, - avg_px=None, - post_only=data.f == BinanceFuturesTimeInForce.GTX, - reduce_only=False, - report_id=UUID4(), - ts_accepted=ts_event, - ts_last=ts_event, - ts_init=self._clock.timestamp_ns(), - ) + def _handle_list_status(self, raw: bytes) -> None: + self._log.warning("List status (OCO) received.") # Implement - self._send_order_status_report(report) + def _handle_balance_update(self, raw: bytes) -> None: + self.create_task(self._update_account_state_async()) diff --git a/nautilus_trader/adapters/binance/spot/http/account.py b/nautilus_trader/adapters/binance/spot/http/account.py index 165ae4c34986..b07507f80f99 100644 --- a/nautilus_trader/adapters/binance/spot/http/account.py +++ b/nautilus_trader/adapters/binance/spot/http/account.py @@ -17,879 +17,728 @@ import msgspec -from nautilus_trader.adapters.binance.common.functions import format_symbol +from nautilus_trader.adapters.binance.common.enums import BinanceAccountType +from nautilus_trader.adapters.binance.common.enums import BinanceMethodType +from nautilus_trader.adapters.binance.common.enums import BinanceNewOrderRespType +from nautilus_trader.adapters.binance.common.enums import BinanceOrderSide +from nautilus_trader.adapters.binance.common.enums import BinanceSecurityType +from nautilus_trader.adapters.binance.common.enums import BinanceTimeInForce +from nautilus_trader.adapters.binance.common.schemas.market import BinanceRateLimit +from nautilus_trader.adapters.binance.common.schemas.symbol import BinanceSymbol +from nautilus_trader.adapters.binance.http.account import BinanceAccountHttpAPI +from nautilus_trader.adapters.binance.http.account import BinanceOpenOrdersHttp from nautilus_trader.adapters.binance.http.client import BinanceHttpClient -from nautilus_trader.adapters.binance.http.enums import NewOrderRespType +from nautilus_trader.adapters.binance.http.endpoint import BinanceHttpEndpoint from nautilus_trader.adapters.binance.spot.schemas.account import BinanceSpotAccountInfo +from nautilus_trader.adapters.binance.spot.schemas.account import BinanceSpotOrderOco +from nautilus_trader.common.clock import LiveClock -class BinanceSpotAccountHttpAPI: +class BinanceSpotOpenOrdersHttp(BinanceOpenOrdersHttp): """ - Provides access to the `Binance Spot/Margin` Account/Trade HTTP REST API. + Endpoint of all SPOT/MARGIN open orders on a symbol. - Parameters - ---------- - client : BinanceHttpClient - The Binance REST API client. - """ + `GET /api/v3/openOrders` (inherited) - BASE_ENDPOINT = "/api/v3/" + `DELETE /api/v3/openOrders` - def __init__(self, client: BinanceHttpClient): - self.client = client + Warnings + -------- + Care should be taken when accessing this endpoint with no symbol specified. + The weight usage can be very large, which may cause rate limits to be hit. - # Decoders - self._decoder_account_info = msgspec.json.Decoder(BinanceSpotAccountInfo) + References + ---------- + https://binance-docs.github.io/apidocs/spot/en/#current-open-orders-user_data + https://binance-docs.github.io/apidocs/spot/en/#cancel-all-open-orders-on-a-symbol-trade - async def new_order_test( - self, - symbol: str, - side: str, - type: str, - time_in_force: Optional[str] = None, - quantity: Optional[str] = None, - quote_order_qty: Optional[str] = None, - price: Optional[str] = None, - new_client_order_id: Optional[str] = None, - stop_price: Optional[str] = None, - iceberg_qty: Optional[str] = None, - new_order_resp_type: NewOrderRespType = None, - recv_window: Optional[int] = None, - ) -> dict[str, Any]: - """ - Test new order creation and signature/recvWindow. + """ - Creates and validates a new order but does not send it into the matching engine. + def __init__( + self, + client: BinanceHttpClient, + base_endpoint: str, + ): + methods = { + BinanceMethodType.GET: BinanceSecurityType.USER_DATA, + BinanceMethodType.DELETE: BinanceSecurityType.TRADE, + } + super().__init__( + client, + base_endpoint, + methods, + ) + self._delete_resp_decoder = msgspec.json.Decoder() - Test New Order (TRADE). - `POST /api/v3/order/test`. + class DeleteParameters(msgspec.Struct, omit_defaults=True, frozen=True): + """ + Parameters of openOrders SPOT/MARGIN DELETE request. + Includes OCO orders. Parameters ---------- - symbol : str - The symbol for the request. - side : str - The order side for the request. - type : str - The order type for the request. - time_in_force : str, optional - The order time in force for the request. - quantity : str, optional - The order quantity in base asset units for the request. - quote_order_qty : str, optional - The order quantity in quote asset units for the request. - price : str, optional - The order price for the request. - new_client_order_id : str, optional - The client order ID for the request. A unique ID among open orders. - Automatically generated if not provided. - stop_price : str, optional - The order stop price for the request. - Used with STOP_LOSS, STOP_LOSS_LIMIT, TAKE_PROFIT, and TAKE_PROFIT_LIMIT orders. - iceberg_qty : str, optional - The order iceberg (display) quantity for the request. - Used with LIMIT, STOP_LOSS_LIMIT, and TAKE_PROFIT_LIMIT to create an iceberg order. - new_order_resp_type : NewOrderRespType, optional - The response type for the order request. - MARKET and LIMIT order types default to FULL, all other orders default to ACK. - recv_window : int, optional + timestamp : str + The millisecond timestamp of the request + symbol : BinanceSymbol + The symbol of the orders + recvWindow : str, optional The response receive window for the request (cannot be greater than 60000). - Returns - ------- - dict[str, Any] - - References - ---------- - https://binance-docs.github.io/apidocs/spot/en/#test-new-order-trade - """ - payload: dict[str, str] = { - "symbol": format_symbol(symbol), - "side": side, - "type": type, - } - if time_in_force is not None: - payload["timeInForce"] = time_in_force - if quantity is not None: - payload["quantity"] = quantity - if quote_order_qty is not None: - payload["quoteOrderQty"] = quote_order_qty - if price is not None: - payload["price"] = price - if new_client_order_id is not None: - payload["newClientOrderId"] = new_client_order_id - if stop_price is not None: - payload["stopPrice"] = stop_price - if iceberg_qty is not None: - payload["icebergQty"] = iceberg_qty - if new_order_resp_type is not None: - payload["newOrderRespType"] = new_order_resp_type.value - if recv_window is not None: - payload["recvWindow"] = str(recv_window) - - raw: bytes = await self.client.sign_request( - http_method="POST", - url_path=self.BASE_ENDPOINT + "order/test", - payload=payload, - ) - return msgspec.json.decode(raw) + timestamp: str + symbol: BinanceSymbol + recvWindow: Optional[str] = None - async def new_order( - self, - symbol: str, - side: str, - type: str, - time_in_force: Optional[str] = None, - quantity: Optional[str] = None, - quote_order_qty: Optional[str] = None, - price: Optional[str] = None, - new_client_order_id: Optional[str] = None, - stop_price: Optional[str] = None, - iceberg_qty: Optional[str] = None, - new_order_resp_type: NewOrderRespType = None, - recv_window: Optional[int] = None, - ) -> dict[str, Any]: - """ - Submit a new order. + async def _delete(self, parameters: DeleteParameters) -> list[dict[str, Any]]: + method_type = BinanceMethodType.DELETE + raw = await self._method(method_type, parameters) + return self._delete_resp_decoder.decode(raw) - Submit New Order (TRADE). - `POST /api/v3/order`. - Parameters - ---------- - symbol : str - The symbol for the request. - side : str - The order side for the request. - type : str - The order type for the request. - time_in_force : str, optional - The order time in force for the request. - quantity : str, optional - The order quantity in base asset units for the request. - quote_order_qty : str, optional - The order quantity in quote asset units for the request. - price : str, optional - The order price for the request. - new_client_order_id : str, optional - The client order ID for the request. A unique ID among open orders. - Automatically generated if not provided. - stop_price : str, optional - The order stop price for the request. - Used with STOP_LOSS, STOP_LOSS_LIMIT, TAKE_PROFIT, and TAKE_PROFIT_LIMIT orders. - iceberg_qty : str, optional - The order iceberg (display) quantity for the request. - Used with LIMIT, STOP_LOSS_LIMIT, and TAKE_PROFIT_LIMIT to create an iceberg order. - new_order_resp_type : NewOrderRespType, optional - The response type for the order request. - MARKET and LIMIT order types default to FULL, all other orders default to ACK. - recv_window : int, optional - The response receive window for the request (cannot be greater than 60000). +class BinanceSpotOrderOcoHttp(BinanceHttpEndpoint): + """ + Endpoint for creating SPOT/MARGIN OCO orders - Returns - ------- - dict[str, Any] + `POST /api/v3/order/oco` - References - ---------- - https://binance-docs.github.io/apidocs/spot/en/#new-order-trade + References + ---------- + https://binance-docs.github.io/apidocs/spot/en/#new-oco-trade - """ - payload: dict[str, str] = { - "symbol": format_symbol(symbol), - "side": side, - "type": type, + """ + + def __init__( + self, + client: BinanceHttpClient, + base_endpoint: str, + ): + methods = { + BinanceMethodType.POST: BinanceSecurityType.TRADE, } - if time_in_force is not None: - payload["timeInForce"] = time_in_force - if quantity is not None: - payload["quantity"] = quantity - if quote_order_qty is not None: - payload["quoteOrderQty"] = quote_order_qty - if price is not None: - payload["price"] = price - if new_client_order_id is not None: - payload["newClientOrderId"] = new_client_order_id - if stop_price is not None: - payload["stopPrice"] = stop_price - if iceberg_qty is not None: - payload["icebergQty"] = iceberg_qty - if new_order_resp_type is not None: - payload["newOrderRespType"] = new_order_resp_type.value - if recv_window is not None: - payload["recvWindow"] = str(recv_window) - - raw: bytes = await self.client.sign_request( - http_method="POST", - url_path=self.BASE_ENDPOINT + "order", - payload=payload, + url_path = base_endpoint + "order/oco" + super().__init__( + client, + methods, + url_path, ) + self._resp_decoder = msgspec.json.Decoder(BinanceSpotOrderOco) - return msgspec.json.decode(raw) - - async def cancel_order( - self, - symbol: str, - order_id: Optional[str] = None, - orig_client_order_id: Optional[str] = None, - new_client_order_id: Optional[str] = None, - recv_window: Optional[int] = None, - ) -> dict[str, Any]: + class PostParameters(msgspec.Struct, omit_defaults=True, frozen=True): """ - Cancel an open order. - - Cancel Order (TRADE). - `DELETE /api/v3/order`. + OCO order creation POST endpoint parameters Parameters ---------- - symbol : str - The symbol for the request. - order_id : str, optional - The order ID to cancel. - orig_client_order_id : str, optional - The original client order ID to cancel. - new_client_order_id : str, optional - The new client order ID to uniquely identify this request. - recv_window : int, optional - The response receive window for the request (cannot be greater than 60000). + symbol : BinanceSymbol + The symbol of the order + timestamp : str + The millisecond timestamp of the request + side : BinanceOrderSide + The market side of the order (BUY, SELL) + quantity : str + The order quantity in base asset units for the request + price : str + The order price for the request. + stopPrice : str + The order stop price for the request. + listClientOrderId : str, optional + A unique Id for the entire orderList + limitClientOrderId : str, optional + The client order ID for the limit request. A unique ID among open orders. + Automatically generated if not provided. + limitStrategyId : int, optional + The client strategy ID for the limit request. + limitStrategyType : int, optional + The client strategy type for the limit request. Cannot be less than 1000000 + limitIcebergQty : str, optional + Create a limit iceberg order. + trailingDelta : str, optional + Can be used in addition to stopPrice + The order trailing delta of the request. + stopClientOrderId : str, optional + The client order ID for the stop request. A unique ID among open orders. + Automatically generated if not provided. + stopStrategyId : int, optional + The client strategy ID for the stop request. + stopStrategyType : int, optional + The client strategy type for the stop request. Cannot be less than 1000000 + stopLimitPrice : str, optional + Limit price for the stop order request. + If provided, stopLimitTimeInForce is required. + stopIcebergQty : str, optional + Create a stop iceberg order. + stopLimitTimeInForce : BinanceTimeInForce, optional + The time in force of the stop limit order. + Valid values: (GTC, FOK, IOC) + newOrderRespType : BinanceNewOrderRespType, optional + The response type for the order request. + recvWindow : str, optional + The response receive window in milliseconds for the request. + Cannot exceed 60000. - Returns - ------- - dict[str, Any] + """ - References - ---------- - https://binance-docs.github.io/apidocs/spot/en/#cancel-order-trade + symbol: BinanceSymbol + timestamp: str + side: BinanceOrderSide + quantity: str + price: str + stopPrice: str + listClientOrderId: Optional[str] = None + limitClientOrderId: Optional[str] = None + limitStrategyId: Optional[int] = None + limitStrategyType: Optional[int] = None + limitIcebergQty: Optional[str] = None + trailingDelta: Optional[str] = None + stopClientOrderId: Optional[str] = None + stopStrategyId: Optional[int] = None + stopStrategyType: Optional[int] = None + stopLimitPrice: Optional[str] = None + stopIcebergQty: Optional[str] = None + stopLimitTimeInForce: Optional[BinanceTimeInForce] = None + newOrderRespType: Optional[BinanceNewOrderRespType] = None + recvWindow: Optional[str] = None + + async def _post(self, parameters: PostParameters) -> BinanceSpotOrderOco: + method_type = BinanceMethodType.POST + raw = await self._method(method_type, parameters) + return self._resp_decoder.decode(raw) + + +class BinanceSpotOrderListHttp(BinanceHttpEndpoint): + """ + Endpoint for querying and deleting SPOT/MARGIN OCO orders - """ - payload: dict[str, str] = {"symbol": format_symbol(symbol)} - if order_id is not None: - payload["orderId"] = str(order_id) - if orig_client_order_id is not None: - payload["origClientOrderId"] = str(orig_client_order_id) - if new_client_order_id is not None: - payload["newClientOrderId"] = str(new_client_order_id) - if recv_window is not None: - payload["recvWindow"] = str(recv_window) - - raw: bytes = await self.client.sign_request( - http_method="DELETE", - url_path=self.BASE_ENDPOINT + "order", - payload=payload, - ) + `GET /api/v3/orderList` + `DELETE /api/v3/orderList` - return msgspec.json.decode(raw) + References + ---------- + https://binance-docs.github.io/apidocs/spot/en/#query-oco-user_data + https://binance-docs.github.io/apidocs/spot/en/#cancel-oco-trade - async def cancel_open_orders( + """ + + def __init__( self, - symbol: str, - recv_window: Optional[int] = None, - ) -> dict[str, Any]: - """ - Cancel all open orders for a symbol. This includes OCO orders. + client: BinanceHttpClient, + base_endpoint: str, + ): + methods = { + BinanceMethodType.GET: BinanceSecurityType.USER_DATA, + BinanceMethodType.DELETE: BinanceSecurityType.TRADE, + } + url_path = base_endpoint + "orderList" + super().__init__( + client, + methods, + url_path, + ) + self._resp_decoder = msgspec.json.Decoder(BinanceSpotOrderOco) - Cancel all Open Orders for a Symbol (TRADE). - `DELETE api/v3/openOrders`. + class GetParameters(msgspec.Struct, omit_defaults=True, frozen=True): + """ + orderList (OCO) GET endpoint parameters. Parameters ---------- - symbol : str - The symbol for the request. - recv_window : int, optional - The response receive window for the request (cannot be greater than 60000). - - Returns - ------- - dict[str, Any] - - References - ---------- - https://binance-docs.github.io/apidocs/spot/en/#cancel-all-open-orders-on-a-symbol-trade + timestamp : str + The millisecond timestamp of the request + orderListId : str, optional + The unique identifier of the order list to retrieve + origClientOrderId : str, optional + The client specified identifier of the order list to retrieve + recvWindow : str, optional + The response receive window in milliseconds for the request. + Cannot exceed 60000. + + NOTE: Either orderListId or origClientOrderId must be provided """ - payload: dict[str, str] = {"symbol": format_symbol(symbol)} - if recv_window is not None: - payload["recvWindow"] = str(recv_window) - - raw: bytes = await self.client.sign_request( - http_method="DELETE", - url_path=self.BASE_ENDPOINT + "openOrders", - payload=payload, - ) - return msgspec.json.decode(raw) + timestamp: str + orderListId: Optional[str] = None + origClientOrderId: Optional[str] = None + recvWindow: Optional[str] = None - async def get_order( - self, - symbol: str, - order_id: Optional[str] = None, - orig_client_order_id: Optional[str] = None, - recv_window: Optional[int] = None, - ) -> dict[str, Any]: + class DeleteParameters(msgspec.Struct, omit_defaults=True, frozen=True): """ - Check an order's status. - - Query Order (USER_DATA). - `GET /api/v3/order`. + orderList (OCO) DELETE endpoint parameters. Parameters ---------- - symbol : str - The symbol for the request. - order_id : str, optional - The order ID for the request. - orig_client_order_id : str, optional - The original client order ID for the request. - recv_window : int, optional - The response receive window for the request (cannot be greater than 60000). - - Returns - ------- - dict[str, Any] - - References - ---------- - https://binance-docs.github.io/apidocs/spot/en/#query-order-user_data + timestamp : str + The millisecond timestamp of the request + symbol : BinanceSymbol + The symbol of the order + orderListId : str, optional + The unique identifier of the order list to retrieve + listClientOrderId : str, optional + The client specified identifier of the order list to retrieve + newClientOrderId : str, optional + Used to uniquely identify this cancel. Automatically generated + by default. + recvWindow : str, optional + The response receive window in milliseconds for the request. + Cannot exceed 60000. + + NOTE: Either orderListId or listClientOrderId must be provided """ - payload: dict[str, str] = {"symbol": format_symbol(symbol)} - if order_id is not None: - payload["orderId"] = order_id - if orig_client_order_id is not None: - payload["origClientOrderId"] = orig_client_order_id - if recv_window is not None: - payload["recvWindow"] = str(recv_window) - - raw: bytes = await self.client.sign_request( - http_method="GET", - url_path=self.BASE_ENDPOINT + "order", - payload=payload, - ) - return msgspec.json.decode(raw) + timestamp: str + symbol: BinanceSymbol + orderListId: Optional[str] = None + listClientOrderId: Optional[str] = None + newClientOrderId: Optional[str] = None + recvWindow: Optional[str] = None - async def get_open_orders( - self, - symbol: Optional[str] = None, - recv_window: Optional[int] = None, - ) -> list[dict[str, Any]]: - """ - Get all open orders for a symbol. + async def _get(self, parameters: GetParameters) -> BinanceSpotOrderOco: + method_type = BinanceMethodType.GET + raw = await self._method(method_type, parameters) + return self._resp_decoder.decode(raw) - Query Current Open Orders (USER_DATA). + async def _delete(self, parameters: DeleteParameters) -> BinanceSpotOrderOco: + method_type = BinanceMethodType.DELETE + raw = await self._method(method_type, parameters) + return self._resp_decoder.decode(raw) - Parameters - ---------- - symbol : str, optional - The symbol for the request. - recv_window : int, optional - The response receive window for the request (cannot be greater than 60000). - Returns - ------- - dict[str, Any] +class BinanceSpotAllOrderListHttp(BinanceHttpEndpoint): + """ + Endpoint for querying all SPOT/MARGIN OCO orders - References - ---------- - https://binance-docs.github.io/apidocs/spot/en/#current-open-orders-user_data - https://binance-docs.github.io/apidocs/futures/en/#current-open-orders-user_data + `GET /api/v3/allOrderList` - """ - payload: dict[str, str] = {} - if symbol is not None: - payload["symbol"] = format_symbol(symbol) - if recv_window is not None: - payload["recvWindow"] = str(recv_window) - - raw: bytes = await self.client.sign_request( - http_method="GET", - url_path=self.BASE_ENDPOINT + "openOrders", - payload=payload, - ) + References + ---------- + https://binance-docs.github.io/apidocs/spot/en/#query-all-oco-user_data - return msgspec.json.decode(raw) + """ - async def get_orders( + def __init__( self, - symbol: str, - order_id: Optional[str] = None, - start_time: Optional[int] = None, - end_time: Optional[int] = None, - limit: Optional[int] = None, - recv_window: Optional[int] = None, - ) -> list[dict[str, Any]]: - """ - Get all account orders (open, or closed). + client: BinanceHttpClient, + base_endpoint: str, + ): + methods = { + BinanceMethodType.GET: BinanceSecurityType.USER_DATA, + } + url_path = base_endpoint + "allOrderList" + super().__init__( + client, + methods, + url_path, + ) + self._resp_decoder = msgspec.json.Decoder(list[BinanceSpotOrderOco]) - All Orders (USER_DATA). + class GetParameters(msgspec.Struct, omit_defaults=True, frozen=True): + """ + Parameters of allOrderList GET request Parameters ---------- - symbol : str - The symbol for the request. - order_id : str, optional + timestamp : str + The millisecond timestamp of the request + fromId : str, optional The order ID for the request. - start_time : int, optional + If included, request will return orders from this orderId INCLUSIVE + startTime : str, optional The start time (UNIX milliseconds) filter for the request. - end_time : int, optional + endTime : str, optional The end time (UNIX milliseconds) filter for the request. limit : int, optional The limit for the response. - recv_window : int, optional + Default 500, max 1000 + recvWindow : str, optional The response receive window for the request (cannot be greater than 60000). - Returns - ------- - list[dict[str, Any]] - - References - ---------- - https://binance-docs.github.io/apidocs/spot/en/#all-orders-user_data - https://binance-docs.github.io/apidocs/futures/en/#all-orders-user_data + NOTE: If fromId is specified, neither startTime endTime can be provided. """ - payload: dict[str, str] = {"symbol": format_symbol(symbol)} - if order_id is not None: - payload["orderId"] = order_id - if start_time is not None: - payload["startTime"] = str(start_time) - if end_time is not None: - payload["endTime"] = str(end_time) - if limit is not None: - payload["limit"] = str(limit) - if recv_window is not None: - payload["recvWindow"] = str(recv_window) - - raw: bytes = await self.client.sign_request( - http_method="GET", - url_path=self.BASE_ENDPOINT + "allOrders", - payload=payload, - ) - return msgspec.json.decode(raw) + timestamp: str + fromId: Optional[str] = None + startTime: Optional[str] = None + endTime: Optional[str] = None + limit: Optional[int] = None + recvWindow: Optional[str] = None - async def new_oco_order( - self, - symbol: str, - side: str, - quantity: str, - price: str, - stop_price: str, - list_client_order_id: Optional[str] = None, - limit_client_order_id: Optional[str] = None, - limit_iceberg_qty: Optional[str] = None, - stop_client_order_id: Optional[str] = None, - stop_limit_price: Optional[str] = None, - stop_iceberg_qty: Optional[str] = None, - stop_limit_time_in_force: Optional[str] = None, - new_order_resp_type: NewOrderRespType = None, - recv_window: Optional[int] = None, - ) -> dict[str, Any]: - """ - Submit a new OCO order. + async def _get(self, parameters: GetParameters) -> list[BinanceSpotOrderOco]: + method_type = BinanceMethodType.GET + raw = await self._method(method_type, parameters) + return self._resp_decoder.decode(raw) - Submit New OCO (TRADE). - `POST /api/v3/order/oco`. - Parameters - ---------- - symbol : str - The symbol for the request. - side : str - The order side for the request. - quantity : str - The order quantity for the request. - price : str - The order price for the request. - stop_price : str - The order stop price for the request. - list_client_order_id : str, optional - The list client order ID for the request. - limit_client_order_id : str, optional - The LIMIT client order ID for the request. - limit_iceberg_qty : str, optional - The LIMIT order display quantity for the request. - stop_client_order_id : str, optional - The STOP order client order ID for the request. - stop_limit_price : str, optional - The STOP_LIMIT price for the request. - stop_iceberg_qty : str, optional - The STOP order display quantity for the request. - stop_limit_time_in_force : str, optional - The STOP_LIMIT time_in_force for the request. - new_order_resp_type : NewOrderRespType, optional - The response type for the order request. - MARKET and LIMIT order types default to FULL, all other orders default to ACK. - recv_window : int, optional - The response receive window for the request (cannot be greater than 60000). +class BinanceSpotOpenOrderListHttp(BinanceHttpEndpoint): + """ + Endpoint for querying all SPOT/MARGIN OPEN OCO orders - Returns - ------- - dict[str, Any] + `GET /api/v3/openOrderList` - References - ---------- - https://binance-docs.github.io/apidocs/spot/en/#new-oco-trade + References + ---------- + https://binance-docs.github.io/apidocs/spot/en/#query-open-oco-user_data - """ - payload: dict[str, str] = { - "symbol": format_symbol(symbol), - "side": side, - "quantity": quantity, - "price": price, - "stopPrice": stop_price, + """ + + def __init__( + self, + client: BinanceHttpClient, + base_endpoint: str, + ): + methods = { + BinanceMethodType.GET: BinanceSecurityType.USER_DATA, } - if list_client_order_id is not None: - payload["listClientOrderId"] = list_client_order_id - if limit_client_order_id is not None: - payload["limitClientOrderId"] = limit_client_order_id - if limit_iceberg_qty is not None: - payload["limitIcebergQty"] = limit_iceberg_qty - if stop_client_order_id is not None: - payload["stopClientOrderId"] = stop_client_order_id - if stop_limit_price is not None: - payload["stopLimitPrice"] = stop_limit_price - if stop_iceberg_qty is not None: - payload["stopIcebergQty"] = stop_iceberg_qty - if stop_limit_time_in_force is not None: - payload["stopLimitTimeInForce"] = stop_limit_time_in_force - if new_order_resp_type is not None: - payload["new_order_resp_type"] = new_order_resp_type.value - if recv_window is not None: - payload["recvWindow"] = str(recv_window) - - raw: bytes = await self.client.sign_request( - http_method="POST", - url_path=self.BASE_ENDPOINT + "order/oco", - payload=payload, + url_path = base_endpoint + "openOrderList" + super().__init__( + client, + methods, + url_path, ) + self._resp_decoder = msgspec.json.Decoder(list[BinanceSpotOrderOco]) - return msgspec.json.decode(raw) - - async def cancel_oco_order( - self, - symbol: str, - order_list_id: Optional[str] = None, - list_client_order_id: Optional[str] = None, - new_client_order_id: Optional[str] = None, - recv_window: Optional[int] = None, - ) -> dict[str, Any]: + class GetParameters(msgspec.Struct, omit_defaults=True, frozen=True): """ - Cancel an entire Order List. - - Either `order_list_id` or `list_client_order_id` must be provided. - - Cancel OCO (TRADE). - `DELETE /api/v3/orderList`. + Parameters of allOrderList GET request Parameters ---------- - symbol : str - The symbol for the request. - order_list_id : str, optional - The order list ID for the request. - list_client_order_id : str, optional - The list client order ID for the request. - new_client_order_id : str, optional - The new client order ID to uniquely identify this request. - recv_window : int, optional + timestamp : str + The millisecond timestamp of the request + recvWindow : str, optional The response receive window for the request (cannot be greater than 60000). - Returns - ------- - dict[str, Any] - - References - ---------- - https://binance-docs.github.io/apidocs/spot/en/#cancel-oco-trade - """ - payload: dict[str, str] = {"symbol": format_symbol(symbol)} - if order_list_id is not None: - payload["orderListId"] = order_list_id - if list_client_order_id is not None: - payload["listClientOrderId"] = list_client_order_id - if new_client_order_id is not None: - payload["newClientOrderId"] = new_client_order_id - if recv_window is not None: - payload["recvWindow"] = str(recv_window) - - raw: bytes = await self.client.sign_request( - http_method="DELETE", - url_path=self.BASE_ENDPOINT + "orderList", - payload=payload, - ) - return msgspec.json.decode(raw) + timestamp: str + recvWindow: Optional[str] = None - async def get_oco_order( - self, - order_list_id: Optional[str], - orig_client_order_id: Optional[str], - recv_window: Optional[int] = None, - ) -> dict[str, Any]: - """ - Retrieve a specific OCO based on provided optional parameters. + async def _get(self, parameters: GetParameters) -> list[BinanceSpotOrderOco]: + method_type = BinanceMethodType.GET + raw = await self._method(method_type, parameters) + return self._resp_decoder.decode(raw) - Either `order_list_id` or `orig_client_order_id` must be provided. - Query OCO (USER_DATA). - `GET /api/v3/orderList`. +class BinanceSpotAccountHttp(BinanceHttpEndpoint): + """ + Endpoint of current SPOT/MARGIN account information - Parameters - ---------- - order_list_id : str, optional - The order list ID for the request. - orig_client_order_id : str, optional - The original client order ID for the request. - recv_window : int, optional - The response receive window for the request (cannot be greater than 60000). + `GET /api/v3/account` - Returns - ------- - dict[str, Any] + References + ---------- + https://binance-docs.github.io/apidocs/spot/en/#account-information-user_data - References - ---------- - https://binance-docs.github.io/apidocs/spot/en/#query-oco-user_data + """ - """ - payload: dict[str, str] = {} - if order_list_id is not None: - payload["orderListId"] = order_list_id - if orig_client_order_id is not None: - payload["origClientOrderId"] = orig_client_order_id - if recv_window is not None: - payload["recvWindow"] = str(recv_window) - - raw: bytes = await self.client.sign_request( - http_method="GET", - url_path=self.BASE_ENDPOINT + "orderList", - payload=payload, + def __init__( + self, + client: BinanceHttpClient, + base_endpoint: str, + ): + methods = { + BinanceMethodType.GET: BinanceSecurityType.USER_DATA, + } + url_path = base_endpoint + "account" + super().__init__( + client, + methods, + url_path, ) + self._resp_decoder = msgspec.json.Decoder(BinanceSpotAccountInfo) - return msgspec.json.decode(raw) - - async def get_oco_orders( - self, - from_id: Optional[str] = None, - start_time: Optional[int] = None, - end_time: Optional[int] = None, - limit: Optional[int] = None, - recv_window: Optional[int] = None, - ) -> dict[str, Any]: + class GetParameters(msgspec.Struct, omit_defaults=True, frozen=True): """ - Retrieve all OCO based on provided optional parameters. - - If `from_id` is provided then neither `start_time` nor `end_time` can be - provided. - - Query all OCO (USER_DATA). - `GET /api/v3/allOrderList`. + Parameters of account GET request Parameters ---------- - from_id : int, optional - The order ID filter for the request. - start_time : int, optional - The start time (UNIX milliseconds) filter for the request. - end_time : int, optional - The end time (UNIX milliseconds) filter for the request. - limit : int, optional - The limit for the response. - recv_window : int, optional + timestamp : str + The millisecond timestamp of the request + recvWindow : str, optional The response receive window for the request (cannot be greater than 60000). - Returns - ------- - dict[str, Any] - - References - ---------- - https://binance-docs.github.io/apidocs/spot/en/#query-all-oco-user_data - """ - payload: dict[str, str] = {} - if from_id is not None: - payload["fromId"] = from_id - if start_time is not None: - payload["startTime"] = str(start_time) - if end_time is not None: - payload["endTime"] = str(end_time) - if limit is not None: - payload["limit"] = str(limit) - if recv_window is not None: - payload["recvWindow"] = str(recv_window) - - raw: bytes = await self.client.sign_request( - http_method="GET", - url_path=self.BASE_ENDPOINT + "allOrderList", - payload=payload, - ) - return msgspec.json.decode(raw) + timestamp: str + recvWindow: Optional[str] = None - async def get_oco_open_orders(self, recv_window: Optional[int] = None) -> dict[str, Any]: - """ - Get all open OCO orders. + async def _get(self, parameters: GetParameters) -> BinanceSpotAccountInfo: + method_type = BinanceMethodType.GET + raw = await self._method(method_type, parameters) + return self._resp_decoder.decode(raw) - Query Open OCO (USER_DATA). - GET /api/v3/openOrderList. - Parameters - ---------- - recv_window : int, optional - The response receive window for the request (cannot be greater than 60000). +class BinanceSpotOrderRateLimitHttp(BinanceHttpEndpoint): + """ + Endpoint of current SPOT/MARGIN order count usage for all intervals. - Returns - ------- - dict[str, Any] + `GET /api/v3/rateLimit/order` - References - ---------- - https://binance-docs.github.io/apidocs/spot/en/#query-open-oco-user_data + References + ---------- + https://binance-docs.github.io/apidocs/spot/en/#query-current-order-count-usage-trade - """ - payload: dict[str, str] = {} - if recv_window is not None: - payload["recvWindow"] = str(recv_window) - - raw: bytes = await self.client.sign_request( - http_method="GET", - url_path=self.BASE_ENDPOINT + "openOrderList", - payload=payload, - ) + """ - return msgspec.json.decode(raw) + def __init__( + self, + client: BinanceHttpClient, + base_endpoint: str, + ): + methods = { + BinanceMethodType.GET: BinanceSecurityType.TRADE, + } + url_path = base_endpoint + "rateLimit/order" + super().__init__( + client, + methods, + url_path, + ) + self._resp_decoder = msgspec.json.Decoder(list[BinanceRateLimit]) - async def account(self, recv_window: Optional[int] = None) -> BinanceSpotAccountInfo: + class GetParameters(msgspec.Struct, omit_defaults=True, frozen=True): """ - Get current account information. - - Account Information (USER_DATA). - `GET /api/v3/account`. + Parameters of rateLimit/order GET request Parameters ---------- - recv_window : int, optional + timestamp : str + The millisecond timestamp of the request + recvWindow : str, optional The response receive window for the request (cannot be greater than 60000). - Returns - ------- - BinanceSpotAccountInfo - - References - ---------- - https://binance-docs.github.io/apidocs/spot/en/#account-information-user_data - """ - payload: dict[str, str] = {} - if recv_window is not None: - payload["recvWindow"] = str(recv_window) - - raw: bytes = await self.client.sign_request( - http_method="GET", - url_path=self.BASE_ENDPOINT + "account", - payload=payload, - ) - return self._decoder_account_info.decode(raw) + timestamp: str + recvWindow: Optional[str] = None - async def get_account_trades( - self, - symbol: str, - from_id: Optional[str] = None, - order_id: Optional[str] = None, - start_time: Optional[int] = None, - end_time: Optional[int] = None, - limit: Optional[int] = None, - recv_window: Optional[int] = None, - ) -> list[dict[str, Any]]: - """ - Get trades for a specific account and symbol. + async def _get(self, parameters: GetParameters) -> list[BinanceRateLimit]: + method_type = BinanceMethodType.GET + raw = await self._method(method_type, parameters) + return self._resp_decoder.decode(raw) - Account Trade List (USER_DATA) - Parameters - ---------- - symbol : str - The symbol for the request. - from_id : str, optional - The trade match ID to query from. - order_id : str, optional - The order ID for the trades. This can only be used in combination with symbol. - start_time : int, optional - The start time (UNIX milliseconds) filter for the request. - end_time : int, optional - The end time (UNIX milliseconds) filter for the request. - limit : int, optional - The limit for the response. - recv_window : int, optional - The response receive window for the request (cannot be greater than 60000). +class BinanceSpotAccountHttpAPI(BinanceAccountHttpAPI): + """ + Provides access to the `Binance Spot/Margin` Account/Trade HTTP REST API. - Returns - ------- - list[dict[str, Any]] + Parameters + ---------- + client : BinanceHttpClient + The Binance REST API client. + clock : LiveClock, + The clock for the API client. + account_type : BinanceAccountType + The Binance account type, used to select the endpoint prefix - References - ---------- - https://binance-docs.github.io/apidocs/spot/en/#account-trade-list-user_data + """ - """ - payload: dict[str, str] = {"symbol": format_symbol(symbol)} - if from_id is not None: - payload["fromId"] = from_id - if order_id is not None: - payload["orderId"] = order_id - if start_time is not None: - payload["startTime"] = str(start_time) - if end_time is not None: - payload["endTime"] = str(end_time) - if limit is not None: - payload["limit"] = str(limit) - if recv_window is not None: - payload["recvWindow"] = str(recv_window) - - raw: bytes = await self.client.sign_request( - http_method="GET", - url_path=self.BASE_ENDPOINT + "myTrades", - payload=payload, + def __init__( + self, + client: BinanceHttpClient, + clock: LiveClock, + account_type: BinanceAccountType = BinanceAccountType.SPOT, + ): + super().__init__( + client=client, + clock=clock, + account_type=account_type, ) - return msgspec.json.decode(raw) + if not account_type.is_spot_or_margin: + raise RuntimeError( # pragma: no cover (design-time error) + f"`BinanceAccountType` not SPOT, MARGIN_CROSS or MARGIN_ISOLATED, was {account_type}", # pragma: no cover + ) + + # Create endpoints + self._endpoint_spot_open_orders = BinanceSpotOpenOrdersHttp(client, self.base_endpoint) + self._endpoint_spot_order_oco = BinanceSpotOrderOcoHttp(client, self.base_endpoint) + self._endpoint_spot_order_list = BinanceSpotOrderListHttp(client, self.base_endpoint) + self._endpoint_spot_all_order_list = BinanceSpotAllOrderListHttp(client, self.base_endpoint) + self._endpoint_spot_open_order_list = BinanceSpotOpenOrderListHttp( + client, + self.base_endpoint, + ) + self._endpoint_spot_account = BinanceSpotAccountHttp(client, self.base_endpoint) + self._endpoint_spot_order_rate_limit = BinanceSpotOrderRateLimitHttp( + client, + self.base_endpoint, + ) - async def get_order_rate_limit(self, recv_window: Optional[int] = None) -> dict[str, Any]: - """ - Get the user's current order count usage for all intervals. + async def new_spot_oco( + self, + symbol: str, + side: BinanceOrderSide, + quantity: str, + price: str, + stop_price: str, + list_client_order_id: Optional[str] = None, + limit_client_order_id: Optional[str] = None, + limit_strategy_id: Optional[int] = None, + limit_strategy_type: Optional[int] = None, + limit_iceberg_qty: Optional[str] = None, + trailing_delta: Optional[str] = None, + stop_client_order_id: Optional[str] = None, + stop_strategy_id: Optional[int] = None, + stop_strategy_type: Optional[int] = None, + stop_limit_price: Optional[str] = None, + stop_iceberg_qty: Optional[str] = None, + stop_limit_time_in_force: Optional[BinanceTimeInForce] = None, + new_order_resp_type: Optional[BinanceNewOrderRespType] = None, + recv_window: Optional[str] = None, + ) -> BinanceSpotOrderOco: + """Send in a new spot OCO order to Binance""" + if stop_limit_price is not None and stop_limit_time_in_force is None: + raise RuntimeError( + "stopLimitPrice cannot be provided without stopLimitTimeInForce.", + ) + if stop_limit_time_in_force == BinanceTimeInForce.GTX: + raise RuntimeError( + "stopLimitTimeInForce, Good Till Crossing (GTX) not supported.", + ) + return await self._endpoint_spot_order_oco._post( + parameters=self._endpoint_spot_order_oco.PostParameters( + symbol=BinanceSymbol(symbol), + timestamp=self._timestamp(), + side=side, + quantity=quantity, + price=price, + stopPrice=stop_price, + listClientOrderId=list_client_order_id, + limitClientOrderId=limit_client_order_id, + limitStrategyId=limit_strategy_id, + limitStrategyType=limit_strategy_type, + limitIcebergQty=limit_iceberg_qty, + trailingDelta=trailing_delta, + stopClientOrderId=stop_client_order_id, + stopStrategyId=stop_strategy_id, + stopStrategyType=stop_strategy_type, + stopLimitPrice=stop_limit_price, + stopIcebergQty=stop_iceberg_qty, + stopLimitTimeInForce=stop_limit_time_in_force, + newOrderRespType=new_order_resp_type, + recvWindow=recv_window, + ), + ) - Query Current Order Count Usage (TRADE). - `GET /api/v3/rateLimit/order`. + async def query_spot_oco( + self, + order_list_id: Optional[str] = None, + orig_client_order_id: Optional[str] = None, + recv_window: Optional[str] = None, + ) -> BinanceSpotOrderOco: + """Check single spot OCO order information.""" + if order_list_id is None and orig_client_order_id is None: + raise RuntimeError( + "Either orderListId or origClientOrderId must be provided.", + ) + return await self._endpoint_spot_order_list._get( + parameters=self._endpoint_spot_order_list.GetParameters( + timestamp=self._timestamp(), + orderListId=order_list_id, + origClientOrderId=orig_client_order_id, + recvWindow=recv_window, + ), + ) - Parameters - ---------- - recv_window : int, optional - The response receive window for the request (cannot be greater than 60000). + async def cancel_all_open_orders( + self, + symbol: str, + recv_window: Optional[str] = None, + ) -> bool: + """Cancel all active orders on a symbol, including OCO. Returns whether successful.""" + await self._endpoint_spot_open_orders._delete( + parameters=self._endpoint_spot_open_orders.DeleteParameters( + timestamp=self._timestamp(), + symbol=BinanceSymbol(symbol), + recvWindow=recv_window, + ), + ) + return True - Returns - ------- - dict[str, Any] + async def cancel_spot_oco( + self, + symbol: str, + order_list_id: Optional[str] = None, + list_client_order_id: Optional[str] = None, + new_client_order_id: Optional[str] = None, + recv_window: Optional[str] = None, + ) -> BinanceSpotOrderOco: + """Delete spot OCO order from Binance.""" + if order_list_id is None and list_client_order_id is None: + raise RuntimeError( + "Either orderListId or listClientOrderId must be provided.", + ) + return await self._endpoint_spot_order_list._delete( + parameters=self._endpoint_spot_order_list.DeleteParameters( + timestamp=self._timestamp(), + symbol=BinanceSymbol(symbol), + orderListId=order_list_id, + listClientOrderId=list_client_order_id, + newClientOrderId=new_client_order_id, + recvWindow=recv_window, + ), + ) - References - ---------- - https://binance-docs.github.io/apidocs/spot/en/#query-current-order-count-usage-trade + async def query_spot_all_oco( + self, + from_id: Optional[str] = None, + start_time: Optional[str] = None, + end_time: Optional[str] = None, + limit: Optional[int] = None, + recv_window: Optional[str] = None, + ) -> list[BinanceSpotOrderOco]: + """Check all spot OCO orders' information, matching provided filter parameters.""" + if from_id is not None and (start_time or end_time) is not None: + raise RuntimeError( + "Cannot specify both fromId and a startTime/endTime.", + ) + return await self._endpoint_spot_all_order_list._get( + parameters=self._endpoint_spot_all_order_list.GetParameters( + timestamp=self._timestamp(), + fromId=from_id, + startTime=start_time, + endTime=end_time, + limit=limit, + recvWindow=recv_window, + ), + ) - """ - payload: dict[str, str] = {} - if recv_window is not None: - payload["recvWindow"] = str(recv_window) - - raw: bytes = await self.client.sign_request( - http_method="GET", - url_path=self.BASE_ENDPOINT + "rateLimit/order", - payload=payload, + async def query_spot_all_open_oco( + self, + recv_window: Optional[str] = None, + ) -> list[BinanceSpotOrderOco]: + """Check all OPEN spot OCO orders' information.""" + return await self._endpoint_spot_open_order_list._get( + parameters=self._endpoint_spot_open_order_list.GetParameters( + timestamp=self._timestamp(), + recvWindow=recv_window, + ), ) - return msgspec.json.decode(raw) + async def query_spot_account_info( + self, + recv_window: Optional[str] = None, + ) -> BinanceSpotAccountInfo: + """Check SPOT/MARGIN Binance account information.""" + return await self._endpoint_spot_account._get( + parameters=self._endpoint_spot_account.GetParameters( + timestamp=self._timestamp(), + recvWindow=recv_window, + ), + ) + + async def query_spot_order_rate_limit( + self, + recv_window: Optional[str] = None, + ) -> list[BinanceRateLimit]: + """Check SPOT/MARGIN order count/rateLimit.""" + return await self._endpoint_spot_order_rate_limit._get( + parameters=self._endpoint_spot_order_rate_limit.GetParameters( + timestamp=self._timestamp(), + recvWindow=recv_window, + ), + ) diff --git a/nautilus_trader/adapters/binance/spot/http/market.py b/nautilus_trader/adapters/binance/spot/http/market.py index ef4b51fa65fb..dc6d5a3f9c72 100644 --- a/nautilus_trader/adapters/binance/spot/http/market.py +++ b/nautilus_trader/adapters/binance/spot/http/market.py @@ -13,452 +13,175 @@ # limitations under the License. # ------------------------------------------------------------------------------------------------- -from typing import Any, Optional +from typing import Optional import msgspec -from nautilus_trader.adapters.binance.common.functions import convert_symbols_list_to_json_array -from nautilus_trader.adapters.binance.common.functions import format_symbol -from nautilus_trader.adapters.binance.common.schemas import BinanceTrade +from nautilus_trader.adapters.binance.common.enums import BinanceAccountType +from nautilus_trader.adapters.binance.common.enums import BinanceMethodType +from nautilus_trader.adapters.binance.common.enums import BinanceSecurityType +from nautilus_trader.adapters.binance.common.schemas.symbol import BinanceSymbol +from nautilus_trader.adapters.binance.common.schemas.symbol import BinanceSymbols from nautilus_trader.adapters.binance.http.client import BinanceHttpClient +from nautilus_trader.adapters.binance.http.endpoint import BinanceHttpEndpoint +from nautilus_trader.adapters.binance.http.market import BinanceMarketHttpAPI +from nautilus_trader.adapters.binance.spot.enums import BinanceSpotPermissions +from nautilus_trader.adapters.binance.spot.schemas.market import BinanceSpotAvgPrice from nautilus_trader.adapters.binance.spot.schemas.market import BinanceSpotExchangeInfo -class BinanceSpotMarketHttpAPI: +class BinanceSpotExchangeInfoHttp(BinanceHttpEndpoint): """ - Provides access to the `Binance Futures` Market HTTP REST API. + Endpoint of SPOT/MARGIN exchange trading rules and symbol information - Parameters - ---------- - client : BinanceHttpClient - The Binance REST API client. - """ - - BASE_ENDPOINT = "/api/v3/" - - def __init__(self, client: BinanceHttpClient): - self.client = client - - self._decoder_exchange_info = msgspec.json.Decoder(BinanceSpotExchangeInfo) - self._decoder_trades = msgspec.json.Decoder(list[BinanceTrade]) - - async def ping(self) -> dict[str, Any]: - """ - Test the connectivity to the REST API. - - `GET /api/v3/ping` - - Returns - ------- - dict[str, Any] - - References - ---------- - https://binance-docs.github.io/apidocs/spot/en/#test-connectivity - - """ - raw: bytes = await self.client.query(url_path=self.BASE_ENDPOINT + "ping") - return msgspec.json.decode(raw) - - async def time(self) -> dict[str, Any]: - """ - Test connectivity to the Rest API and get the current server time. + `GET /api/v3/exchangeInfo` - Check Server Time. - `GET /api/v3/time` - - Returns - ------- - dict[str, Any] - - References - ---------- - https://binance-docs.github.io/apidocs/spot/en/#check-server-time + References + ---------- + https://binance-docs.github.io/apidocs/spot/en/#exchange-information - """ - raw: bytes = await self.client.query(url_path=self.BASE_ENDPOINT + "time") - return msgspec.json.decode(raw) + """ - async def exchange_info( + def __init__( self, - symbol: Optional[str] = None, - symbols: Optional[list[str]] = None, - ) -> BinanceSpotExchangeInfo: - """ - Get current exchange trading rules and symbol information. - Only either `symbol` or `symbols` should be passed. - - Exchange Information. - `GET /api/v3/exchangeinfo` - - Parameters - ---------- - symbol : str, optional - The trading pair. - symbols : list[str], optional - The list of trading pairs. - - Returns - ------- - BinanceSpotExchangeInfo - - References - ---------- - https://binance-docs.github.io/apidocs/spot/en/#exchange-information - - """ - if symbol and symbols: - raise ValueError("`symbol` and `symbols` cannot be sent together") - - payload: dict[str, str] = {} - if symbol is not None: - payload["symbol"] = format_symbol(symbol) - if symbols is not None: - payload["symbols"] = convert_symbols_list_to_json_array(symbols) - - raw: bytes = await self.client.query( - url_path=self.BASE_ENDPOINT + "exchangeInfo", - payload=payload, - ) - - return self._decoder_exchange_info.decode(raw) - - async def depth(self, symbol: str, limit: Optional[int] = None) -> dict[str, Any]: - """ - Get orderbook. - - `GET /api/v3/depth` - - Parameters - ---------- - symbol : str - The trading pair. - limit : int, optional, default 100 - The limit for the response. Default 100; max 5000. - Valid limits:[5, 10, 20, 50, 100, 500, 1000, 5000]. - - Returns - ------- - dict[str, Any] - - References - ---------- - https://binance-docs.github.io/apidocs/spot/en/#order-book - - """ - payload: dict[str, str] = {"symbol": format_symbol(symbol)} - if limit is not None: - payload["limit"] = str(limit) - - raw: bytes = await self.client.query( - url_path=self.BASE_ENDPOINT + "depth", - payload=payload, + client: BinanceHttpClient, + base_endpoint: str, + ): + methods = { + BinanceMethodType.GET: BinanceSecurityType.NONE, + } + url_path = base_endpoint + "exchangeInfo" + super().__init__( + client, + methods, + url_path, ) + self._get_resp_decoder = msgspec.json.Decoder(BinanceSpotExchangeInfo) - return msgspec.json.decode(raw) - - async def trades(self, symbol: str, limit: Optional[int] = None) -> list[BinanceTrade]: + class GetParameters(msgspec.Struct, omit_defaults=True, frozen=True): """ - Get recent market trades. - - Recent Trades List. - `GET /api/v3/trades` + GET exchangeInfo parameters Parameters ---------- - symbol : str - The trading pair. - limit : int, optional - The limit for the response. Default 500; max 1000. - - Returns - ------- - list[BinanceTrade] - - References - ---------- - https://binance-docs.github.io/apidocs/spot/en/#recent-trades-list + symbol : BinanceSymbol + Optional, specify trading pair to get exchange info for + symbols : BinanceSymbols + Optional, specify list of trading pairs to get exchange info for + permissions : BinanceSpotPermissions + Optional, filter symbols list by supported permissions """ - payload: dict[str, str] = {"symbol": format_symbol(symbol)} - if limit is not None: - payload["limit"] = str(limit) - raw: bytes = await self.client.query( - url_path=self.BASE_ENDPOINT + "trades", - payload=payload, - ) + symbol: Optional[BinanceSymbol] = None + symbols: Optional[BinanceSymbols] = None + permissions: Optional[BinanceSpotPermissions] = None - return self._decoder_trades.decode(raw) + async def _get(self, parameters: Optional[GetParameters] = None) -> BinanceSpotExchangeInfo: + method_type = BinanceMethodType.GET + raw = await self._method(method_type, parameters) + return self._get_resp_decoder.decode(raw) - async def historical_trades( - self, - symbol: str, - from_id: Optional[int] = None, - limit: Optional[int] = None, - ) -> dict[str, Any]: - """ - Get older market trades. - - Old Trade Lookup. - `GET /api/v3/historicalTrades` - - Parameters - ---------- - symbol : str - The trading pair. - from_id : int, optional - The trade ID to fetch from. Default gets most recent trades. - limit : int, optional - The limit for the response. Default 500; max 1000. - - Returns - ------- - dict[str, Any] - - References - ---------- - https://binance-docs.github.io/apidocs/spot/en/#old-trade-lookup - """ - payload: dict[str, str] = {"symbol": format_symbol(symbol)} - if limit is not None: - payload["limit"] = str(limit) - if from_id is not None: - payload["fromId"] = str(from_id) - - raw: bytes = await self.client.limit_request( - http_method="GET", - url_path=self.BASE_ENDPOINT + "historicalTrades", - payload=payload, - ) - - return msgspec.json.decode(raw) - - async def agg_trades( - self, - symbol: str, - from_id: Optional[int] = None, - start_time_ms: Optional[int] = None, - end_time_ms: Optional[int] = None, - limit: Optional[int] = None, - ) -> dict[str, Any]: - """ - Get recent aggregated market trades. - - Compressed/Aggregate Trades List. - `GET /api/v3/aggTrades` +class BinanceSpotAvgPriceHttp(BinanceHttpEndpoint): + """ + Endpoint of current average price of a symbol - Parameters - ---------- - symbol : str - The trading pair. - from_id : int, optional - The trade ID to fetch from. Default gets most recent trades. - start_time_ms : int, optional - The UNIX timestamp (milliseconds) to get aggregate trades from INCLUSIVE. - end_time_ms: int, optional - The UNIX timestamp (milliseconds) to get aggregate trades until INCLUSIVE. - limit : int, optional - The limit for the response. Default 500; max 1000. - - Returns - ------- - dict[str, Any] - - References - ---------- - https://binance-docs.github.io/apidocs/spot/en/#compressed-aggregate-trades-list + `GET /api/v3/avgPrice` - """ - payload: dict[str, str] = {"symbol": format_symbol(symbol)} - if from_id is not None: - payload["fromId"] = str(from_id) - if start_time_ms is not None: - payload["startTime"] = str(start_time_ms) - if end_time_ms is not None: - payload["endTime"] = str(end_time_ms) - if limit is not None: - payload["limit"] = str(limit) - - raw: bytes = await self.client.query( - url_path=self.BASE_ENDPOINT + "aggTrades", - payload=payload, - ) + References + ---------- + https://binance-docs.github.io/apidocs/spot/en/#current-average-price - return msgspec.json.decode(raw) + """ - async def klines( + def __init__( self, - symbol: str, - interval: str, - start_time_ms: Optional[int] = None, - end_time_ms: Optional[int] = None, - limit: Optional[int] = None, - ) -> list[list[Any]]: - """ - Kline/Candlestick Data. - - `GET /api/v3/klines` - - Parameters - ---------- - symbol : str - The trading pair. - interval : str - The interval of kline, e.g 1m, 5m, 1h, 1d, etc. - start_time_ms : int, optional - The UNIX timestamp (milliseconds) to get aggregate trades from INCLUSIVE. - end_time_ms: int, optional - The UNIX timestamp (milliseconds) to get aggregate trades until INCLUSIVE. - limit : int, optional - The limit for the response. Default 500; max 1000. - - Returns - ------- - list[list[Any]] - - References - ---------- - https://binance-docs.github.io/apidocs/spot/en/#kline-candlestick-data - - """ - payload: dict[str, str] = { - "symbol": format_symbol(symbol), - "interval": interval, + client: BinanceHttpClient, + base_endpoint: str, + ): + methods = { + BinanceMethodType.GET: BinanceSecurityType.NONE, } - if start_time_ms is not None: - payload["startTime"] = str(start_time_ms) - if end_time_ms is not None: - payload["endTime"] = str(end_time_ms) - if limit is not None: - payload["limit"] = str(limit) - - raw: bytes = await self.client.query( - url_path=self.BASE_ENDPOINT + "klines", - payload=payload, + url_path = base_endpoint + "avgPrice" + super().__init__( + client, + methods, + url_path, ) + self._get_resp_decoder = msgspec.json.Decoder(BinanceSpotAvgPrice) - return msgspec.json.decode(raw) - - async def avg_price(self, symbol: str) -> dict[str, Any]: + class GetParameters(msgspec.Struct, omit_defaults=True, frozen=True): """ - Get the current average price for the given symbol. - - `GET /api/v3/avgPrice` + GET avgPrice parameters Parameters ---------- - symbol : str - The trading pair. - - Returns - ------- - dict[str, Any] - - References - ---------- - https://binance-docs.github.io/apidocs/spot/en/#current-average-price + symbol : BinanceSymbol + Specify trading pair to get average price for """ - payload: dict[str, str] = {"symbol": format_symbol(symbol)} - raw: bytes = await self.client.query( - url_path=self.BASE_ENDPOINT + "avgPrice", - payload=payload, - ) + symbol: BinanceSymbol = None - return msgspec.json.decode(raw) + async def _get(self, parameters: GetParameters) -> BinanceSpotAvgPrice: + method_type = BinanceMethodType.GET + raw = await self._method(method_type, parameters) + return self._get_resp_decoder.decode(raw) - async def ticker_24hr(self, symbol: Optional[str] = None) -> dict[str, Any]: - """ - 24hr Ticker Price Change Statistics. - - `GET /api/v3/ticker/24hr` - - Parameters - ---------- - symbol : str, optional - The trading pair. - Returns - ------- - dict[str, Any] +class BinanceSpotMarketHttpAPI(BinanceMarketHttpAPI): + """ + Provides access to the `Binance Spot` Market HTTP REST API. - References - ---------- - https://binance-docs.github.io/apidocs/spot/en/#24hr-ticker-price-change-statistics + Parameters + ---------- + client : BinanceHttpClient + The Binance REST API client. + account_type : BinanceAccountType + The Binance account type, used to select the endpoint - """ - payload: dict[str, str] = {} - if symbol is not None: - payload["symbol"] = format_symbol(symbol) + """ - raw: bytes = await self.client.query( - url_path=self.BASE_ENDPOINT + "ticker/24hr", - payload=payload, + def __init__( + self, + client: BinanceHttpClient, + account_type: BinanceAccountType = BinanceAccountType.SPOT, + ): + super().__init__( + client=client, + account_type=account_type, ) - return msgspec.json.decode(raw) - - async def ticker_price(self, symbol: Optional[str] = None) -> dict[str, Any]: - """ - Symbol Price Ticker. - - `GET /api/v3/ticker/price` + if not account_type.is_spot_or_margin: + raise RuntimeError( # pragma: no cover (design-time error) + f"`BinanceAccountType` not SPOT, MARGIN_CROSS or MARGIN_ISOLATED, was {account_type}", # pragma: no cover + ) - Parameters - ---------- - symbol : str, optional - The trading pair. - - Returns - ------- - dict[str, Any] - - References - ---------- - https://binance-docs.github.io/apidocs/spot/en/#symbol-price-ticker + self._endpoint_spot_exchange_info = BinanceSpotExchangeInfoHttp(client, self.base_endpoint) + self._endpoint_spot_average_price = BinanceSpotAvgPriceHttp(client, self.base_endpoint) - """ - payload: dict[str, str] = {} - if symbol is not None: - payload["symbol"] = format_symbol(symbol) - - raw: bytes = await self.client.query( - url_path=self.BASE_ENDPOINT + "ticker/price", - payload=payload, + async def query_spot_exchange_info( + self, + symbol: Optional[str] = None, + symbols: Optional[list[str]] = None, + permissions: Optional[BinanceSpotPermissions] = None, + ) -> BinanceSpotExchangeInfo: + """Check Binance Spot exchange information.""" + if symbol and symbols: + raise ValueError("`symbol` and `symbols` cannot be sent together") + return await self._endpoint_spot_exchange_info._get( + parameters=self._endpoint_spot_exchange_info.GetParameters( + symbol=BinanceSymbol(symbol), + symbols=BinanceSymbols(symbols), + permissions=permissions, + ), ) - return msgspec.json.decode(raw) - - async def book_ticker(self, symbol: Optional[str] = None) -> dict[str, Any]: - """ - Symbol Order Book Ticker. - - `GET /api/v3/ticker/bookTicker` - - Parameters - ---------- - symbol : str, optional - The trading pair. - - Returns - ------- - dict[str, Any] - - References - ---------- - https://binance-docs.github.io/apidocs/spot/en/#symbol-order-book-ticker - - """ - payload: dict[str, str] = {} - if symbol is not None: - payload["symbol"] = format_symbol(symbol).upper() - - raw: bytes = await self.client.query( - url_path=self.BASE_ENDPOINT + "ticker/bookTicker", - payload=payload, + async def query_spot_average_price(self, symbol: str) -> BinanceSpotAvgPrice: + """Check average price for a provided symbol on the Spot exchange.""" + return await self._endpoint_spot_average_price._get( + parameters=self._endpoint_spot_average_price.GetParameters( + symbol=BinanceSymbol(symbol), + ), ) - - return msgspec.json.decode(raw) diff --git a/nautilus_trader/adapters/binance/spot/http/user.py b/nautilus_trader/adapters/binance/spot/http/user.py index 4ebe8c75274d..8c5a6034fdbc 100644 --- a/nautilus_trader/adapters/binance/spot/http/user.py +++ b/nautilus_trader/adapters/binance/spot/http/user.py @@ -13,16 +13,13 @@ # limitations under the License. # ------------------------------------------------------------------------------------------------- -from typing import Any - -import msgspec from nautilus_trader.adapters.binance.common.enums import BinanceAccountType -from nautilus_trader.adapters.binance.common.functions import format_symbol from nautilus_trader.adapters.binance.http.client import BinanceHttpClient +from nautilus_trader.adapters.binance.http.user import BinanceUserDataHttpAPI -class BinanceSpotUserDataHttpAPI: +class BinanceSpotUserDataHttpAPI(BinanceUserDataHttpAPI): """ Provides access to the `Binance Spot/Margin` User Data HTTP REST API. @@ -30,6 +27,8 @@ class BinanceSpotUserDataHttpAPI: ---------- client : BinanceHttpClient The Binance REST API client. + account_type : BinanceAccountType + The Binance account type, used to select the endpoint """ def __init__( @@ -37,201 +36,12 @@ def __init__( client: BinanceHttpClient, account_type: BinanceAccountType = BinanceAccountType.SPOT, ): - self.client = client - self.account_type = account_type + super().__init__( + client=client, + account_type=account_type, + ) - if account_type == BinanceAccountType.SPOT: - self.BASE_ENDPOINT = "/api/v3/" - elif account_type == BinanceAccountType.MARGIN: - self.BASE_ENDPOINT = "sapi/v1/" - else: + if not account_type.is_spot_or_margin: raise RuntimeError( # pragma: no cover (design-time error) - f"invalid `BinanceAccountType`, was {account_type}", # pragma: no cover (design-time error) # noqa + f"`BinanceAccountType` not SPOT, MARGIN_CROSS or MARGIN_ISOLATED, was {account_type}", # pragma: no cover (design-time error) # noqa ) - - async def create_listen_key(self) -> dict[str, Any]: - """ - Create a new listen key for the Binance Spot/Margin. - - Start a new user data stream. The stream will close after 60 minutes - unless a keepalive is sent. If the account has an active listenKey, - that listenKey will be returned and its validity will be extended for 60 - minutes. - - Create a ListenKey (USER_STREAM). - - Returns - ------- - dict[str, Any] - - References - ---------- - https://binance-docs.github.io/apidocs/spot/en/#listen-key-spot - - """ - raw: bytes = await self.client.send_request( - http_method="POST", - url_path=self.BASE_ENDPOINT + "userDataStream", - ) - - return msgspec.json.decode(raw) - - async def ping_listen_key(self, key: str) -> dict[str, Any]: - """ - Ping/Keep-alive a listen key for the Binance Spot/Margin API. - - Keep-alive a user data stream to prevent a time-out. User data streams - will close after 60 minutes. It's recommended to send a ping about every - 30 minutes. - - Ping/Keep-alive a ListenKey (USER_STREAM). - - Parameters - ---------- - key : str - The listen key for the request. - - Returns - ------- - dict[str, Any] - - References - ---------- - https://binance-docs.github.io/apidocs/spot/en/#listen-key-spot - - """ - raw: bytes = await self.client.send_request( - http_method="PUT", - url_path=self.BASE_ENDPOINT + "userDataStream", - payload={"listenKey": key}, - ) - - return msgspec.json.decode(raw) - - async def close_listen_key(self, key: str) -> dict[str, Any]: - """ - Close a listen key for the Binance Spot/Margin API. - - Close a ListenKey (USER_STREAM). - - Parameters - ---------- - key : str - The listen key for the request. - - Returns - ------- - dict[str, Any] - - References - ---------- - https://binance-docs.github.io/apidocs/spot/en/#listen-key-spot - - """ - raw: bytes = await self.client.send_request( - http_method="DELETE", - url_path=self.BASE_ENDPOINT + "userDataStream", - payload={"listenKey": key}, - ) - - return msgspec.json.decode(raw) - - async def create_listen_key_isolated_margin(self, symbol: str) -> dict[str, Any]: - """ - Create a new listen key for the ISOLATED MARGIN API. - - Start a new user data stream. The stream will close after 60 minutes - unless a keepalive is sent. If the account has an active listenKey, - that listenKey will be returned and its validity will be extended for 60 - minutes. - - Create a ListenKey (USER_STREAM). - `POST /api/v3/userDataStream `. - - Parameters - ---------- - symbol : str - The symbol for the listen key request. - - Returns - ------- - dict[str, Any] - - References - ---------- - https://binance-docs.github.io/apidocs/spot/en/#listen-key-isolated-margin - - """ - raw: bytes = await self.client.send_request( - http_method="POST", - url_path="/sapi/v1/userDataStream/isolated", - payload={"symbol": format_symbol(symbol)}, - ) - - return msgspec.json.decode(raw) - - async def ping_listen_key_isolated_margin(self, symbol: str, key: str) -> dict[str, Any]: - """ - Ping/Keep-alive a listen key for the ISOLATED MARGIN API. - - Keep-alive a user data stream to prevent a time-out. User data streams - will close after 60 minutes. It's recommended to send a ping about every - 30 minutes. - - Ping/Keep-alive a ListenKey (USER_STREAM). - `PUT /api/v3/userDataStream`. - - Parameters - ---------- - symbol : str - The symbol for the listen key request. - key : str - The listen key for the request. - - Returns - ------- - dict[str, Any] - - References - ---------- - https://binance-docs.github.io/apidocs/spot/en/#listen-key-isolated-margin - - """ - raw: bytes = await self.client.send_request( - http_method="PUT", - url_path="/sapi/v1/userDataStream/isolated", - payload={"listenKey": key, "symbol": format_symbol(symbol)}, - ) - - return msgspec.json.decode(raw) - - async def close_listen_key_isolated_margin(self, symbol: str, key: str) -> dict[str, Any]: - """ - Close a listen key for the ISOLATED MARGIN API. - - Close a ListenKey (USER_STREAM). - `DELETE /sapi/v1/userDataStream`. - - Parameters - ---------- - symbol : str - The symbol for the listen key request. - key : str - The listen key for the request. - - Returns - ------- - dict[str, Any] - - References - ---------- - https://binance-docs.github.io/apidocs/spot/en/#listen-key-isolated-margin - - """ - raw: bytes = await self.client.send_request( - http_method="DELETE", - url_path="/sapi/v1/userDataStream/isolated", - payload={"listenKey": key, "symbol": format_symbol(symbol)}, - ) - - return msgspec.json.decode(raw) diff --git a/nautilus_trader/adapters/binance/spot/http/wallet.py b/nautilus_trader/adapters/binance/spot/http/wallet.py index a81672613fa5..931bf516d9b7 100644 --- a/nautilus_trader/adapters/binance/spot/http/wallet.py +++ b/nautilus_trader/adapters/binance/spot/http/wallet.py @@ -17,94 +17,113 @@ import msgspec +from nautilus_trader.adapters.binance.common.enums import BinanceAccountType +from nautilus_trader.adapters.binance.common.enums import BinanceMethodType +from nautilus_trader.adapters.binance.common.enums import BinanceSecurityType +from nautilus_trader.adapters.binance.common.schemas.symbol import BinanceSymbol from nautilus_trader.adapters.binance.http.client import BinanceHttpClient -from nautilus_trader.adapters.binance.spot.schemas.wallet import BinanceSpotTradeFees +from nautilus_trader.adapters.binance.http.endpoint import BinanceHttpEndpoint +from nautilus_trader.adapters.binance.spot.schemas.wallet import BinanceSpotTradeFee +from nautilus_trader.common.clock import LiveClock -class BinanceSpotWalletHttpAPI: +class BinanceSpotTradeFeeHttp(BinanceHttpEndpoint): """ - Provides access to the `Binance Spot/Margin` Wallet HTTP REST API. + Endpoint of maker/taker trade fee information - Parameters - ---------- - client : BinanceHttpClient - The Binance REST API client. - """ + `GET /sapi/v1/asset/tradeFee` - def __init__(self, client: BinanceHttpClient): - self.client = client + References + ---------- + https://binance-docs.github.io/apidocs/spot/en/#trade-fee-user_data - self._decoder_trade_fees = msgspec.json.Decoder(BinanceSpotTradeFees) - self._decoder_trade_fees_array = msgspec.json.Decoder(list[BinanceSpotTradeFees]) + """ - async def trade_fee( + def __init__( self, - symbol: Optional[str] = None, - recv_window: Optional[int] = None, - ) -> BinanceSpotTradeFees: - """ - Fetch trade fee. + client: BinanceHttpClient, + base_endpoint: str, + ): + methods = { + BinanceMethodType.GET: BinanceSecurityType.USER_DATA, + } + super().__init__( + client, + methods, + base_endpoint + "tradeFee", + ) + self._get_obj_resp_decoder = msgspec.json.Decoder(BinanceSpotTradeFee) + self._get_arr_resp_decoder = msgspec.json.Decoder(list[BinanceSpotTradeFee]) - `GET /sapi/v1/asset/tradeFee` + class GetParameters(msgspec.Struct, omit_defaults=True, frozen=True): + """ + GET parameters for fetching trade fees Parameters ---------- - symbol : str, optional - The trading pair. If None then queries for all symbols. - recv_window : int, optional - The acceptable receive window for the response. + symbol : BinanceSymbol + Optional symbol to receive individual trade fee + recvWindow : str + Optional number of milliseconds after timestamp the request is valid + timestamp : str + Millisecond timestamp of the request - Returns - ------- - BinanceSpotTradeFees + """ - References - ---------- - https://binance-docs.github.io/apidocs/spot/en/#trade-fee-user_data + timestamp: str + symbol: Optional[BinanceSymbol] = None + recvWindow: Optional[str] = None - """ - payload: dict[str, str] = {} - if symbol is not None: - payload["symbol"] = symbol - if recv_window is not None: - payload["recv_window"] = str(recv_window) - - raw: bytes = await self.client.sign_request( - http_method="GET", - url_path="/sapi/v1/asset/tradeFee", - payload=payload, - ) + async def _get(self, parameters: GetParameters) -> list[BinanceSpotTradeFee]: + method_type = BinanceMethodType.GET + raw = await self._method(method_type, parameters) + if parameters.symbol is not None: + return [self._get_obj_resp_decoder.decode(raw)] + else: + return self._get_arr_resp_decoder.decode(raw) - return self._decoder_trade_fees.decode(raw) - async def trade_fees(self, recv_window: Optional[int] = None) -> list[BinanceSpotTradeFees]: - """ - Fetch trade fee. +class BinanceSpotWalletHttpAPI: + """ + Provides access to the `Binance Spot/Margin` Wallet HTTP REST API. - `GET /sapi/v1/asset/tradeFee` + Parameters + ---------- + client : BinanceHttpClient + The Binance REST API client. + """ - Parameters - ---------- - recv_window : int, optional - The acceptable receive window for the response. + def __init__( + self, + client: BinanceHttpClient, + clock: LiveClock, + account_type: BinanceAccountType = BinanceAccountType.SPOT, + ): + self.client = client + self._clock = clock + self.base_endpoint = "/sapi/v1/asset/" - Returns - ------- - list[BinanceSpotTradeFees] + if not account_type.is_spot_or_margin: + raise RuntimeError( # pragma: no cover (design-time error) + f"`BinanceAccountType` not SPOT, MARGIN_CROSS or MARGIN_ISOLATED, was {account_type}", # pragma: no cover + ) - References - ---------- - https://binance-docs.github.io/apidocs/spot/en/#trade-fee-user_data + self._endpoint_spot_trade_fee = BinanceSpotTradeFeeHttp(client, self.base_endpoint) - """ - payload: dict[str, str] = {} - if recv_window is not None: - payload["recv_window"] = str(recv_window) - - raw: bytes = await self.client.sign_request( - http_method="GET", - url_path="/sapi/v1/asset/tradeFee", - payload=payload, - ) + def _timestamp(self) -> str: + """Create Binance timestamp from internal clock""" + return str(self._clock.timestamp_ms()) - return self._decoder_trade_fees_array.decode(raw) + async def query_spot_trade_fees( + self, + symbol: Optional[str] = None, + recv_window: Optional[str] = None, + ) -> list[BinanceSpotTradeFee]: + fees = await self._endpoint_spot_trade_fee._get( + parameters=self._endpoint_spot_trade_fee.GetParameters( + timestamp=self._timestamp(), + symbol=BinanceSymbol(symbol), + recvWindow=recv_window, + ), + ) + return fees diff --git a/nautilus_trader/adapters/binance/spot/parsing/__init__.py b/nautilus_trader/adapters/binance/spot/parsing/__init__.py deleted file mode 100644 index ca16b56e4794..000000000000 --- a/nautilus_trader/adapters/binance/spot/parsing/__init__.py +++ /dev/null @@ -1,14 +0,0 @@ -# ------------------------------------------------------------------------------------------------- -# Copyright (C) 2015-2023 Nautech Systems Pty Ltd. All rights reserved. -# https://nautechsystems.io -# -# Licensed under the GNU Lesser General Public License Version 3.0 (the "License"); -# You may not use this file except in compliance with the License. -# You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# ------------------------------------------------------------------------------------------------- diff --git a/nautilus_trader/adapters/binance/spot/parsing/account.py b/nautilus_trader/adapters/binance/spot/parsing/account.py deleted file mode 100644 index 0bed5e1f27ad..000000000000 --- a/nautilus_trader/adapters/binance/spot/parsing/account.py +++ /dev/null @@ -1,58 +0,0 @@ -# ------------------------------------------------------------------------------------------------- -# Copyright (C) 2015-2023 Nautech Systems Pty Ltd. All rights reserved. -# https://nautechsystems.io -# -# Licensed under the GNU Lesser General Public License Version 3.0 (the "License"); -# You may not use this file except in compliance with the License. -# You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# ------------------------------------------------------------------------------------------------- - -from decimal import Decimal - -from nautilus_trader.adapters.binance.spot.schemas.account import BinanceSpotBalanceInfo -from nautilus_trader.adapters.binance.spot.schemas.user import BinanceSpotBalance -from nautilus_trader.model.currency import Currency -from nautilus_trader.model.objects import AccountBalance -from nautilus_trader.model.objects import Money - - -def parse_account_balances_ws(raw_balances: list[BinanceSpotBalance]) -> list[AccountBalance]: - balances: list[AccountBalance] = [] - for b in raw_balances: - currency = Currency.from_str(b.a) - free = Decimal(b.f) - locked = Decimal(b.l) - total: Decimal = free + locked - - balance = AccountBalance( - total=Money(total, currency), - locked=Money(locked, currency), - free=Money(free, currency), - ) - balances.append(balance) - - return balances - - -def parse_account_balances_http(raw_balances: list[BinanceSpotBalanceInfo]) -> list[AccountBalance]: - balances: list[AccountBalance] = [] - for b in raw_balances: - currency = Currency.from_str(b.asset) - free = Decimal(b.free) - locked = Decimal(b.locked) - total: Decimal = free + locked - - balance = AccountBalance( - total=Money(total, currency), - locked=Money(locked, currency), - free=Money(free, currency), - ) - balances.append(balance) - - return balances diff --git a/nautilus_trader/adapters/binance/spot/parsing/data.py b/nautilus_trader/adapters/binance/spot/parsing/data.py deleted file mode 100644 index 3d078a6b82ad..000000000000 --- a/nautilus_trader/adapters/binance/spot/parsing/data.py +++ /dev/null @@ -1,170 +0,0 @@ -# ------------------------------------------------------------------------------------------------- -# Copyright (C) 2015-2023 Nautech Systems Pty Ltd. All rights reserved. -# https://nautechsystems.io -# -# Licensed under the GNU Lesser General Public License Version 3.0 (the "License"); -# You may not use this file except in compliance with the License. -# You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# ------------------------------------------------------------------------------------------------- - -from decimal import Decimal -from typing import Optional - -import msgspec - -from nautilus_trader.adapters.binance.common.constants import BINANCE_VENUE -from nautilus_trader.adapters.binance.common.enums import BinanceSymbolFilterType -from nautilus_trader.adapters.binance.spot.schemas.market import BinanceSpotOrderBookDepthData -from nautilus_trader.adapters.binance.spot.schemas.market import BinanceSpotSymbolInfo -from nautilus_trader.adapters.binance.spot.schemas.market import BinanceSpotTradeData -from nautilus_trader.adapters.binance.spot.schemas.market import BinanceSymbolFilter -from nautilus_trader.adapters.binance.spot.schemas.wallet import BinanceSpotTradeFees -from nautilus_trader.core.correctness import PyCondition -from nautilus_trader.core.datetime import millis_to_nanos -from nautilus_trader.model.currency import Currency -from nautilus_trader.model.data.tick import TradeTick -from nautilus_trader.model.enums import AggressorSide -from nautilus_trader.model.enums import BookType -from nautilus_trader.model.enums import CurrencyType -from nautilus_trader.model.identifiers import InstrumentId -from nautilus_trader.model.identifiers import Symbol -from nautilus_trader.model.identifiers import TradeId -from nautilus_trader.model.instruments.base import Instrument -from nautilus_trader.model.instruments.currency_pair import CurrencyPair -from nautilus_trader.model.objects import PRICE_MAX -from nautilus_trader.model.objects import PRICE_MIN -from nautilus_trader.model.objects import QUANTITY_MAX -from nautilus_trader.model.objects import QUANTITY_MIN -from nautilus_trader.model.objects import Money -from nautilus_trader.model.objects import Price -from nautilus_trader.model.objects import Quantity -from nautilus_trader.model.orderbook.data import OrderBookSnapshot - - -def parse_spot_instrument_http( - symbol_info: BinanceSpotSymbolInfo, - fees: Optional[BinanceSpotTradeFees], - ts_event: int, - ts_init: int, -) -> Instrument: - # Create base asset - base_currency = Currency( - code=symbol_info.baseAsset, - precision=symbol_info.baseAssetPrecision, - iso4217=0, # Currently undetermined for crypto assets - name=symbol_info.baseAsset, - currency_type=CurrencyType.CRYPTO, - ) - - # Create quote asset - quote_currency = Currency( - code=symbol_info.quoteAsset, - precision=symbol_info.quoteAssetPrecision, - iso4217=0, # Currently undetermined for crypto assets - name=symbol_info.quoteAsset, - currency_type=CurrencyType.CRYPTO, - ) - - native_symbol = Symbol(symbol_info.symbol) - instrument_id = InstrumentId(symbol=native_symbol, venue=BINANCE_VENUE) - - # Parse instrument filters - filters: dict[BinanceSymbolFilterType, BinanceSymbolFilter] = { - f.filterType: f for f in symbol_info.filters - } - price_filter: BinanceSymbolFilter = filters.get(BinanceSymbolFilterType.PRICE_FILTER) - lot_size_filter: BinanceSymbolFilter = filters.get(BinanceSymbolFilterType.LOT_SIZE) - min_notional_filter: BinanceSymbolFilter = filters.get(BinanceSymbolFilterType.MIN_NOTIONAL) - # market_lot_size_filter = symbol_filters.get("MARKET_LOT_SIZE") - - tick_size = price_filter.tickSize.rstrip("0") - step_size = lot_size_filter.stepSize.rstrip("0") - PyCondition.in_range(float(tick_size), PRICE_MIN, PRICE_MAX, "tick_size") - PyCondition.in_range(float(step_size), QUANTITY_MIN, QUANTITY_MAX, "step_size") - - price_precision = abs(Decimal(tick_size).as_tuple().exponent) - size_precision = abs(Decimal(step_size).as_tuple().exponent) - price_increment = Price.from_str(tick_size) - size_increment = Quantity.from_str(step_size) - lot_size = Quantity.from_str(step_size) - - PyCondition.in_range(float(lot_size_filter.maxQty), QUANTITY_MIN, QUANTITY_MAX, "maxQty") - PyCondition.in_range(float(lot_size_filter.minQty), QUANTITY_MIN, QUANTITY_MAX, "minQty") - max_quantity = Quantity(float(lot_size_filter.maxQty), precision=size_precision) - min_quantity = Quantity(float(lot_size_filter.minQty), precision=size_precision) - min_notional = None - if filters.get(BinanceSymbolFilterType.MIN_NOTIONAL): - min_notional = Money(min_notional_filter.minNotional, currency=quote_currency) - max_price = Price(min(float(price_filter.maxPrice), 4294967296.0), precision=price_precision) - min_price = Price(max(float(price_filter.minPrice), 0.0), precision=price_precision) - - # Parse fees - maker_fee: Decimal = Decimal(0) - taker_fee: Decimal = Decimal(0) - if fees: - maker_fee = Decimal(fees.makerCommission) - taker_fee = Decimal(fees.takerCommission) - - # Create instrument - return CurrencyPair( - instrument_id=instrument_id, - native_symbol=native_symbol, - base_currency=base_currency, - quote_currency=quote_currency, - price_precision=price_precision, - size_precision=size_precision, - price_increment=price_increment, - size_increment=size_increment, - lot_size=lot_size, - max_quantity=max_quantity, - min_quantity=min_quantity, - max_notional=None, - min_notional=min_notional, - max_price=max_price, - min_price=min_price, - margin_init=Decimal(0), - margin_maint=Decimal(0), - maker_fee=maker_fee, - taker_fee=taker_fee, - ts_event=ts_event, - ts_init=ts_init, - info=msgspec.json.decode(msgspec.json.encode(symbol_info)), - ) - - -def parse_spot_book_snapshot( - instrument_id: InstrumentId, - data: BinanceSpotOrderBookDepthData, - ts_init: int, -) -> OrderBookSnapshot: - return OrderBookSnapshot( - instrument_id=instrument_id, - book_type=BookType.L2_MBP, - bids=[[float(o[0]), float(o[1])] for o in data.bids], - asks=[[float(o[0]), float(o[1])] for o in data.asks], - ts_event=ts_init, - ts_init=ts_init, - sequence=data.lastUpdateId, - ) - - -def parse_spot_trade_tick_ws( - instrument_id: InstrumentId, - data: BinanceSpotTradeData, - ts_init: int, -) -> TradeTick: - return TradeTick( - instrument_id=instrument_id, - price=Price.from_str(data.p), - size=Quantity.from_str(data.q), - aggressor_side=AggressorSide.SELLER if data.m else AggressorSide.BUYER, - trade_id=TradeId(str(data.t)), - ts_event=millis_to_nanos(data.T), - ts_init=ts_init, - ) diff --git a/nautilus_trader/adapters/binance/spot/parsing/execution.py b/nautilus_trader/adapters/binance/spot/parsing/execution.py deleted file mode 100644 index aa2b275222d3..000000000000 --- a/nautilus_trader/adapters/binance/spot/parsing/execution.py +++ /dev/null @@ -1,185 +0,0 @@ -# ------------------------------------------------------------------------------------------------- -# Copyright (C) 2015-2023 Nautech Systems Pty Ltd. All rights reserved. -# https://nautechsystems.io -# -# Licensed under the GNU Lesser General Public License Version 3.0 (the "License"); -# You may not use this file except in compliance with the License. -# You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# ------------------------------------------------------------------------------------------------- - -from decimal import Decimal -from typing import Any - -from nautilus_trader.adapters.binance.common.enums import BinanceOrderStatus -from nautilus_trader.adapters.binance.spot.enums import BinanceSpotOrderType -from nautilus_trader.core.datetime import millis_to_nanos -from nautilus_trader.core.uuid import UUID4 -from nautilus_trader.execution.reports import OrderStatusReport -from nautilus_trader.execution.reports import TradeReport -from nautilus_trader.model.currency import Currency -from nautilus_trader.model.enums import LiquiditySide -from nautilus_trader.model.enums import OrderSide -from nautilus_trader.model.enums import OrderStatus -from nautilus_trader.model.enums import OrderType -from nautilus_trader.model.enums import TimeInForce -from nautilus_trader.model.enums import TriggerType -from nautilus_trader.model.identifiers import AccountId -from nautilus_trader.model.identifiers import ClientOrderId -from nautilus_trader.model.identifiers import InstrumentId -from nautilus_trader.model.identifiers import TradeId -from nautilus_trader.model.identifiers import VenueOrderId -from nautilus_trader.model.objects import AccountBalance -from nautilus_trader.model.objects import Money -from nautilus_trader.model.objects import Price -from nautilus_trader.model.objects import Quantity -from nautilus_trader.model.orders.base import Order - - -def parse_balances( - raw_balances: list[dict[str, str]], - asset_key: str, - free_key: str, - locked_key: str, -) -> list[AccountBalance]: - parsed_balances: dict[Currency, tuple[Decimal, Decimal, Decimal]] = {} - for b in raw_balances: - currency = Currency.from_str(b[asset_key]) - free = Decimal(b[free_key]) - locked = Decimal(b[locked_key]) - total: Decimal = free + locked - parsed_balances[currency] = (total, locked, free) - - balances: list[AccountBalance] = [ - AccountBalance( - total=Money(values[0], currency), - locked=Money(values[1], currency), - free=Money(values[2], currency), - ) - for currency, values in parsed_balances.items() - ] - - return balances - - -def parse_time_in_force(time_in_force: str) -> TimeInForce: - if time_in_force == "GTX": - return TimeInForce.GTC - else: - return TimeInForce[time_in_force] - - -def parse_order_status(status: BinanceOrderStatus) -> OrderStatus: - if status == BinanceOrderStatus.NEW: - return OrderStatus.ACCEPTED - elif status == BinanceOrderStatus.CANCELED: - return OrderStatus.CANCELED - elif status == BinanceOrderStatus.PARTIALLY_FILLED: - return OrderStatus.PARTIALLY_FILLED - elif status == BinanceOrderStatus.FILLED: - return OrderStatus.FILLED - elif status == BinanceOrderStatus.EXPIRED: - return OrderStatus.EXPIRED - else: - raise RuntimeError( # pragma: no cover (design-time error) - f"unrecognized order status, was {status}", # pragma: no cover - ) - - -def parse_order_type(order_type: BinanceSpotOrderType) -> OrderType: - if order_type == BinanceSpotOrderType.STOP: - return OrderType.STOP_MARKET - elif order_type == BinanceSpotOrderType.STOP_LOSS: - return OrderType.STOP_MARKET - elif order_type == BinanceSpotOrderType.STOP_LOSS_LIMIT: - return OrderType.STOP_LIMIT - elif order_type == BinanceSpotOrderType.TAKE_PROFIT: - return OrderType.LIMIT - elif order_type == BinanceSpotOrderType.TAKE_PROFIT_LIMIT: - return OrderType.STOP_LIMIT - elif order_type == BinanceSpotOrderType.LIMIT_MAKER: - return OrderType.LIMIT - elif order_type == BinanceSpotOrderType.LIMIT: - return OrderType.LIMIT - else: - return OrderType.MARKET - - -def binance_order_type(order: Order) -> BinanceSpotOrderType: - if order.order_type == OrderType.MARKET: - return BinanceSpotOrderType.MARKET - elif order.order_type == OrderType.LIMIT: - if order.is_post_only: - return BinanceSpotOrderType.LIMIT_MAKER - else: - return BinanceSpotOrderType.LIMIT - elif order.order_type == OrderType.STOP_LIMIT: - return BinanceSpotOrderType.STOP_LOSS_LIMIT - elif order.order_type == OrderType.LIMIT_IF_TOUCHED: - return BinanceSpotOrderType.TAKE_PROFIT_LIMIT - else: - raise RuntimeError("invalid `OrderType`") # pragma: no cover (design-time error) # noqa - - -def parse_order_report_http( - account_id: AccountId, - instrument_id: InstrumentId, - data: dict[str, Any], - report_id: UUID4, - ts_init: int, -) -> OrderStatusReport: - client_id_str = data.get("clientOrderId") - order_type = data["type"].upper() - price = data.get("price") - trigger_price = Decimal(data["stopPrice"]) - avg_px = Decimal(data["price"]) - return OrderStatusReport( - account_id=account_id, - instrument_id=instrument_id, - client_order_id=ClientOrderId(client_id_str) if client_id_str is not None else None, - venue_order_id=VenueOrderId(str(data["orderId"])), - order_side=OrderSide[data["side"].upper()], - order_type=parse_order_type(order_type), - time_in_force=parse_time_in_force(data["timeInForce"].upper()), - order_status=parse_order_status(BinanceOrderStatus(data["status"].upper())), - price=Price.from_str(price) if price is not None else None, - quantity=Quantity.from_str(data["origQty"]), - filled_qty=Quantity.from_str(data["executedQty"]), - avg_px=avg_px if avg_px > 0 else None, - post_only=order_type == "LIMIT_MAKER", - reduce_only=False, - report_id=report_id, - ts_accepted=millis_to_nanos(data["time"]), - ts_last=millis_to_nanos(data["updateTime"]), - ts_init=ts_init, - trigger_price=Price.from_str(str(trigger_price)) if trigger_price > 0 else None, - trigger_type=TriggerType.LAST_TRADE if trigger_price > 0 else TriggerType.NO_TRIGGER, - ) - - -def parse_trade_report_http( - account_id: AccountId, - instrument_id: InstrumentId, - data: dict[str, Any], - report_id: UUID4, - ts_init: int, -) -> TradeReport: - return TradeReport( - account_id=account_id, - instrument_id=instrument_id, - venue_order_id=VenueOrderId(str(data["orderId"])), - trade_id=TradeId(str(data["id"])), - order_side=OrderSide.BUY if data["isBuyer"] else OrderSide.SELL, - last_qty=Quantity.from_str(data["qty"]), - last_px=Price.from_str(data["price"]), - commission=Money(data["commission"], Currency.from_str(data["commissionAsset"])), - liquidity_side=LiquiditySide.MAKER if data["isMaker"] else LiquiditySide.TAKER, - report_id=report_id, - ts_event=millis_to_nanos(data["time"]), - ts_init=ts_init, - ) diff --git a/nautilus_trader/adapters/binance/spot/providers.py b/nautilus_trader/adapters/binance/spot/providers.py index 3326dd9bf1a6..3b71d3a3d6fb 100644 --- a/nautilus_trader/adapters/binance/spot/providers.py +++ b/nautilus_trader/adapters/binance/spot/providers.py @@ -13,25 +13,37 @@ # limitations under the License. # ------------------------------------------------------------------------------------------------- -import time +from decimal import Decimal from typing import Optional +import msgspec + from nautilus_trader.adapters.binance.common.constants import BINANCE_VENUE from nautilus_trader.adapters.binance.common.enums import BinanceAccountType +from nautilus_trader.adapters.binance.common.enums import BinanceSymbolFilterType +from nautilus_trader.adapters.binance.common.schemas.market import BinanceSymbolFilter from nautilus_trader.adapters.binance.http.client import BinanceHttpClient from nautilus_trader.adapters.binance.http.error import BinanceClientError from nautilus_trader.adapters.binance.spot.http.market import BinanceSpotMarketHttpAPI from nautilus_trader.adapters.binance.spot.http.wallet import BinanceSpotWalletHttpAPI -from nautilus_trader.adapters.binance.spot.parsing.data import parse_spot_instrument_http -from nautilus_trader.adapters.binance.spot.schemas.market import BinanceSpotExchangeInfo from nautilus_trader.adapters.binance.spot.schemas.market import BinanceSpotSymbolInfo -from nautilus_trader.adapters.binance.spot.schemas.wallet import BinanceSpotTradeFees +from nautilus_trader.adapters.binance.spot.schemas.wallet import BinanceSpotTradeFee +from nautilus_trader.common.clock import LiveClock from nautilus_trader.common.logging import Logger from nautilus_trader.common.providers import InstrumentProvider from nautilus_trader.config import InstrumentProviderConfig from nautilus_trader.core.correctness import PyCondition from nautilus_trader.core.datetime import millis_to_nanos from nautilus_trader.model.identifiers import InstrumentId +from nautilus_trader.model.identifiers import Symbol +from nautilus_trader.model.instruments.currency_pair import CurrencyPair +from nautilus_trader.model.objects import PRICE_MAX +from nautilus_trader.model.objects import PRICE_MIN +from nautilus_trader.model.objects import QUANTITY_MAX +from nautilus_trader.model.objects import QUANTITY_MIN +from nautilus_trader.model.objects import Money +from nautilus_trader.model.objects import Price +from nautilus_trader.model.objects import Quantity class BinanceSpotInstrumentProvider(InstrumentProvider): @@ -52,6 +64,7 @@ def __init__( self, client: BinanceHttpClient, logger: Logger, + clock: LiveClock, account_type: BinanceAccountType = BinanceAccountType.SPOT, config: Optional[InstrumentProviderConfig] = None, ): @@ -63,23 +76,31 @@ def __init__( self._client = client self._account_type = account_type + self._clock = clock - self._http_wallet = BinanceSpotWalletHttpAPI(self._client) - self._http_market = BinanceSpotMarketHttpAPI(self._client) + self._http_wallet = BinanceSpotWalletHttpAPI( + self._client, + clock=self._clock, + account_type=account_type, + ) + self._http_market = BinanceSpotMarketHttpAPI(self._client, account_type=account_type) self._log_warnings = config.log_warnings if config else True + self._decoder = msgspec.json.Decoder() + self._encoder = msgspec.json.Encoder() + async def load_all_async(self, filters: Optional[dict] = None) -> None: filters_str = "..." if not filters else f" with filters {filters}..." self._log.info(f"Loading all instruments{filters_str}") # Get current commission rates if self._client.base_url.__contains__("testnet.binance.vision"): - fees: dict[str, BinanceSpotTradeFees] = {} + fees_dict: dict[str, BinanceSpotTradeFee] = {} else: try: - fee_res: list[BinanceSpotTradeFees] = await self._http_wallet.trade_fees() - fees = {s.symbol: s for s in fee_res} + response = await self._http_wallet.query_spot_trade_fees() + fees_dict = {fee.symbol: fee for fee in response} except BinanceClientError as e: self._log.error( "Cannot load instruments: API key authentication failed " @@ -88,11 +109,11 @@ async def load_all_async(self, filters: Optional[dict] = None) -> None: return # Get exchange info for all assets - exchange_info: BinanceSpotExchangeInfo = await self._http_market.exchange_info() + exchange_info = await self._http_market.query_spot_exchange_info() for symbol_info in exchange_info.symbols: self._parse_instrument( symbol_info=symbol_info, - fees=fees.get(symbol_info.symbol), + fee=fees_dict.get(symbol_info.symbol), ts_event=millis_to_nanos(exchange_info.serverTime), ) @@ -114,8 +135,8 @@ async def load_ids_async( # Get current commission rates try: - fee_res: list[BinanceSpotTradeFees] = await self._http_wallet.trade_fees() - fees: dict[str, BinanceSpotTradeFees] = {s.symbol: s for s in fee_res} + response = await self._http_wallet.query_spot_trade_fees() + fees_dict: dict[str, BinanceSpotTradeFee] = {fee.symbol: fee for fee in response} except BinanceClientError as e: self._log.error( "Cannot load instruments: API key authentication failed " @@ -124,16 +145,17 @@ async def load_ids_async( return # Extract all symbol strings - symbols: list[str] = [instrument_id.symbol.value for instrument_id in instrument_ids] - + symbols = [instrument_id.symbol.value for instrument_id in instrument_ids] # Get exchange info for all assets - exchange_info: BinanceSpotExchangeInfo = await self._http_market.exchange_info( - symbols=symbols, - ) - for symbol_info in exchange_info.symbols: + exchange_info = await self._http_market.query_spot_exchange_info(symbols=symbols) + symbol_info_dict: dict[str, BinanceSpotSymbolInfo] = { + info.symbol: info for info in exchange_info.symbols + } + + for symbol in symbols: self._parse_instrument( - symbol_info=symbol_info, - fees=fees[symbol_info.symbol], + symbol_info=symbol_info_dict[symbol], + fee=fees_dict[symbol], ts_event=millis_to_nanos(exchange_info.serverTime), ) @@ -148,9 +170,8 @@ async def load_async(self, instrument_id: InstrumentId, filters: Optional[dict] # Get current commission rates try: - fees: BinanceSpotTradeFees = await self._http_wallet.trade_fee( - symbol=instrument_id.symbol.value, - ) + trade_fees = await self._http_wallet.query_spot_trade_fees(symbol=symbol) + fees_dict: dict[str, BinanceSpotTradeFee] = {fee.symbol: fee for fee in trade_fees} except BinanceClientError as e: self._log.error( "Cannot load instruments: API key authentication failed " @@ -159,29 +180,108 @@ async def load_async(self, instrument_id: InstrumentId, filters: Optional[dict] return # Get exchange info for asset - exchange_info: BinanceSpotExchangeInfo = await self._http_market.exchange_info( - symbol=symbol, + exchange_info = await self._http_market.query_spot_exchange_info(symbol=symbol) + symbol_info_dict: dict[str, BinanceSpotSymbolInfo] = { + info.symbol: info for info in exchange_info.symbols + } + + self._parse_instrument( + symbol_info=symbol_info_dict[symbol], + fee=fees_dict[symbol], + ts_event=millis_to_nanos(exchange_info.serverTime), ) - for symbol_info in exchange_info.symbols: - self._parse_instrument( - symbol_info=symbol_info, - fees=fees, - ts_event=millis_to_nanos(exchange_info.serverTime), - ) def _parse_instrument( self, symbol_info: BinanceSpotSymbolInfo, - fees: Optional[BinanceSpotTradeFees], + fee: Optional[BinanceSpotTradeFee], ts_event: int, ) -> None: - ts_init = time.time_ns() + ts_init = self._clock.timestamp_ns() try: - instrument = parse_spot_instrument_http( - symbol_info=symbol_info, - fees=fees, + base_currency = symbol_info.parse_to_base_asset() + quote_currency = symbol_info.parse_to_quote_asset() + + native_symbol = Symbol(symbol_info.symbol) + instrument_id = InstrumentId(symbol=native_symbol, venue=BINANCE_VENUE) + + # Parse instrument filters + filters: dict[BinanceSymbolFilterType, BinanceSymbolFilter] = { + f.filterType: f for f in symbol_info.filters + } + price_filter: BinanceSymbolFilter = filters.get(BinanceSymbolFilterType.PRICE_FILTER) + lot_size_filter: BinanceSymbolFilter = filters.get(BinanceSymbolFilterType.LOT_SIZE) + min_notional_filter: BinanceSymbolFilter = filters.get( + BinanceSymbolFilterType.MIN_NOTIONAL, + ) + # market_lot_size_filter = symbol_filters.get("MARKET_LOT_SIZE") + + tick_size = price_filter.tickSize.rstrip("0") + step_size = lot_size_filter.stepSize.rstrip("0") + PyCondition.in_range(float(tick_size), PRICE_MIN, PRICE_MAX, "tick_size") + PyCondition.in_range(float(step_size), QUANTITY_MIN, QUANTITY_MAX, "step_size") + + price_precision = abs(Decimal(tick_size).as_tuple().exponent) + size_precision = abs(Decimal(step_size).as_tuple().exponent) + price_increment = Price.from_str(tick_size) + size_increment = Quantity.from_str(step_size) + lot_size = Quantity.from_str(step_size) + + PyCondition.in_range( + float(lot_size_filter.maxQty), + QUANTITY_MIN, + QUANTITY_MAX, + "maxQty", + ) + PyCondition.in_range( + float(lot_size_filter.minQty), + QUANTITY_MIN, + QUANTITY_MAX, + "minQty", + ) + max_quantity = Quantity(float(lot_size_filter.maxQty), precision=size_precision) + min_quantity = Quantity(float(lot_size_filter.minQty), precision=size_precision) + min_notional = None + if filters.get(BinanceSymbolFilterType.MIN_NOTIONAL): + min_notional = Money(min_notional_filter.minNotional, currency=quote_currency) + max_price = Price( + min(float(price_filter.maxPrice), 4294967296.0), + precision=price_precision, + ) + min_price = Price(max(float(price_filter.minPrice), 0.0), precision=price_precision) + + # Parse fees + maker_fee: Decimal = Decimal(0) + taker_fee: Decimal = Decimal(0) + if fee: + assert fee.symbol == symbol_info.symbol + maker_fee = Decimal(fee.makerCommission) + taker_fee = Decimal(fee.takerCommission) + + # Create instrument + instrument = CurrencyPair( + instrument_id=instrument_id, + native_symbol=native_symbol, + base_currency=base_currency, + quote_currency=quote_currency, + price_precision=price_precision, + size_precision=size_precision, + price_increment=price_increment, + size_increment=size_increment, + lot_size=lot_size, + max_quantity=max_quantity, + min_quantity=min_quantity, + max_notional=None, + min_notional=min_notional, + max_price=max_price, + min_price=min_price, + margin_init=Decimal(0), + margin_maint=Decimal(0), + maker_fee=maker_fee, + taker_fee=taker_fee, ts_event=min(ts_event, ts_init), ts_init=ts_init, + info=self._decoder.decode(self._encoder.encode(symbol_info)), ) self.add_currency(currency=instrument.base_currency) self.add_currency(currency=instrument.quote_currency) diff --git a/nautilus_trader/adapters/binance/spot/schemas/account.py b/nautilus_trader/adapters/binance/spot/schemas/account.py index 7118ff191fbb..59c3c040653f 100644 --- a/nautilus_trader/adapters/binance/spot/schemas/account.py +++ b/nautilus_trader/adapters/binance/spot/schemas/account.py @@ -13,9 +13,16 @@ # limitations under the License. # ------------------------------------------------------------------------------------------------- +from decimal import Decimal +from typing import Optional + import msgspec from nautilus_trader.adapters.binance.common.enums import BinanceAccountType +from nautilus_trader.adapters.binance.common.schemas.account import BinanceOrder +from nautilus_trader.model.currency import Currency +from nautilus_trader.model.objects import AccountBalance +from nautilus_trader.model.objects import Money ################################################################################ @@ -23,7 +30,7 @@ ################################################################################ -class BinanceSpotBalanceInfo(msgspec.Struct): +class BinanceSpotBalanceInfo(msgspec.Struct, frozen=True): """ HTTP response 'inner struct' from `Binance Spot/Margin` GET /api/v3/account (HMAC SHA256). """ @@ -32,8 +39,19 @@ class BinanceSpotBalanceInfo(msgspec.Struct): free: str locked: str + def parse_to_account_balance(self) -> AccountBalance: + currency = Currency.from_str(self.asset) + free = Decimal(self.free) + locked = Decimal(self.locked) + total: Decimal = free + locked + return AccountBalance( + total=Money(total, currency), + locked=Money(locked, currency), + free=Money(free, currency), + ) + -class BinanceSpotAccountInfo(msgspec.Struct): +class BinanceSpotAccountInfo(msgspec.Struct, frozen=True): """ HTTP response from `Binance Spot/Margin` GET /api/v3/account (HMAC SHA256). """ @@ -49,3 +67,24 @@ class BinanceSpotAccountInfo(msgspec.Struct): accountType: BinanceAccountType balances: list[BinanceSpotBalanceInfo] permissions: list[str] + + def parse_to_account_balances(self) -> list[AccountBalance]: + return [balance.parse_to_account_balance() for balance in self.balances] + + +class BinanceSpotOrderOco(msgspec.Struct, frozen=True): + """ + HTTP response from `Binance Spot/Margin` GET /api/v3/orderList (HMAC SHA256). + HTTP response from `Binance Spot/Margin` POST /api/v3/order/oco (HMAC SHA256). + HTTP response from `Binance Spot/Margin` DELETE /api/v3/orderList (HMAC SHA256). + """ + + orderListId: int + contingencyType: str + listStatusType: str + listOrderStatus: str + listClientOrderId: str + transactionTime: int + symbol: str + orders: Optional[list[BinanceOrder]] = None # Included for ACK response type + orderReports: Optional[list[BinanceOrder]] = None # Included for FULL & RESPONSE types diff --git a/nautilus_trader/adapters/binance/spot/schemas/market.py b/nautilus_trader/adapters/binance/spot/schemas/market.py index b35fa3e60f72..17e2f96b8cd9 100644 --- a/nautilus_trader/adapters/binance/spot/schemas/market.py +++ b/nautilus_trader/adapters/binance/spot/schemas/market.py @@ -13,16 +13,26 @@ # limitations under the License. # ------------------------------------------------------------------------------------------------- -from typing import Optional import msgspec -from nautilus_trader.adapters.binance.common.enums import BinanceExchangeFilterType -from nautilus_trader.adapters.binance.common.enums import BinanceRateLimitInterval -from nautilus_trader.adapters.binance.common.enums import BinanceRateLimitType -from nautilus_trader.adapters.binance.common.enums import BinanceSymbolFilterType -from nautilus_trader.adapters.binance.spot.enums import BinanceSpotOrderType +from nautilus_trader.adapters.binance.common.enums import BinanceOrderType +from nautilus_trader.adapters.binance.common.schemas.market import BinanceExchangeFilter +from nautilus_trader.adapters.binance.common.schemas.market import BinanceOrderBookDelta +from nautilus_trader.adapters.binance.common.schemas.market import BinanceRateLimit +from nautilus_trader.adapters.binance.common.schemas.market import BinanceSymbolFilter from nautilus_trader.adapters.binance.spot.enums import BinanceSpotPermissions +from nautilus_trader.core.datetime import millis_to_nanos +from nautilus_trader.model.currency import Currency +from nautilus_trader.model.data.tick import TradeTick +from nautilus_trader.model.enums import AggressorSide +from nautilus_trader.model.enums import BookType +from nautilus_trader.model.enums import CurrencyType +from nautilus_trader.model.identifiers import InstrumentId +from nautilus_trader.model.identifiers import TradeId +from nautilus_trader.model.objects import Price +from nautilus_trader.model.objects import Quantity +from nautilus_trader.model.orderbook.data import OrderBookSnapshot ################################################################################ @@ -30,51 +40,8 @@ ################################################################################ -class BinanceExchangeFilter(msgspec.Struct): - """HTTP response 'inner struct' from `Binance Spot/Margin` GET /fapi/v1/exchangeInfo.""" - - filterType: BinanceExchangeFilterType - maxNumOrders: Optional[int] = None - maxNumAlgoOrders: Optional[int] = None - - -class BinanceSymbolFilter(msgspec.Struct): - """HTTP response 'inner struct' from `Binance Spot/Margin` GET /fapi/v1/exchangeInfo.""" - - filterType: BinanceSymbolFilterType - minPrice: Optional[str] = None - maxPrice: Optional[str] = None - tickSize: Optional[str] = None - multiplierUp: Optional[str] = None - multiplierDown: Optional[str] = None - avgPriceMins: Optional[int] = None - bidMultiplierUp: Optional[str] = None - bidMultiplierDown: Optional[str] = None - askMultiplierUp: Optional[str] = None - askMultiplierDown: Optional[str] = None - minQty: Optional[str] = None - maxQty: Optional[str] = None - stepSize: Optional[str] = None - minNotional: Optional[str] = None - applyToMarket: Optional[bool] = None - limit: Optional[int] = None - maxNumOrders: Optional[int] = None - maxNumAlgoOrders: Optional[int] = None - maxNumIcebergOrders: Optional[int] = None - maxPosition: Optional[str] = None - - -class BinanceRateLimit(msgspec.Struct): - """HTTP response 'inner struct' from `Binance Spot/Margin` GET /fapi/v1/exchangeInfo.""" - - rateLimitType: BinanceRateLimitType - interval: BinanceRateLimitInterval - intervalNum: int - limit: int - - -class BinanceSpotSymbolInfo(msgspec.Struct): - """HTTP response 'inner struct' from `Binance Spot/Margin` GET /fapi/v1/exchangeInfo.""" +class BinanceSpotSymbolInfo(msgspec.Struct, frozen=True): + """HTTP response 'inner struct' from `Binance Spot/Margin` GET /api/v3/exchangeInfo.""" symbol: str status: str @@ -83,7 +50,7 @@ class BinanceSpotSymbolInfo(msgspec.Struct): quoteAsset: str quotePrecision: int quoteAssetPrecision: int - orderTypes: list[BinanceSpotOrderType] + orderTypes: list[BinanceOrderType] icebergAllowed: bool ocoAllowed: bool quoteOrderQtyMarketAllowed: bool @@ -93,9 +60,27 @@ class BinanceSpotSymbolInfo(msgspec.Struct): filters: list[BinanceSymbolFilter] permissions: list[BinanceSpotPermissions] + def parse_to_base_asset(self): + return Currency( + code=self.baseAsset, + precision=self.baseAssetPrecision, + iso4217=0, # Currently undetermined for crypto assets + name=self.baseAsset, + currency_type=CurrencyType.CRYPTO, + ) + + def parse_to_quote_asset(self): + return Currency( + code=self.baseAsset, + precision=self.baseAssetPrecision, + iso4217=0, # Currently undetermined for crypto assets + name=self.baseAsset, + currency_type=CurrencyType.CRYPTO, + ) -class BinanceSpotExchangeInfo(msgspec.Struct): - """HTTP response from `Binance Spot/Margin` GET /fapi/v1/exchangeInfo.""" + +class BinanceSpotExchangeInfo(msgspec.Struct, frozen=True): + """HTTP response from `Binance Spot/Margin` GET /api/v3/exchangeInfo.""" timezone: str serverTime: int @@ -104,12 +89,11 @@ class BinanceSpotExchangeInfo(msgspec.Struct): symbols: list[BinanceSpotSymbolInfo] -class BinanceSpotOrderBookDepthData(msgspec.Struct): - """HTTP response from `Binance` GET /fapi/v1/depth.""" +class BinanceSpotAvgPrice(msgspec.Struct, frozen=True): + """HTTP response from `Binance Spot/Margin` GET /api/v3/avgPrice.""" - lastUpdateId: int - bids: list[tuple[str, str]] - asks: list[tuple[str, str]] + mins: int + price: str ################################################################################ @@ -117,11 +101,34 @@ class BinanceSpotOrderBookDepthData(msgspec.Struct): ################################################################################ -class BinanceSpotOrderBookMsg(msgspec.Struct): - """WebSocket message.""" +class BinanceSpotOrderBookPartialDepthData(msgspec.Struct): + """Websocket message 'inner struct' for 'Binance Spot/Margin Partial Book Depth Streams.'""" + + lastUpdateId: int + bids: list[BinanceOrderBookDelta] + asks: list[BinanceOrderBookDelta] + + def parse_to_order_book_snapshot( + self, + instrument_id: InstrumentId, + ts_init: int, + ) -> OrderBookSnapshot: + return OrderBookSnapshot( + instrument_id=instrument_id, + book_type=BookType.L2_MBP, + bids=[[float(o.price), float(o.size)] for o in self.bids], + asks=[[float(o.price), float(o.size)] for o in self.asks], + ts_event=ts_init, + ts_init=ts_init, + sequence=self.lastUpdateId, + ) + + +class BinanceSpotOrderBookPartialDepthMsg(msgspec.Struct): + """WebSocket message for 'Binance Spot/Margin' Partial Book Depth Streams.""" stream: str - data: BinanceSpotOrderBookDepthData + data: BinanceSpotOrderBookPartialDepthData class BinanceSpotTradeData(msgspec.Struct): @@ -153,6 +160,21 @@ class BinanceSpotTradeData(msgspec.Struct): T: int # Trade time m: bool # Is the buyer the market maker? + def parse_to_trade_tick( + self, + instrument_id: InstrumentId, + ts_init: int, + ) -> TradeTick: + return TradeTick( + instrument_id=instrument_id, + price=Price.from_str(self.p), + size=Quantity.from_str(self.q), + aggressor_side=AggressorSide.SELLER if self.m else AggressorSide.BUYER, + trade_id=TradeId(str(self.t)), + ts_event=millis_to_nanos(self.T), + ts_init=ts_init, + ) + class BinanceSpotTradeMsg(msgspec.Struct): """WebSocket message from `Binance` Trade Streams.""" diff --git a/nautilus_trader/adapters/binance/spot/schemas/user.py b/nautilus_trader/adapters/binance/spot/schemas/user.py index c9ae88b0b3b0..2e89d950e9a7 100644 --- a/nautilus_trader/adapters/binance/spot/schemas/user.py +++ b/nautilus_trader/adapters/binance/spot/schemas/user.py @@ -13,16 +13,37 @@ # limitations under the License. # ------------------------------------------------------------------------------------------------- +from decimal import Decimal from typing import Optional import msgspec +from nautilus_trader.adapters.binance.common.enums import BinanceEnumParser from nautilus_trader.adapters.binance.common.enums import BinanceExecutionType from nautilus_trader.adapters.binance.common.enums import BinanceOrderSide from nautilus_trader.adapters.binance.common.enums import BinanceOrderStatus +from nautilus_trader.adapters.binance.common.enums import BinanceOrderType +from nautilus_trader.adapters.binance.common.enums import BinanceTimeInForce +from nautilus_trader.adapters.binance.common.execution import BinanceCommonExecutionClient from nautilus_trader.adapters.binance.spot.enums import BinanceSpotEventType -from nautilus_trader.adapters.binance.spot.enums import BinanceSpotOrderType -from nautilus_trader.adapters.binance.spot.enums import BinanceSpotTimeInForce +from nautilus_trader.core.datetime import millis_to_nanos +from nautilus_trader.core.uuid import UUID4 +from nautilus_trader.execution.reports import OrderStatusReport +from nautilus_trader.model.currency import Currency +from nautilus_trader.model.enums import LiquiditySide +from nautilus_trader.model.enums import OrderSide +from nautilus_trader.model.enums import OrderStatus +from nautilus_trader.model.enums import TrailingOffsetType +from nautilus_trader.model.enums import TriggerType +from nautilus_trader.model.identifiers import AccountId +from nautilus_trader.model.identifiers import ClientOrderId +from nautilus_trader.model.identifiers import InstrumentId +from nautilus_trader.model.identifiers import TradeId +from nautilus_trader.model.identifiers import VenueOrderId +from nautilus_trader.model.objects import AccountBalance +from nautilus_trader.model.objects import Money +from nautilus_trader.model.objects import Price +from nautilus_trader.model.objects import Quantity ################################################################################ @@ -30,7 +51,7 @@ ################################################################################ -class BinanceSpotUserMsgData(msgspec.Struct): +class BinanceSpotUserMsgData(msgspec.Struct, frozen=True): """ Inner struct for execution WebSocket messages from `Binance` """ @@ -38,7 +59,7 @@ class BinanceSpotUserMsgData(msgspec.Struct): e: BinanceSpotEventType -class BinanceSpotUserMsgWrapper(msgspec.Struct): +class BinanceSpotUserMsgWrapper(msgspec.Struct, frozen=True): """ Provides a wrapper for execution WebSocket messages from `Binance`. """ @@ -47,15 +68,26 @@ class BinanceSpotUserMsgWrapper(msgspec.Struct): data: BinanceSpotUserMsgData -class BinanceSpotBalance(msgspec.Struct): +class BinanceSpotBalance(msgspec.Struct, frozen=True): """Inner struct for `Binance Spot/Margin` balances.""" a: str # Asset f: str # Free l: str # Locked + def parse_to_account_balance(self) -> AccountBalance: + currency = Currency.from_str(self.a) + free = Decimal(self.f) + locked = Decimal(self.l) + total: Decimal = free + locked + return AccountBalance( + total=Money(total, currency), + locked=Money(locked, currency), + free=Money(free, currency), + ) -class BinanceSpotAccountUpdateMsg(msgspec.Struct): + +class BinanceSpotAccountUpdateMsg(msgspec.Struct, frozen=True): """WebSocket message for `Binance Spot/Margin` Account Update events.""" e: str # Event Type @@ -63,8 +95,20 @@ class BinanceSpotAccountUpdateMsg(msgspec.Struct): u: int # Transaction Time B: list[BinanceSpotBalance] + def parse_to_account_balances(self) -> list[AccountBalance]: + return [balance.parse_to_account_balance() for balance in self.B] + + def handle_account_update(self, exec_client: BinanceCommonExecutionClient): + """Handle BinanceSpotAccountUpdateMsg as payload of outboundAccountPosition""" + exec_client.generate_account_state( + balances=self.parse_to_account_balances(), + margins=[], + reported=True, + ts_event=millis_to_nanos(self.u), + ) -class BinanceSpotAccountUpdateWrapper(msgspec.Struct): + +class BinanceSpotAccountUpdateWrapper(msgspec.Struct, frozen=True): """WebSocket message wrapper for `Binance Spot/Margin` Account Update events.""" stream: str @@ -82,8 +126,8 @@ class BinanceSpotOrderUpdateData(msgspec.Struct, kw_only=True): s: str # Symbol c: str # Client order ID S: BinanceOrderSide - o: BinanceSpotOrderType - f: BinanceSpotTimeInForce + o: BinanceOrderType + f: BinanceTimeInForce q: str # Original Quantity p: str # Original Price P: str # Stop price @@ -110,8 +154,136 @@ class BinanceSpotOrderUpdateData(msgspec.Struct, kw_only=True): Y: str # Last quote asset transacted quantity (i.e. lastPrice * lastQty) Q: str # Quote Order Qty + def parse_to_order_status_report( + self, + account_id: AccountId, + instrument_id: InstrumentId, + client_order_id: ClientOrderId, + venue_order_id: VenueOrderId, + ts_event: int, + ts_init: int, + enum_parser: BinanceEnumParser, + ) -> OrderStatusReport: + price = Price.from_str(self.p) if self.p is not None else None + trigger_price = Price.from_str(self.P) if self.P is not None else None + order_side = (OrderSide.BUY if self.S == BinanceOrderSide.BUY else OrderSide.SELL,) + post_only = self.f == BinanceTimeInForce.GTX + display_qty = ( + Quantity.from_str( + str(Decimal(self.q) - Decimal(self.F)), + ) + if self.F is not None + else None + ) + + return OrderStatusReport( + account_id=account_id, + instrument_id=instrument_id, + client_order_id=client_order_id, + venue_order_id=venue_order_id, + order_side=order_side, + order_type=enum_parser.parse_binance_order_type(self.o), + time_in_force=enum_parser.parse_binance_time_in_force(self.f), + order_status=OrderStatus.ACCEPTED, + price=price, + trigger_price=trigger_price, + trigger_type=TriggerType.LAST_TRADE, + trailing_offset=None, + trailing_offset_type=TrailingOffsetType.NO_TRAILING_OFFSET, + quantity=Quantity.from_str(self.q), + filled_qty=Quantity.from_str(self.z), + display_qty=display_qty, + avg_px=None, + post_only=post_only, + reduce_only=False, + report_id=UUID4(), + ts_accepted=ts_event, + ts_last=ts_event, + ts_init=ts_init, + ) + + def handle_execution_report( + self, + exec_client: BinanceCommonExecutionClient, + ): + """Handle BinanceSpotOrderUpdateData as payload of executionReport event.""" + client_order_id_str: str = self.c + if not client_order_id_str or not client_order_id_str.startswith("O"): + client_order_id_str = self.C + client_order_id = ClientOrderId(client_order_id_str) + ts_event = millis_to_nanos(self.T) + venue_order_id = VenueOrderId(str(self.i)) + instrument_id = exec_client._get_cached_instrument_id(self.s) + strategy_id = exec_client._cache.strategy_id_for_order(client_order_id) + if strategy_id is None: + report = self.parse_to_order_status_report( + account_id=exec_client.account_id, + instrument_id=instrument_id, + client_order_id=client_order_id, + venue_order_id=venue_order_id, + ts_event=ts_event, + ts_init=exec_client._clock.timestamp_ns(), + enum_parser=exec_client._enum_parser, + ) + exec_client._send_order_status_report(report) + elif self.x == BinanceExecutionType.NEW: + exec_client.generate_order_accepted( + strategy_id=strategy_id, + instrument_id=instrument_id, + client_order_id=client_order_id, + venue_order_id=venue_order_id, + ts_event=ts_event, + ) + elif self.x == BinanceExecutionType.TRADE: + instrument = exec_client._instrument_provider.find(instrument_id=instrument_id) + + # Determine commission + commission_asset: str = self.N + commission_amount: str = self.n + if commission_asset is not None: + commission = Money.from_str(f"{commission_amount} {commission_asset}") + else: + # Binance typically charges commission as base asset or BNB + commission = Money(0, instrument.base_currency) + + exec_client.generate_order_filled( + strategy_id=strategy_id, + instrument_id=instrument_id, + client_order_id=client_order_id, + venue_order_id=venue_order_id, + venue_position_id=None, # NETTING accounts + trade_id=TradeId(str(self.t)), # Trade ID + order_side=exec_client._enum_parser.parse_binance_order_side(self.S), + order_type=exec_client._enum_parser.parse_binance_order_type(self.o), + last_qty=Quantity.from_str(self.l), + last_px=Price.from_str(self.L), + quote_currency=instrument.quote_currency, + commission=commission, + liquidity_side=LiquiditySide.MAKER if self.m else LiquiditySide.TAKER, + ts_event=ts_event, + ) + elif self.x == BinanceExecutionType.CANCELED: + exec_client.generate_order_canceled( + strategy_id=strategy_id, + instrument_id=instrument_id, + client_order_id=client_order_id, + venue_order_id=venue_order_id, + ts_event=ts_event, + ) + elif self.x == BinanceExecutionType.EXPIRED: + exec_client.generate_order_expired( + strategy_id=strategy_id, + instrument_id=instrument_id, + client_order_id=client_order_id, + venue_order_id=venue_order_id, + ts_event=ts_event, + ) + else: + # Event not handled + exec_client._log.warning(f"Received unhandled {self}") + -class BinanceSpotOrderUpdateWrapper(msgspec.Struct): +class BinanceSpotOrderUpdateWrapper(msgspec.Struct, frozen=True): """WebSocket message wrapper for `Binance Spot/Margin` Order Update events.""" stream: str diff --git a/nautilus_trader/adapters/binance/spot/schemas/wallet.py b/nautilus_trader/adapters/binance/spot/schemas/wallet.py index 8b37aa765090..2412b2200e38 100644 --- a/nautilus_trader/adapters/binance/spot/schemas/wallet.py +++ b/nautilus_trader/adapters/binance/spot/schemas/wallet.py @@ -21,8 +21,8 @@ ################################################################################ -class BinanceSpotTradeFees(msgspec.Struct): - """HTTP response from `Binance Spot/Margin` GET /sapi/v1/asset/tradeFee (HMAC SHA256).""" +class BinanceSpotTradeFee(msgspec.Struct, frozen=True): + """Schema of a single `Binance Spot/Margin` tradeFee""" symbol: str makerCommission: str diff --git a/nautilus_trader/adapters/binance/websocket/client.py b/nautilus_trader/adapters/binance/websocket/client.py index 58f7ca23d85a..c780223fb58e 100644 --- a/nautilus_trader/adapters/binance/websocket/client.py +++ b/nautilus_trader/adapters/binance/websocket/client.py @@ -16,7 +16,7 @@ import asyncio from typing import Callable, Optional -from nautilus_trader.adapters.binance.common.functions import format_symbol +from nautilus_trader.adapters.binance.common.schemas.symbol import BinanceSymbol from nautilus_trader.common.clock import LiveClock from nautilus_trader.common.logging import Logger from nautilus_trader.network.websocket import WebSocketClient @@ -104,7 +104,7 @@ def subscribe_agg_trades(self, symbol: str): Update Speed: Real-time """ - self._add_stream(f"{format_symbol(symbol).lower()}@aggTrade") + self._add_stream(f"{BinanceSymbol(symbol).lower()}@aggTrade") def subscribe_trades(self, symbol: str): """ @@ -115,7 +115,7 @@ def subscribe_trades(self, symbol: str): Update Speed: Real-time """ - self._add_stream(f"{format_symbol(symbol).lower()}@trade") + self._add_stream(f"{BinanceSymbol(symbol).lower()}@trade") def subscribe_bars(self, symbol: str, interval: str): """ @@ -143,7 +143,7 @@ def subscribe_bars(self, symbol: str, interval: str): Update Speed: 2000ms """ - self._add_stream(f"{format_symbol(symbol).lower()}@kline_{interval}") + self._add_stream(f"{BinanceSymbol(symbol).lower()}@kline_{interval}") def subscribe_mini_ticker(self, symbol: str = None): """ @@ -159,7 +159,7 @@ def subscribe_mini_ticker(self, symbol: str = None): if symbol is None: self._add_stream("!miniTicker@arr") else: - self._add_stream(f"{format_symbol(symbol).lower()}@miniTicker") + self._add_stream(f"{BinanceSymbol(symbol).lower()}@miniTicker") def subscribe_ticker(self, symbol: str = None): """ @@ -175,7 +175,7 @@ def subscribe_ticker(self, symbol: str = None): if symbol is None: self._add_stream("!ticker@arr") else: - self._add_stream(f"{format_symbol(symbol).lower()}@ticker") + self._add_stream(f"{BinanceSymbol(symbol).lower()}@ticker") def subscribe_book_ticker(self, symbol: str = None): """ @@ -190,7 +190,7 @@ def subscribe_book_ticker(self, symbol: str = None): if symbol is None: self._add_stream("!bookTicker") else: - self._add_stream(f"{format_symbol(symbol).lower()}@bookTicker") + self._add_stream(f"{BinanceSymbol(symbol).lower()}@bookTicker") def subscribe_partial_book_depth(self, symbol: str, depth: int, speed: int): """ @@ -201,7 +201,7 @@ def subscribe_partial_book_depth(self, symbol: str, depth: int, speed: int): Update Speed: 1000ms or 100ms """ - self._add_stream(f"{format_symbol(symbol).lower()}@depth{depth}@{speed}ms") + self._add_stream(f"{BinanceSymbol(symbol).lower()}@depth{depth}@{speed}ms") def subscribe_diff_book_depth(self, symbol: str, speed: int): """ @@ -212,7 +212,7 @@ def subscribe_diff_book_depth(self, symbol: str, speed: int): Order book price and quantity depth updates used to locally manage an order book. """ - self._add_stream(f"{format_symbol(symbol).lower()}@depth@{speed}ms") + self._add_stream(f"{BinanceSymbol(symbol).lower()}@depth@{speed}ms") def subscribe_mark_price(self, symbol: str = None, speed: int = None): """ @@ -227,4 +227,4 @@ def subscribe_mark_price(self, symbol: str = None, speed: int = None): if symbol is None: self._add_stream("!markPrice@arr") else: - self._add_stream(f"{format_symbol(symbol).lower()}@markPrice@{int(speed / 1000)}s") + self._add_stream(f"{BinanceSymbol(symbol).lower()}@markPrice@{int(speed / 1000)}s") diff --git a/tests/integration_tests/adapters/binance/test_core_functions.py b/tests/integration_tests/adapters/binance/test_core_functions.py index b5271de8e1c6..51f212c5bf4c 100644 --- a/tests/integration_tests/adapters/binance/test_core_functions.py +++ b/tests/integration_tests/adapters/binance/test_core_functions.py @@ -16,8 +16,8 @@ import pytest from nautilus_trader.adapters.binance.common.enums import BinanceAccountType -from nautilus_trader.adapters.binance.common.functions import convert_symbols_list_to_json_array -from nautilus_trader.adapters.binance.common.functions import format_symbol +from nautilus_trader.adapters.binance.common.schemas.symbol import BinanceSymbol +from nautilus_trader.adapters.binance.common.schemas.symbol import BinanceSymbols class TestBinanceCoreFunctions: @@ -26,7 +26,7 @@ def test_format_symbol(self): symbol = "ethusdt-perp" # Act - result = format_symbol(symbol) + result = BinanceSymbol(symbol) # Assert assert result == "ETHUSDT" @@ -36,7 +36,7 @@ def test_convert_symbols_list_to_json_array(self): symbols = ["BTCUSDT", "ETHUSDT-PERP", " XRDUSDT"] # Act - result = convert_symbols_list_to_json_array(symbols) + result = BinanceSymbols(symbols) # Assert assert result == '["BTCUSDT","ETHUSDT","XRDUSDT"]' @@ -45,7 +45,8 @@ def test_convert_symbols_list_to_json_array(self): "account_type, expected", [ [BinanceAccountType.SPOT, True], - [BinanceAccountType.MARGIN, False], + [BinanceAccountType.MARGIN_CROSS, False], + [BinanceAccountType.MARGIN_ISOLATED, False], [BinanceAccountType.FUTURES_USDT, False], [BinanceAccountType.FUTURES_COIN, False], ], @@ -58,7 +59,8 @@ def test_binance_account_type_is_spot(self, account_type, expected): "account_type, expected", [ [BinanceAccountType.SPOT, False], - [BinanceAccountType.MARGIN, True], + [BinanceAccountType.MARGIN_CROSS, True], + [BinanceAccountType.MARGIN_ISOLATED, True], [BinanceAccountType.FUTURES_USDT, False], [BinanceAccountType.FUTURES_COIN, False], ], @@ -67,11 +69,26 @@ def test_binance_account_type_is_margin(self, account_type, expected): # Arrange, Act, Assert assert account_type.is_margin == expected + @pytest.mark.parametrize( + "account_type, expected", + [ + [BinanceAccountType.SPOT, True], + [BinanceAccountType.MARGIN_CROSS, True], + [BinanceAccountType.MARGIN_ISOLATED, True], + [BinanceAccountType.FUTURES_USDT, False], + [BinanceAccountType.FUTURES_COIN, False], + ], + ) + def test_binance_account_type_is_spot_or_margin(self, account_type, expected): + # Arrange, Act, Assert + assert account_type.is_spot_or_margin == expected + @pytest.mark.parametrize( "account_type, expected", [ [BinanceAccountType.SPOT, False], - [BinanceAccountType.MARGIN, False], + [BinanceAccountType.MARGIN_CROSS, False], + [BinanceAccountType.MARGIN_ISOLATED, False], [BinanceAccountType.FUTURES_USDT, True], [BinanceAccountType.FUTURES_COIN, True], ], diff --git a/tests/integration_tests/adapters/binance/test_data_spot.py b/tests/integration_tests/adapters/binance/test_data_spot.py index f4e27cafb813..92a5b3199750 100644 --- a/tests/integration_tests/adapters/binance/test_data_spot.py +++ b/tests/integration_tests/adapters/binance/test_data_spot.py @@ -77,6 +77,7 @@ def setup(self): self.provider = BinanceSpotInstrumentProvider( client=self.http_client, logger=self.logger, + clock=self.clock, config=InstrumentProviderConfig(load_all=True), ) diff --git a/tests/integration_tests/adapters/binance/test_execution_futures.py b/tests/integration_tests/adapters/binance/test_execution_futures.py index fdf7973fb796..ba0fc586b2f1 100644 --- a/tests/integration_tests/adapters/binance/test_execution_futures.py +++ b/tests/integration_tests/adapters/binance/test_execution_futures.py @@ -80,6 +80,7 @@ def setup(self): self.provider = BinanceFuturesInstrumentProvider( client=self.http_client, logger=self.logger, + clock=self.clock, config=InstrumentProviderConfig(load_all=True), ) @@ -249,7 +250,6 @@ async def test_submit_limit_post_only_order(self, mocker): assert request[2]["type"] == "LIMIT" assert request[2]["timeInForce"] == "GTX" assert request[2]["quantity"] == "10" - assert request[2]["reduceOnly"] == "false" assert request[2]["price"] == "10050.80" assert request[2]["newClientOrderId"] is not None assert request[2]["recvWindow"] == "5000" @@ -293,7 +293,7 @@ async def test_submit_stop_market_order(self, mocker): assert request[2]["type"] == "STOP_MARKET" assert request[2]["timeInForce"] == "GTC" assert request[2]["quantity"] == "10" - assert request[2]["reduceOnly"] == "true" + assert request[2]["reduceOnly"] == "True" assert request[2]["newClientOrderId"] is not None assert request[2]["stopPrice"] == "10099.00" assert request[2]["workingType"] == "CONTRACT_PRICE" @@ -426,7 +426,6 @@ async def test_submit_limit_if_touched_order(self, mocker): assert request[2]["type"] == "TAKE_PROFIT" assert request[2]["timeInForce"] == "GTC" assert request[2]["quantity"] == "10" - assert request[2]["reduceOnly"] == "false" assert request[2]["price"] == "10050.80" assert request[2]["newClientOrderId"] is not None assert request[2]["stopPrice"] == "10099.00" @@ -475,7 +474,7 @@ async def test_trailing_stop_market_order(self, mocker): assert request[2]["type"] == "TRAILING_STOP_MARKET" assert request[2]["timeInForce"] == "GTC" assert request[2]["quantity"] == "10" - assert request[2]["reduceOnly"] == "true" + assert request[2]["reduceOnly"] == "True" assert request[2]["newClientOrderId"] is not None assert request[2]["activationPrice"] == "10000.00" assert request[2]["callbackRate"] == "1" diff --git a/tests/integration_tests/adapters/binance/test_execution_spot.py b/tests/integration_tests/adapters/binance/test_execution_spot.py index c49c1fb87c2e..89f7725eebdd 100644 --- a/tests/integration_tests/adapters/binance/test_execution_spot.py +++ b/tests/integration_tests/adapters/binance/test_execution_spot.py @@ -80,6 +80,7 @@ def setup(self): self.provider = BinanceSpotInstrumentProvider( client=self.http_client, logger=self.logger, + clock=self.clock, config=InstrumentProviderConfig(load_all=True), ) diff --git a/tests/integration_tests/adapters/binance/test_factories.py b/tests/integration_tests/adapters/binance/test_factories.py index e92c4e7e3883..b468e4292b33 100644 --- a/tests/integration_tests/adapters/binance/test_factories.py +++ b/tests/integration_tests/adapters/binance/test_factories.py @@ -77,7 +77,13 @@ def setup(self): "https://api.binance.com", ], [ - BinanceAccountType.MARGIN, + BinanceAccountType.MARGIN_CROSS, + False, + False, + "https://sapi.binance.com", + ], + [ + BinanceAccountType.MARGIN_ISOLATED, False, False, "https://sapi.binance.com", @@ -101,7 +107,13 @@ def setup(self): "https://api.binance.us", ], [ - BinanceAccountType.MARGIN, + BinanceAccountType.MARGIN_CROSS, + False, + True, + "https://sapi.binance.us", + ], + [ + BinanceAccountType.MARGIN_ISOLATED, False, True, "https://sapi.binance.us", @@ -125,7 +137,13 @@ def setup(self): "https://testnet.binance.vision", ], [ - BinanceAccountType.MARGIN, + BinanceAccountType.MARGIN_CROSS, + True, + False, + "https://testnet.binance.vision", + ], + [ + BinanceAccountType.MARGIN_ISOLATED, True, False, "https://testnet.binance.vision", @@ -155,7 +173,13 @@ def test_get_http_base_url(self, account_type, is_testnet, is_us, expected): "wss://stream.binance.com:9443", ], [ - BinanceAccountType.MARGIN, + BinanceAccountType.MARGIN_CROSS, + False, + False, + "wss://stream.binance.com:9443", + ], + [ + BinanceAccountType.MARGIN_ISOLATED, False, False, "wss://stream.binance.com:9443", @@ -179,7 +203,13 @@ def test_get_http_base_url(self, account_type, is_testnet, is_us, expected): "wss://stream.binance.us:9443", ], [ - BinanceAccountType.MARGIN, + BinanceAccountType.MARGIN_CROSS, + False, + True, + "wss://stream.binance.us:9443", + ], + [ + BinanceAccountType.MARGIN_ISOLATED, False, True, "wss://stream.binance.us:9443", @@ -203,7 +233,13 @@ def test_get_http_base_url(self, account_type, is_testnet, is_us, expected): "wss://testnet.binance.vision", ], [ - BinanceAccountType.MARGIN, + BinanceAccountType.MARGIN_CROSS, + True, + False, + "wss://testnet.binance.vision", + ], + [ + BinanceAccountType.MARGIN_ISOLATED, True, False, "wss://testnet.binance.vision", diff --git a/tests/integration_tests/adapters/binance/test_http_account.py b/tests/integration_tests/adapters/binance/test_http_account.py index b1f315e6339b..207bd323fb4a 100644 --- a/tests/integration_tests/adapters/binance/test_http_account.py +++ b/tests/integration_tests/adapters/binance/test_http_account.py @@ -17,6 +17,7 @@ import pytest +from nautilus_trader.adapters.binance.http.account import BinanceOrderHttp from nautilus_trader.adapters.binance.http.client import BinanceHttpClient from nautilus_trader.adapters.binance.spot.http.account import BinanceSpotAccountHttpAPI from nautilus_trader.common.clock import LiveClock @@ -27,17 +28,19 @@ class TestBinanceSpotAccountHttpAPI: def setup(self): # Fixture Setup - clock = LiveClock() - logger = Logger(clock=clock) + self.clock = LiveClock() + logger = Logger(clock=self.clock) self.client = BinanceHttpClient( # noqa: S106 (no hardcoded password) loop=asyncio.get_event_loop(), - clock=clock, + clock=self.clock, logger=logger, key="SOME_BINANCE_API_KEY", secret="SOME_BINANCE_API_SECRET", ) - self.api = BinanceSpotAccountHttpAPI(self.client) + self.api = BinanceSpotAccountHttpAPI(self.client, self.clock) + + # COMMON tests @pytest.mark.asyncio async def test_new_order_test_sends_expected_request(self, mocker): @@ -45,15 +48,24 @@ async def test_new_order_test_sends_expected_request(self, mocker): await self.client.connect() mock_send_request = mocker.patch(target="aiohttp.client.ClientSession.request") + endpoint = BinanceOrderHttp( + client=self.client, + base_endpoint="/api/v3", + testing_endpoint=True, + ) + # Act - await self.api.new_order_test( - symbol="ETHUSDT", - side="SELL", - type="LIMIT", - time_in_force="GTC", - quantity="0.01", - price="5000", - recv_window=5000, + await endpoint._post( + parameters=endpoint.PostParameters( + symbol="ETHUSDT", + side="SELL", + type="LIMIT", + timeInForce="GTC", + quantity="0.01", + price="5000", + recvWindow=str(5000), + timestamp=str(self.clock.timestamp_ms()), + ), ) # Assert @@ -65,7 +77,7 @@ async def test_new_order_test_sends_expected_request(self, mocker): ) @pytest.mark.asyncio - async def test_order_test_sends_expected_request(self, mocker): + async def test_new_order_sends_expected_request(self, mocker): # Arrange await self.client.connect() mock_send_request = mocker.patch(target="aiohttp.client.ClientSession.request") @@ -74,11 +86,11 @@ async def test_order_test_sends_expected_request(self, mocker): await self.api.new_order( symbol="ETHUSDT", side="SELL", - type="LIMIT", + order_type="LIMIT", time_in_force="GTC", quantity="0.01", price="5000", - recv_window=5000, + recv_window=str(5000), ) # Assert @@ -109,13 +121,13 @@ async def test_cancel_order_sends_expected_request(self, mocker): assert request["params"].startswith("symbol=ETHUSDT&orderId=1&recvWindow=5000×tamp=") @pytest.mark.asyncio - async def test_cancel_open_orders_sends_expected_request(self, mocker): + async def test_cancel_all_open_orders_sends_expected_request(self, mocker): # Arrange await self.client.connect() mock_send_request = mocker.patch(target="aiohttp.client.ClientSession.request") # Act - await self.api.cancel_open_orders( + await self.api.cancel_all_open_orders( symbol="ETHUSDT", recv_window=5000, ) @@ -127,13 +139,13 @@ async def test_cancel_open_orders_sends_expected_request(self, mocker): assert request["params"].startswith("symbol=ETHUSDT&recvWindow=5000×tamp=") @pytest.mark.asyncio - async def test_get_order_sends_expected_request(self, mocker): + async def test_query_order_sends_expected_request(self, mocker): # Arrange await self.client.connect() mock_send_request = mocker.patch(target="aiohttp.client.ClientSession.request") # Act - await self.api.get_order( + await self.api.query_order( symbol="ETHUSDT", order_id="1", recv_window=5000, @@ -146,13 +158,13 @@ async def test_get_order_sends_expected_request(self, mocker): assert request["params"].startswith("symbol=ETHUSDT&orderId=1&recvWindow=5000×tamp=") @pytest.mark.asyncio - async def test_get_open_orders_sends_expected_request(self, mocker): + async def test_query_open_orders_sends_expected_request(self, mocker): # Arrange await self.client.connect() mock_send_request = mocker.patch(target="aiohttp.client.ClientSession.request") # Act - await self.api.get_open_orders( + await self.api.query_open_orders( symbol="ETHUSDT", recv_window=5000, ) @@ -164,13 +176,13 @@ async def test_get_open_orders_sends_expected_request(self, mocker): assert request["params"].startswith("symbol=ETHUSDT&recvWindow=5000×tamp=") @pytest.mark.asyncio - async def test_get_orders_sends_expected_request(self, mocker): + async def test_query_all_orders_sends_expected_request(self, mocker): # Arrange await self.client.connect() mock_send_request = mocker.patch(target="aiohttp.client.ClientSession.request") # Act - await self.api.get_orders( + await self.api.query_all_orders( symbol="ETHUSDT", recv_window=5000, ) @@ -182,13 +194,38 @@ async def test_get_orders_sends_expected_request(self, mocker): assert request["params"].startswith("symbol=ETHUSDT&recvWindow=5000×tamp=") @pytest.mark.asyncio - async def test_new_oco_order_sends_expected_request(self, mocker): + async def test_query_user_trades_sends_expected_request(self, mocker): + # Arrange + await self.client.connect() + mock_send_request = mocker.patch(target="aiohttp.client.ClientSession.request") + + # Act + await self.api.query_user_trades( + symbol="ETHUSDT", + start_time=str(1600000000), + end_time=str(1637355823), + limit=1000, + recv_window=str(5000), + ) + + # Assert + request = mock_send_request.call_args.kwargs + assert request["method"] == "GET" + assert request["url"] == "https://api.binance.com/api/v3/myTrades" + assert request["params"].startswith( + "symbol=ETHUSDT&fromId=1&orderId=1&startTime=1600000000&endTime=1637355823&limit=1000&recvWindow=5000×tamp=", + ) + + # SPOT/MARGIN tests + + @pytest.mark.asyncio + async def test_new_spot_oco_sends_expected_request(self, mocker): # Arrange await self.client.connect() mock_send_request = mocker.patch(target="aiohttp.client.ClientSession.request") # Act - await self.api.new_oco_order( + await self.api.new_spot_oco( symbol="ETHUSDT", side="BUY", quantity="100", @@ -213,13 +250,13 @@ async def test_new_oco_order_sends_expected_request(self, mocker): ) @pytest.mark.asyncio - async def test_cancel_oco_order_sends_expected_request(self, mocker): + async def test_cancel_spot_oco_sends_expected_request(self, mocker): # Arrange await self.client.connect() mock_send_request = mocker.patch(target="aiohttp.client.ClientSession.request") # Act - await self.api.cancel_oco_order( + await self.api.cancel_spot_oco( symbol="ETHUSDT", order_list_id="1", list_client_order_id="1", @@ -236,13 +273,13 @@ async def test_cancel_oco_order_sends_expected_request(self, mocker): ) @pytest.mark.asyncio - async def test_get_oco_order_sends_expected_request(self, mocker): + async def test_query_spot_oco_sends_expected_request(self, mocker): # Arrange await self.client.connect() mock_send_request = mocker.patch(target="aiohttp.client.ClientSession.request") # Act - await self.api.get_oco_order( + await self.api.query_spot_oco( order_list_id="1", orig_client_order_id="1", recv_window=5000, @@ -257,18 +294,17 @@ async def test_get_oco_order_sends_expected_request(self, mocker): ) @pytest.mark.asyncio - async def test_get_oco_orders_sends_expected_request(self, mocker): + async def test_query_spot_all_oco_sends_expected_request(self, mocker): # Arrange await self.client.connect() mock_send_request = mocker.patch(target="aiohttp.client.ClientSession.request") # Act - await self.api.get_oco_orders( - from_id="1", - start_time=1600000000, - end_time=1637355823, + await self.api.query_spot_all_oco( + start_time=str(1600000000), + end_time=str(1637355823), limit=10, - recv_window=5000, + recv_window=str(5000), ) # Assert @@ -276,17 +312,17 @@ async def test_get_oco_orders_sends_expected_request(self, mocker): assert request["method"] == "GET" assert request["url"] == "https://api.binance.com/api/v3/allOrderList" assert request["params"].startswith( - "fromId=1&startTime=1600000000&endTime=1637355823&limit=10&recvWindow=5000×tamp=", + "startTime=1600000000&endTime=1637355823&limit=10&recvWindow=5000×tamp=", ) @pytest.mark.asyncio - async def test_get_open_oco_orders_sends_expected_request(self, mocker): + async def test_query_spot_all_open_oco_sends_expected_request(self, mocker): # Arrange await self.client.connect() mock_send_request = mocker.patch(target="aiohttp.client.ClientSession.request") # Act - await self.api.get_oco_open_orders(recv_window=5000) + await self.api.query_spot_all_open_oco(recv_window=5000) # Assert request = mock_send_request.call_args.kwargs @@ -295,41 +331,16 @@ async def test_get_open_oco_orders_sends_expected_request(self, mocker): assert request["params"].startswith("recvWindow=5000×tamp=") @pytest.mark.asyncio - async def test_account_sends_expected_request(self, mocker): + async def test_query_spot_account_info_sends_expected_request(self, mocker): # Arrange await self.client.connect() mock_send_request = mocker.patch(target="aiohttp.client.ClientSession.request") # Act - await self.api.account(recv_window=5000) + await self.api.query_spot_account_info(recv_window=5000) # Assert request = mock_send_request.call_args.kwargs assert request["method"] == "GET" assert request["url"] == "https://api.binance.com/api/v3/account" assert request["params"].startswith("recvWindow=5000×tamp=") - - @pytest.mark.asyncio - async def test_my_trades_sends_expected_request(self, mocker): - # Arrange - await self.client.connect() - mock_send_request = mocker.patch(target="aiohttp.client.ClientSession.request") - - # Act - await self.api.get_account_trades( - symbol="ETHUSDT", - from_id="1", - order_id="1", - start_time=1600000000, - end_time=1637355823, - limit=1000, - recv_window=5000, - ) - - # Assert - request = mock_send_request.call_args.kwargs - assert request["method"] == "GET" - assert request["url"] == "https://api.binance.com/api/v3/myTrades" - assert request["params"].startswith( - "symbol=ETHUSDT&fromId=1&orderId=1&startTime=1600000000&endTime=1637355823&limit=1000&recvWindow=5000×tamp=", - ) diff --git a/tests/integration_tests/adapters/binance/test_http_market.py b/tests/integration_tests/adapters/binance/test_http_market.py index ed43bebbc1cc..e1f65a04ffc0 100644 --- a/tests/integration_tests/adapters/binance/test_http_market.py +++ b/tests/integration_tests/adapters/binance/test_http_market.py @@ -38,6 +38,10 @@ def setup(self): ) self.api = BinanceSpotMarketHttpAPI(self.client) + self.test_symbol = "BTCUSDT" + self.test_symbols = ["BTCUSDT", "ETHUSDT"] + + # COMMON tests @pytest.mark.asyncio async def test_ping_sends_expected_request(self, mocker): @@ -54,13 +58,13 @@ async def test_ping_sends_expected_request(self, mocker): assert request["url"] == "https://api.binance.com/api/v3/ping" @pytest.mark.asyncio - async def test_time_sends_expected_request(self, mocker): + async def test_request_server_time_sends_expected_request(self, mocker): # Arrange await self.client.connect() mock_send_request = mocker.patch(target="aiohttp.client.ClientSession.request") # Act - await self.api.time() + await self.api.request_server_time() # Assert request = mock_send_request.call_args.kwargs @@ -68,178 +72,180 @@ async def test_time_sends_expected_request(self, mocker): assert request["url"] == "https://api.binance.com/api/v3/time" @pytest.mark.asyncio - async def test_exchange_info_with_symbol_sends_expected_request(self, mocker): + async def test_query_depth_sends_expected_request(self, mocker): # Arrange await self.client.connect() mock_send_request = mocker.patch(target="aiohttp.client.ClientSession.request") # Act - await self.api.exchange_info(symbol="BTCUSDT") + await self.api.query_depth(symbol=self.test_symbol, limit=10) # Assert request = mock_send_request.call_args.kwargs assert request["method"] == "GET" - assert request["url"] == "https://api.binance.com/api/v3/exchangeInfo" - assert request["params"] == "symbol=BTCUSDT" + assert request["url"] == "https://api.binance.com/api/v3/depth" + assert request["params"] == "symbol=BTCUSDT&limit=10" @pytest.mark.asyncio - async def test_exchange_info_with_symbols_sends_expected_request(self, mocker): + async def test_query_trades_sends_expected_request(self, mocker): # Arrange await self.client.connect() mock_send_request = mocker.patch(target="aiohttp.client.ClientSession.request") # Act - await self.api.exchange_info(symbols=["BTCUSDT", "ETHUSDT"]) + await self.api.query_trades(symbol=self.test_symbol, limit=10) # Assert request = mock_send_request.call_args.kwargs assert request["method"] == "GET" - assert request["url"] == "https://api.binance.com/api/v3/exchangeInfo" - assert request["params"] == "symbols=%5B%22BTCUSDT%22%2C%22ETHUSDT%22%5D" + assert request["url"] == "https://api.binance.com/api/v3/trades" + assert request["params"] == "symbol=BTCUSDT&limit=10" @pytest.mark.asyncio - async def test_depth_sends_expected_request(self, mocker): + async def test_query_historical_trades_sends_expected_request(self, mocker): # Arrange await self.client.connect() mock_send_request = mocker.patch(target="aiohttp.client.ClientSession.request") # Act - await self.api.depth(symbol="BTCUSDT", limit=10) + await self.api.query_historical_trades(symbol=self.test_symbol, limit=10, from_id=0) # Assert request = mock_send_request.call_args.kwargs assert request["method"] == "GET" - assert request["url"] == "https://api.binance.com/api/v3/depth" - assert request["params"] == "symbol=BTCUSDT&limit=10" + assert request["url"] == "https://api.binance.com/api/v3/historicalTrades" + assert request["params"] == "symbol=BTCUSDT&limit=10&fromId=0" @pytest.mark.asyncio - async def test_trades_sends_expected_request(self, mocker): + async def test_query_agg_trades_sends_expected_request(self, mocker): # Arrange await self.client.connect() mock_send_request = mocker.patch(target="aiohttp.client.ClientSession.request") # Act - await self.api.trades(symbol="BTCUSDT", limit=10) + await self.api.query_agg_trades( + symbol=self.test_symbol, + from_id=0, + start_time=0, + end_time=1, + limit=10, + ) # Assert request = mock_send_request.call_args.kwargs assert request["method"] == "GET" - assert request["url"] == "https://api.binance.com/api/v3/trades" - assert request["params"] == "symbol=BTCUSDT&limit=10" + assert request["url"] == "https://api.binance.com/api/v3/aggTrades" + assert request["params"] == "symbol=BTCUSDT&fromId=0&startTime=0&endTime=1&limit=10" @pytest.mark.asyncio - async def test_historical_trades_sends_expected_request(self, mocker): + async def test_query_klines_sends_expected_request(self, mocker): # Arrange await self.client.connect() mock_send_request = mocker.patch(target="aiohttp.client.ClientSession.request") # Act - await self.api.historical_trades(symbol="BTCUSDT", from_id=0, limit=10) + await self.api.query_klines( + symbol=self.test_symbol, + interval="1m", + start_time=0, + end_time=1, + limit=1000, + ) # Assert request = mock_send_request.call_args.kwargs assert request["method"] == "GET" - assert request["url"] == "https://api.binance.com/api/v3/historicalTrades" - assert request["params"] == "symbol=BTCUSDT&limit=10&fromId=0" + assert request["url"] == "https://api.binance.com/api/v3/klines" + assert request["params"] == "symbol=BTCUSDT&interval=1m&startTime=0&endTime=1&limit=1000" @pytest.mark.asyncio - async def test_agg_trades_sends_expected_request(self, mocker): + async def test_query_ticker_24hr_sends_expected_request(self, mocker): # Arrange await self.client.connect() mock_send_request = mocker.patch(target="aiohttp.client.ClientSession.request") # Act - await self.api.agg_trades( - symbol="BTCUSDT", - from_id=0, - start_time_ms=0, - end_time_ms=1, - limit=10, - ) + await self.api.query_ticker_24hr(symbol=self.test_symbol) # Assert request = mock_send_request.call_args.kwargs assert request["method"] == "GET" - assert request["url"] == "https://api.binance.com/api/v3/aggTrades" - assert request["params"] == "symbol=BTCUSDT&fromId=0&startTime=0&endTime=1&limit=10" + assert request["url"] == "https://api.binance.com/api/v3/ticker/24hr" + assert request["params"] == "symbol=BTCUSDT" @pytest.mark.asyncio - async def test_klines_sends_expected_request(self, mocker): + async def test_query_ticker_price_sends_expected_request(self, mocker): # Arrange await self.client.connect() mock_send_request = mocker.patch(target="aiohttp.client.ClientSession.request") # Act - await self.api.klines( - symbol="BTCUSDT", - interval="1m", - start_time_ms=0, - end_time_ms=1, - limit=1000, - ) + await self.api.query_ticker_price(symbol=self.test_symbol) # Assert request = mock_send_request.call_args.kwargs assert request["method"] == "GET" - assert request["url"] == "https://api.binance.com/api/v3/klines" - assert request["params"] == "symbol=BTCUSDT&interval=1m&startTime=0&endTime=1&limit=1000" + assert request["url"] == "https://api.binance.com/api/v3/ticker/price" + assert request["params"] == "symbol=BTCUSDT" @pytest.mark.asyncio - async def test_avg_price_sends_expected_request(self, mocker): + async def test_query_book_ticker_sends_expected_request(self, mocker): # Arrange await self.client.connect() mock_send_request = mocker.patch(target="aiohttp.client.ClientSession.request") # Act - await self.api.avg_price(symbol="BTCUSDT") + await self.api.query_ticker_book(symbol=self.test_symbol) # Assert request = mock_send_request.call_args.kwargs assert request["method"] == "GET" - assert request["url"] == "https://api.binance.com/api/v3/avgPrice" + assert request["url"] == "https://api.binance.com/api/v3/ticker/bookTicker" assert request["params"] == "symbol=BTCUSDT" + # SPOT/MARGIN tests + @pytest.mark.asyncio - async def test_ticker_24hr_sends_expected_request(self, mocker): + async def test_query_spot_exchange_info_with_symbol_sends_expected_request(self, mocker): # Arrange await self.client.connect() mock_send_request = mocker.patch(target="aiohttp.client.ClientSession.request") # Act - await self.api.ticker_24hr(symbol="BTCUSDT") + await self.api.query_spot_exchange_info(symbol=self.test_symbol) # Assert request = mock_send_request.call_args.kwargs assert request["method"] == "GET" - assert request["url"] == "https://api.binance.com/api/v3/ticker/24hr" + assert request["url"] == "https://api.binance.com/api/v3/exchangeInfo" assert request["params"] == "symbol=BTCUSDT" @pytest.mark.asyncio - async def test_ticker_price_sends_expected_request(self, mocker): + async def test_query_spot_exchange_info_with_symbols_sends_expected_request(self, mocker): # Arrange await self.client.connect() mock_send_request = mocker.patch(target="aiohttp.client.ClientSession.request") # Act - await self.api.ticker_price(symbol="BTCUSDT") + await self.api.query_spot_exchange_info(symbols=self.test_symbols) # Assert request = mock_send_request.call_args.kwargs assert request["method"] == "GET" - assert request["url"] == "https://api.binance.com/api/v3/ticker/price" - assert request["params"] == "symbol=BTCUSDT" + assert request["url"] == "https://api.binance.com/api/v3/exchangeInfo" + assert request["params"] == "symbols=%5B%22BTCUSDT%22%2C%22ETHUSDT%22%5D" @pytest.mark.asyncio - async def test_book_ticker_sends_expected_request(self, mocker): + async def test_query_spot_avg_price_sends_expected_request(self, mocker): # Arrange await self.client.connect() mock_send_request = mocker.patch(target="aiohttp.client.ClientSession.request") # Act - await self.api.book_ticker(symbol="BTCUSDT") + await self.api.query_spot_average_price(symbol=self.test_symbol) # Assert request = mock_send_request.call_args.kwargs assert request["method"] == "GET" - assert request["url"] == "https://api.binance.com/api/v3/ticker/bookTicker" + assert request["url"] == "https://api.binance.com/api/v3/avgPrice" assert request["params"] == "symbol=BTCUSDT" diff --git a/tests/integration_tests/adapters/binance/test_http_user.py b/tests/integration_tests/adapters/binance/test_http_user.py index 8f7abc01f846..4a98a37b2a06 100644 --- a/tests/integration_tests/adapters/binance/test_http_user.py +++ b/tests/integration_tests/adapters/binance/test_http_user.py @@ -17,6 +17,7 @@ import pytest +from nautilus_trader.adapters.binance.common.enums import BinanceAccountType from nautilus_trader.adapters.binance.http.client import BinanceHttpClient from nautilus_trader.adapters.binance.spot.http.user import BinanceSpotUserDataHttpAPI from nautilus_trader.common.clock import LiveClock @@ -36,8 +37,12 @@ def setup(self): key="SOME_BINANCE_API_KEY", secret="SOME_BINANCE_API_SECRET", ) - - self.api = BinanceSpotUserDataHttpAPI(self.client) + self.test_symbol = "ETHUSDT" + self.spot_api = BinanceSpotUserDataHttpAPI(self.client, BinanceAccountType.SPOT) + self.isolated_margin_api = BinanceSpotUserDataHttpAPI( + self.client, + BinanceAccountType.MARGIN_ISOLATED, + ) @pytest.mark.asyncio async def test_create_listen_key_spot(self, mocker): @@ -46,7 +51,7 @@ async def test_create_listen_key_spot(self, mocker): mock_send_request = mocker.patch(target="aiohttp.client.ClientSession.request") # Act - await self.api.create_listen_key() + await self.spot_api.create_listen_key() # Assert request = mock_send_request.call_args.kwargs @@ -54,14 +59,14 @@ async def test_create_listen_key_spot(self, mocker): assert request["url"] == "https://api.binance.com/api/v3/userDataStream" @pytest.mark.asyncio - async def test_ping_listen_key_spot(self, mocker): + async def test_keepalive_listen_key_spot(self, mocker): # Arrange await self.client.connect() mock_send_request = mocker.patch(target="aiohttp.client.ClientSession.request") # Act - await self.api.ping_listen_key( - key="JUdsZc8CSmMUxg1wJha23RogrT3EuC8eV5UTbAOVTkF3XWofMzWoXtWmDAhy", + await self.spot_api.keepalive_listen_key( + listen_key="JUdsZc8CSmMUxg1wJha23RogrT3EuC8eV5UTbAOVTkF3XWofMzWoXtWmDAhy", ) # Assert @@ -74,14 +79,14 @@ async def test_ping_listen_key_spot(self, mocker): ) @pytest.mark.asyncio - async def test_close_listen_key_spot(self, mocker): + async def test_delete_listen_key_spot(self, mocker): # Arrange await self.client.connect() mock_send_request = mocker.patch(target="aiohttp.client.ClientSession.request") # Act - await self.api.close_listen_key( - key="JUdsZc8CSmMUxg1wJha23RogrT3EuC8eV5UTbAOVTkF3XWofMzWoXtWmDAhy", + await self.spot_api.delete_listen_key( + listen_key="JUdsZc8CSmMUxg1wJha23RogrT3EuC8eV5UTbAOVTkF3XWofMzWoXtWmDAhy", ) # Assert @@ -100,7 +105,7 @@ async def test_create_listen_key_isolated_margin(self, mocker): mock_send_request = mocker.patch(target="aiohttp.client.ClientSession.request") # Act - await self.api.create_listen_key_isolated_margin(symbol="ETHUSDT") + await self.isolated_margin_api.create_listen_key(symbol=self.test_symbol) # Assert request = mock_send_request.call_args.kwargs @@ -109,15 +114,15 @@ async def test_create_listen_key_isolated_margin(self, mocker): assert request["params"] == "symbol=ETHUSDT" @pytest.mark.asyncio - async def test_ping_listen_key_isolated_margin(self, mocker): + async def test_keepalive_listen_key_isolated_margin(self, mocker): # Arrange await self.client.connect() mock_send_request = mocker.patch(target="aiohttp.client.ClientSession.request") # Act - await self.api.ping_listen_key_isolated_margin( - symbol="ETHUSDT", - key="JUdsZc8CSmMUxg1wJha23RogrT3EuC8eV5UTbAOVTkF3XWofMzWoXtWmDAhy", + await self.isolated_margin_api.keepalive_listen_key( + symbol=self.test_symbol, + listen_key="JUdsZc8CSmMUxg1wJha23RogrT3EuC8eV5UTbAOVTkF3XWofMzWoXtWmDAhy", ) # Assert @@ -130,15 +135,15 @@ async def test_ping_listen_key_isolated_margin(self, mocker): ) @pytest.mark.asyncio - async def test_close_listen_key_isolated_margin(self, mocker): + async def test_delete_listen_key_isolated_margin(self, mocker): # Arrange await self.client.connect() mock_send_request = mocker.patch(target="aiohttp.client.ClientSession.request") # Act - await self.api.close_listen_key_isolated_margin( - symbol="ETHUSDT", - key="JUdsZc8CSmMUxg1wJha23RogrT3EuC8eV5UTbAOVTkF3XWofMzWoXtWmDAhy", + await self.isolated_margin_api.delete_listen_key( + symbol=self.test_symbol, + listen_key="JUdsZc8CSmMUxg1wJha23RogrT3EuC8eV5UTbAOVTkF3XWofMzWoXtWmDAhy", ) # Assert diff --git a/tests/integration_tests/adapters/binance/test_http_wallet.py b/tests/integration_tests/adapters/binance/test_http_wallet.py index 799bbd3eb162..cd68c1befc95 100644 --- a/tests/integration_tests/adapters/binance/test_http_wallet.py +++ b/tests/integration_tests/adapters/binance/test_http_wallet.py @@ -21,7 +21,7 @@ from nautilus_trader.adapters.binance.http.client import BinanceHttpClient from nautilus_trader.adapters.binance.spot.http.wallet import BinanceSpotWalletHttpAPI -from nautilus_trader.adapters.binance.spot.schemas.wallet import BinanceSpotTradeFees +from nautilus_trader.adapters.binance.spot.schemas.wallet import BinanceSpotTradeFee from nautilus_trader.common.clock import LiveClock from nautilus_trader.common.logging import Logger @@ -39,7 +39,7 @@ def setup(self): secret="SOME_BINANCE_API_SECRET", ) - self.api = BinanceSpotWalletHttpAPI(self.client) + self.api = BinanceSpotWalletHttpAPI(self.client, clock) @pytest.mark.asyncio async def test_trade_fee(self, mocker): @@ -58,7 +58,7 @@ async def async_mock(): ) # Act - response: BinanceSpotTradeFees = await self.api.trade_fee(symbol="BTCUSDT") + response = await self.api.query_spot_trade_fees(symbol="BTCUSDT") # Assert name, args, kwargs = mock_request.call_args[0] @@ -67,7 +67,8 @@ async def async_mock(): assert kwargs["symbol"] == "BTCUSDT" assert "signature" in kwargs assert "timestamp" in kwargs - assert isinstance(response, BinanceSpotTradeFees) + assert len(response) == 1 + assert isinstance(response[0], BinanceSpotTradeFee) @pytest.mark.asyncio async def test_trade_fees(self, mocker): @@ -86,7 +87,7 @@ async def async_mock(): ) # Act - response: list[BinanceSpotTradeFees] = await self.api.trade_fees() + response = await self.api.query_spot_trade_fees() # Assert name, args, kwargs = mock_request.call_args[0] @@ -95,5 +96,5 @@ async def async_mock(): assert "signature" in kwargs assert "timestamp" in kwargs assert len(response) == 2 - assert isinstance(response[0], BinanceSpotTradeFees) - assert isinstance(response[1], BinanceSpotTradeFees) + assert isinstance(response[0], BinanceSpotTradeFee) + assert isinstance(response[1], BinanceSpotTradeFee) diff --git a/tests/integration_tests/adapters/binance/test_parsing_common.py b/tests/integration_tests/adapters/binance/test_parsing_common.py index c24b2574af1f..57e9116b38c6 100644 --- a/tests/integration_tests/adapters/binance/test_parsing_common.py +++ b/tests/integration_tests/adapters/binance/test_parsing_common.py @@ -15,10 +15,9 @@ import pytest -from nautilus_trader.adapters.binance.common.parsing.data import parse_bar_ws -from nautilus_trader.adapters.binance.common.schemas import BinanceCandlestick -from nautilus_trader.adapters.binance.spot.enums import BinanceSpotOrderType -from nautilus_trader.adapters.binance.spot.parsing.execution import parse_order_type +from nautilus_trader.adapters.binance.common.enums import BinanceOrderType +from nautilus_trader.adapters.binance.common.schemas.market import BinanceCandlestick +from nautilus_trader.adapters.binance.spot.enums import BinanceSpotEnumParser from nautilus_trader.backtest.data.providers import TestInstrumentProvider from nautilus_trader.core.datetime import millis_to_nanos from nautilus_trader.model.data.bar import BarSpecification @@ -33,20 +32,25 @@ class TestBinanceCommonParsing: + def __init__(self) -> None: + self._spot_enum_parser = BinanceSpotEnumParser() + @pytest.mark.parametrize( "order_type, expected", [ - [BinanceSpotOrderType.MARKET, OrderType.MARKET], - [BinanceSpotOrderType.LIMIT, OrderType.LIMIT], - [BinanceSpotOrderType.STOP, OrderType.STOP_MARKET], - [BinanceSpotOrderType.STOP_LOSS, OrderType.STOP_MARKET], - [BinanceSpotOrderType.TAKE_PROFIT, OrderType.LIMIT], - [BinanceSpotOrderType.TAKE_PROFIT_LIMIT, OrderType.STOP_LIMIT], + [BinanceOrderType.LIMIT, OrderType.LIMIT], + [BinanceOrderType.MARKET, OrderType.MARKET], + [BinanceOrderType.STOP, OrderType.STOP_MARKET], + [BinanceOrderType.STOP_LOSS, OrderType.STOP_MARKET], + [BinanceOrderType.STOP_LOSS_LIMIT, OrderType.STOP_LIMIT], + [BinanceOrderType.TAKE_PROFIT, OrderType.LIMIT], + [BinanceOrderType.TAKE_PROFIT_LIMIT, OrderType.STOP_LIMIT], + [BinanceOrderType.LIMIT_MAKER, OrderType.LIMIT], ], ) def test_parse_order_type(self, order_type, expected): # Arrange, # Act - result = parse_order_type(order_type) + result = self._spot_enum_parser.parse_binance_order_type(order_type) # Assert assert result == expected @@ -199,9 +203,9 @@ def test_parse_parse_bar_ws(self, resolution, expected_type): ) # Act - bar = parse_bar_ws( + bar = candle.parse_to_binance_bar( instrument_id=BTCUSDT_BINANCE.id, - data=candle, + enum_parser=self._spot_enum_parser, ts_init=millis_to_nanos(1638747720000), ) diff --git a/tests/integration_tests/adapters/binance/test_parsing_http.py b/tests/integration_tests/adapters/binance/test_parsing_http.py index 6a8abeabbc13..16cb15560992 100644 --- a/tests/integration_tests/adapters/binance/test_parsing_http.py +++ b/tests/integration_tests/adapters/binance/test_parsing_http.py @@ -17,8 +17,7 @@ import msgspec -from nautilus_trader.adapters.binance.spot.parsing.data import parse_spot_book_snapshot -from nautilus_trader.adapters.binance.spot.schemas.market import BinanceSpotOrderBookDepthData +from nautilus_trader.adapters.binance.common.schemas.market import BinanceDepth from nautilus_trader.backtest.data.providers import TestInstrumentProvider @@ -34,9 +33,10 @@ def test_parse_book_snapshot(self): ) # Act - result = parse_spot_book_snapshot( + decoder = msgspec.json.Decoder(BinanceDepth) + data = decoder.decode(raw) + result = data.parse_to_order_book_snapshot( instrument_id=ETHUSDT.id, - data=msgspec.json.decode(raw, type=BinanceSpotOrderBookDepthData), ts_init=2, ) diff --git a/tests/integration_tests/adapters/binance/test_parsing_ws.py b/tests/integration_tests/adapters/binance/test_parsing_ws.py index c27c5aad7785..10643243e074 100644 --- a/tests/integration_tests/adapters/binance/test_parsing_ws.py +++ b/tests/integration_tests/adapters/binance/test_parsing_ws.py @@ -17,8 +17,7 @@ import msgspec -from nautilus_trader.adapters.binance.common.parsing.data import parse_ticker_24hr_ws -from nautilus_trader.adapters.binance.common.schemas import BinanceTickerData +from nautilus_trader.adapters.binance.common.schemas.market import BinanceTickerData from nautilus_trader.backtest.data.providers import TestInstrumentProvider @@ -34,9 +33,10 @@ def test_parse_ticker(self): ) # Act - result = parse_ticker_24hr_ws( + decoder = msgspec.json.Decoder(BinanceTickerData) + data = decoder.decode(raw) + result = data.parse_to_binance_ticker( instrument_id=ETHUSDT.id, - data=msgspec.json.decode(raw, type=BinanceTickerData), ts_init=9999999999999991, ) diff --git a/tests/integration_tests/adapters/binance/test_providers.py b/tests/integration_tests/adapters/binance/test_providers.py index 557361cb6d94..52ffb5372867 100644 --- a/tests/integration_tests/adapters/binance/test_providers.py +++ b/tests/integration_tests/adapters/binance/test_providers.py @@ -22,6 +22,7 @@ from nautilus_trader.adapters.binance.futures.providers import BinanceFuturesInstrumentProvider from nautilus_trader.adapters.binance.http.client import BinanceHttpClient from nautilus_trader.adapters.binance.spot.providers import BinanceSpotInstrumentProvider +from nautilus_trader.common.clock import LiveClock from nautilus_trader.model.identifiers import InstrumentId from nautilus_trader.model.identifiers import Symbol from nautilus_trader.model.identifiers import Venue @@ -29,6 +30,10 @@ @pytest.mark.skip(reason="WIP") class TestBinanceInstrumentProvider: + def setup(self): + # Fixture Setup + self.clock = LiveClock() + @pytest.mark.asyncio async def test_load_all_async_for_spot_markets( self, @@ -68,6 +73,7 @@ async def mock_send_request( self.provider = BinanceSpotInstrumentProvider( client=binance_http_client, logger=live_logger, + clock=self.clock, account_type=BinanceAccountType.SPOT, ) @@ -122,6 +128,7 @@ async def mock_send_request( self.provider = BinanceFuturesInstrumentProvider( client=binance_http_client, logger=live_logger, + clock=self.clock, account_type=BinanceAccountType.FUTURES_USDT, ) From 5698494d3fa035fc6759cdb1de909e57e7ead911 Mon Sep 17 00:00:00 2001 From: ghill2 Date: Fri, 3 Feb 2023 07:12:29 +0000 Subject: [PATCH 09/81] Implement rust catalog read and write from Python (#986) --- nautilus_trader/model/data/tick.pxd | 12 + nautilus_trader/model/data/tick.pyx | 61 ++- nautilus_trader/persistence/base.py | 56 --- nautilus_trader/persistence/batching.py | 91 +++++ nautilus_trader/persistence/catalog/base.py | 2 +- .../persistence/catalog/parquet.py | 135 +++++-- nautilus_trader/persistence/external/core.py | 101 +++-- nautilus_trader/persistence/external/util.py | 118 ++++++ nautilus_trader/test_kit/mocks/data.py | 2 +- tests/test_data/bars_eurusd_2019_sim.parquet | Bin 0 -> 249174 bytes .../quote_tick_eurusd_2019_sim_rust.parquet | Bin 0 -> 482530 bytes .../quote_tick_usdjpy_2019_sim_rust.parquet | Bin 0 -> 482530 bytes .../persistence/external/test_core.py | 100 ++++- .../persistence/external/test_util.py | 118 +++++- tests/unit_tests/persistence/test_batching.py | 363 +++++++++++++++++- tests/unit_tests/persistence/test_catalog.py | 277 ++++++++++--- .../persistence/test_catalog_rust.py | 101 ++++- 17 files changed, 1350 insertions(+), 187 deletions(-) delete mode 100644 nautilus_trader/persistence/base.py create mode 100644 nautilus_trader/persistence/external/util.py create mode 100644 tests/test_data/bars_eurusd_2019_sim.parquet create mode 100644 tests/test_data/quote_tick_eurusd_2019_sim_rust.parquet create mode 100644 tests/test_data/quote_tick_usdjpy_2019_sim_rust.parquet diff --git a/nautilus_trader/model/data/tick.pxd b/nautilus_trader/model/data/tick.pxd index 4c0657de5d83..481378085403 100644 --- a/nautilus_trader/model/data/tick.pxd +++ b/nautilus_trader/model/data/tick.pxd @@ -54,6 +54,9 @@ cdef class QuoteTick(Data): @staticmethod cdef list capsule_to_quote_tick_list(object capsule) + @staticmethod + cdef object quote_tick_list_to_capsule(list items) + @staticmethod cdef QuoteTick from_dict_c(dict values) @@ -88,8 +91,17 @@ cdef class TradeTick(Data): @staticmethod cdef list capsule_to_trade_tick_list(object capsule) + @staticmethod + cdef object trade_tick_list_to_capsule(list items) + @staticmethod cdef TradeTick from_dict_c(dict values) @staticmethod cdef dict to_dict_c(TradeTick obj) + + @staticmethod + cdef TradeTick from_mem_c(TradeTick_t mem) + + @staticmethod + cdef list capsule_to_trade_tick_list(object capsule) diff --git a/nautilus_trader/model/data/tick.pyx b/nautilus_trader/model/data/tick.pyx index 8fc24587f4da..c037814e3b16 100644 --- a/nautilus_trader/model/data/tick.pyx +++ b/nautilus_trader/model/data/tick.pyx @@ -12,8 +12,11 @@ # See the License for the specific language governing permissions and # limitations under the License. # ------------------------------------------------------------------------------------------------- - +from cpython.mem cimport PyMem_Free +from cpython.mem cimport PyMem_Malloc +from cpython.pycapsule cimport PyCapsule_Destructor from cpython.pycapsule cimport PyCapsule_GetPointer +from cpython.pycapsule cimport PyCapsule_New from libc.stdint cimport int64_t from libc.stdint cimport uint8_t from libc.stdint cimport uint64_t @@ -45,6 +48,12 @@ from nautilus_trader.model.objects cimport Price from nautilus_trader.model.objects cimport Quantity +cdef void capsule_destructor(object capsule): + cdef CVec* cvec = PyCapsule_GetPointer(capsule, NULL) + PyMem_Free(cvec[0].ptr) # de-allocate buffer + PyMem_Free(cvec) # de-allocate cvec + + cdef class QuoteTick(Data): """ Represents a single quote tick in a financial market. @@ -300,10 +309,35 @@ cdef class QuoteTick(Data): return ticks + @staticmethod + cdef inline quote_tick_list_to_capsule(list items): + + # create a C struct buffer + cdef uint64_t len_ = len(items) + cdef QuoteTick_t * data = PyMem_Malloc(len_ * sizeof(QuoteTick_t)) + cdef uint64_t i + for i in range(len_): + data[i] = ( items[i])._mem + if not data: + raise MemoryError() + + # create CVec + cdef CVec * cvec = PyMem_Malloc(1 * sizeof(CVec)) + cvec.ptr = data + cvec.len = len_ + cvec.cap = len_ + + # create PyCapsule + return PyCapsule_New(cvec, NULL, capsule_destructor) + @staticmethod def list_from_capsule(capsule) -> list[QuoteTick]: return QuoteTick.capsule_to_quote_tick_list(capsule) + @staticmethod + def capsule_from_list(items): + return QuoteTick.quote_tick_list_to_capsule(items) + @staticmethod def from_raw( InstrumentId instrument_id, @@ -667,10 +701,35 @@ cdef class TradeTick(Data): return ticks + @staticmethod + cdef inline trade_tick_list_to_capsule(list items): + + # create a C struct buffer + cdef uint64_t len_ = len(items) + cdef TradeTick_t * data = PyMem_Malloc(len_ * sizeof(TradeTick_t)) + cdef uint64_t i + for i in range(len_): + data[i] = ( items[i])._mem + if not data: + raise MemoryError() + + # create CVec + cdef CVec * cvec = PyMem_Malloc(1 * sizeof(CVec)) + cvec.ptr = data + cvec.len = len_ + cvec.cap = len_ + + # create PyCapsule + return PyCapsule_New(cvec, NULL, capsule_destructor) + @staticmethod def list_from_capsule(capsule) -> list[TradeTick]: return TradeTick.capsule_to_trade_tick_list(capsule) + @staticmethod + def capsule_from_list(items): + return TradeTick.trade_tick_list_to_capsule(items) + @staticmethod cdef TradeTick from_dict_c(dict values): Condition.not_none(values, "values") diff --git a/nautilus_trader/persistence/base.py b/nautilus_trader/persistence/base.py deleted file mode 100644 index 99ccad28b337..000000000000 --- a/nautilus_trader/persistence/base.py +++ /dev/null @@ -1,56 +0,0 @@ -# ------------------------------------------------------------------------------------------------- -# Copyright (C) 2015-2023 Nautech Systems Pty Ltd. All rights reserved. -# https://nautechsystems.io -# -# Licensed under the GNU Lesser General Public License Version 3.0 (the "License"); -# You may not use this file except in compliance with the License. -# You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# ------------------------------------------------------------------------------------------------- - -import inspect - - -def freeze_dict(dict_like: dict): - return tuple(sorted(dict_like.items())) - - -def check_value(v): - if isinstance(v, dict): - return freeze_dict(dict_like=v) - return v - - -def resolve_kwargs(func, *args, **kwargs): - kw = inspect.getcallargs(func, *args, **kwargs) - return {k: check_value(v) for k, v in kw.items()} - - -def clear_singleton_instances(cls: type): - assert isinstance(cls, Singleton) - cls._instances = {} - - -class Singleton(type): - """ - The base class to ensure a singleton. - """ - - def __init__(cls, name, bases, dict_like): - super().__init__(name, bases, dict_like) - cls._instances = {} - - def __call__(cls, *args, **kw): - full_kwargs = resolve_kwargs(cls.__init__, None, *args, **kw) - if full_kwargs == {"self": None, "args": (), "kwargs": {}}: - full_kwargs = {} - full_kwargs.pop("self", None) - key = tuple(full_kwargs.items()) - if key not in cls._instances: - cls._instances[key] = super().__call__(*args, **kw) - return cls._instances[key] diff --git a/nautilus_trader/persistence/batching.py b/nautilus_trader/persistence/batching.py index 803140ec7250..efdc63c2f766 100644 --- a/nautilus_trader/persistence/batching.py +++ b/nautilus_trader/persistence/batching.py @@ -18,15 +18,22 @@ import sys from collections import namedtuple from collections.abc import Iterator +from pathlib import Path import fsspec +import numpy as np import pandas as pd import pyarrow.dataset as ds import pyarrow.parquet as pq from pyarrow.lib import ArrowInvalid from nautilus_trader.config import BacktestDataConfig +from nautilus_trader.core.nautilus_pyo3.persistence import ParquetReader +from nautilus_trader.core.nautilus_pyo3.persistence import ParquetReaderType +from nautilus_trader.model.data.tick import QuoteTick +from nautilus_trader.model.data.tick import TradeTick from nautilus_trader.persistence.catalog.parquet import ParquetDataCatalog +from nautilus_trader.persistence.external.util import py_type_to_parquet_type from nautilus_trader.persistence.funcs import parse_bytes from nautilus_trader.serialization.arrow.serializer import ParquetSerializer from nautilus_trader.serialization.arrow.util import clean_key @@ -35,6 +42,90 @@ FileMeta = namedtuple("FileMeta", "filename datatype instrument_id client_id start end") +def _generate_batches( + files: list[str], + cls: type, + fs: fsspec.AbstractFileSystem, + use_rust: bool = False, + n_rows: int = 10_000, +): + + use_rust = use_rust and cls in (QuoteTick, TradeTick) + files = sorted(files, key=lambda x: Path(x).stem) + for file in files: + + if use_rust: + reader = ParquetReader( + file, + n_rows, + py_type_to_parquet_type(cls), + ParquetReaderType.File, + ) + + for capsule in reader: + + # PyCapsule > List + if cls == QuoteTick: + objs = QuoteTick.list_from_capsule(capsule) + elif cls == TradeTick: + objs = TradeTick.list_from_capsule(capsule) + + yield objs + + else: + for batch in pq.ParquetFile(fs.open(file)).iter_batches(batch_size=n_rows): + if batch.num_rows == 0: + break + objs = ParquetSerializer.deserialize(cls=cls, chunk=batch.to_pylist()) + yield objs + + +def generate_batches( + files: list[str], + cls: type, + fs: fsspec.AbstractFileSystem, + use_rust: bool = False, + n_rows: int = 10_000, + start_time: int = None, + end_time: int = None, +): + if start_time is None: + start_time = 0 + + if end_time is None: + end_time = sys.maxsize + + batches = _generate_batches(files, cls, fs, use_rust=use_rust, n_rows=n_rows) + + start = start_time + end = end_time + started = False + for batch in batches: + + min = batch[0].ts_init + max = batch[-1].ts_init + if min < start and max < start: + batch = [] # not started yet + + if max >= start and not started: + timestamps = np.array([x.ts_init for x in batch]) + mask = timestamps >= start + masked = list(itertools.compress(batch, mask)) + batch = masked + started = True + + if max > end: + timestamps = np.array([x.ts_init for x in batch]) + mask = timestamps <= end + masked = list(itertools.compress(batch, mask)) + batch = masked + if batch: + yield batch + return # stop iterating + + yield batch + + def dataset_batches( file_meta: FileMeta, fs: fsspec.AbstractFileSystem, diff --git a/nautilus_trader/persistence/catalog/base.py b/nautilus_trader/persistence/catalog/base.py index 0985837be945..17dde2c9d5ac 100644 --- a/nautilus_trader/persistence/catalog/base.py +++ b/nautilus_trader/persistence/catalog/base.py @@ -27,7 +27,7 @@ from nautilus_trader.model.data.venue import InstrumentStatusUpdate from nautilus_trader.model.instruments.base import Instrument from nautilus_trader.model.orderbook.data import OrderBookData -from nautilus_trader.persistence.base import Singleton +from nautilus_trader.persistence.external.util import Singleton from nautilus_trader.serialization.arrow.util import GENERIC_DATA_PREFIX diff --git a/nautilus_trader/persistence/catalog/parquet.py b/nautilus_trader/persistence/catalog/parquet.py index aa1c24ee181e..0246b60a7807 100644 --- a/nautilus_trader/persistence/catalog/parquet.py +++ b/nautilus_trader/persistence/catalog/parquet.py @@ -12,11 +12,12 @@ # See the License for the specific language governing permissions and # limitations under the License. # ------------------------------------------------------------------------------------------------- - +import heapq import itertools import os import pathlib import platform +import sys from pathlib import Path from typing import Callable, Optional, Union @@ -31,10 +32,8 @@ from fsspec.utils import infer_storage_options from pyarrow import ArrowInvalid +from nautilus_trader.core.datetime import dt_to_unix_nanos from nautilus_trader.core.inspect import is_nautilus_class -from nautilus_trader.core.nautilus_pyo3.persistence import ParquetReader -from nautilus_trader.core.nautilus_pyo3.persistence import ParquetReaderType -from nautilus_trader.core.nautilus_pyo3.persistence import ParquetType from nautilus_trader.model.data.base import DataType from nautilus_trader.model.data.base import GenericData from nautilus_trader.model.data.tick import QuoteTick @@ -42,6 +41,7 @@ from nautilus_trader.model.objects import FIXED_SCALAR from nautilus_trader.persistence.catalog.base import BaseDataCatalog from nautilus_trader.persistence.external.metadata import load_mappings +from nautilus_trader.persistence.external.util import is_filename_in_time_range from nautilus_trader.serialization.arrow.serializer import ParquetSerializer from nautilus_trader.serialization.arrow.serializer import list_schemas from nautilus_trader.serialization.arrow.util import camel_to_snake_case @@ -164,6 +164,51 @@ def _query( # noqa (too complex) else: return pd.DataFrame() if as_dataframe else None + if isinstance(start, int) or start is None: + start_nanos = start + else: + start_nanos = dt_to_unix_nanos(start) # datetime > nanos + + if isinstance(end, int) or end is None: + end_nanos = end + else: + end_nanos = dt_to_unix_nanos(end) # datetime > nanos + + # Load rust objects + use_rust = kwargs.get("use_rust") and cls in (QuoteTick, TradeTick) + if use_rust and kwargs.get("as_nautilus"): + + # start_nanos = int(pd.Timestamp(end).to_datetime64()) if start else None + # end_nanos = int(pd.Timestamp(end).to_datetime64()) if end else None + from nautilus_trader.persistence.batching import ( + generate_batches, # avoid circular import error + ) + + assert instrument_ids is not None + assert len(instrument_ids) > 0 + + to_merge = [] + for instrument_id in instrument_ids: + files = self._get_files(cls, instrument_id, start_nanos, end_nanos) + + if raise_on_empty and not files: + raise RuntimeError("No files found.") + batches = generate_batches( + files=files, + cls=cls, + fs=self.fs, + use_rust=True, + n_rows=sys.maxsize, + start_time=start_nanos, + end_time=end_nanos, + ) + objs = list(itertools.chain(*batches)) + if len(instrument_ids) == 1: + return objs # skip merge, only 1 instrument + to_merge.append(objs) + + return list(heapq.merge(*to_merge, key=lambda x: x.ts_init)) + dataset = ds.dataset(full_path, partitioning="hive", filesystem=self.fs) table_kwargs = table_kwargs or {} @@ -176,44 +221,19 @@ def _query( # noqa (too complex) except Exception as e: print(e) raise e - mappings = self.load_inverse_mappings(path=full_path) - if ( - cls in (QuoteTick, TradeTick) - and kwargs.get("use_rust") - and not kwargs.get("as_nautilus") - ): - return int_to_float_dataframe(table.to_pandas()) + if use_rust: + df = int_to_float_dataframe(table.to_pandas()) + if start_nanos and end_nanos is None: + return df + if start_nanos is None: + start_nanos = 0 + if end_nanos is None: + end_nanos = sys.maxsize + df = df[(df["ts_init"] >= start_nanos) & (df["ts_init"] <= end_nanos)] + return df - if cls in (QuoteTick, TradeTick) and kwargs.get("use_rust"): - if cls == QuoteTick: - parquet_type = ParquetType.QuoteTick - elif cls == TradeTick: - parquet_type = ParquetType.TradeTick - else: - raise RuntimeError() - - ticks = [] - for file in dataset.files: - with open(file, "rb") as f: - file_data = f.read() - reader = ParquetReader( - "", - 1000, - parquet_type, - ParquetReaderType.Buffer, - file_data, - ) - - if cls == QuoteTick: - data = map(QuoteTick.list_from_capsule, reader) - elif cls == TradeTick: - data = map(TradeTick.list_from_capsule, reader) - else: - raise RuntimeError() - ticks.extend(list(itertools.chain.from_iterable(data))) - - return ticks + mappings = self.load_inverse_mappings(path=full_path) if "as_nautilus" in kwargs: as_dataframe = not kwargs.pop("as_nautilus") @@ -225,9 +245,44 @@ def _query( # noqa (too complex) else: return self._handle_table_nautilus(table=table, cls=cls, mappings=mappings) + def make_path(self, cls: type, instrument_id: Optional[str] = None, clean=True) -> str: + path = f"{self.path}/data/{class_to_filename(cls=cls)}.parquet" + if instrument_id is not None: + path += f"/instrument_id={clean_key(instrument_id)}" + return path + def _make_path(self, cls: type) -> str: return f"{self.path}/data/{class_to_filename(cls=cls)}.parquet" + def _get_files( + self, + cls: type, + instrument_id: Optional[str] = None, + start_nanos: Optional[int] = None, + end_nanos: Optional[int] = None, + ) -> list[str]: + + if instrument_id is None: + folder = self.path + else: + folder = self.make_path(cls=cls, instrument_id=instrument_id) + + if not os.path.exists(folder): + return [] + + paths = self.fs.glob(f"{folder}/**") + + files = [] + for path in paths: + fn = pathlib.PurePosixPath(path).name + matched = is_filename_in_time_range(fn, start_nanos, end_nanos) + if matched: + files.append(str(path)) + + files = sorted(files, key=lambda x: Path(x).stem) + + return files + def load_inverse_mappings(self, path): mappings = load_mappings(fs=self.fs, path=path) for key in mappings: diff --git a/nautilus_trader/persistence/external/core.py b/nautilus_trader/persistence/external/core.py index 6aa5fb93e653..dd90952db02f 100644 --- a/nautilus_trader/persistence/external/core.py +++ b/nautilus_trader/persistence/external/core.py @@ -14,8 +14,8 @@ # ------------------------------------------------------------------------------------------------- import logging +import os import pathlib -import re from concurrent.futures import Executor from concurrent.futures import ThreadPoolExecutor from io import BytesIO @@ -25,20 +25,25 @@ import fsspec import pandas as pd import pyarrow as pa -import pyarrow.dataset as ds -import pyarrow.parquet as pq from fsspec.core import OpenFile from pyarrow import ArrowInvalid +from pyarrow import dataset as ds +from pyarrow import parquet as pq from tqdm import tqdm from nautilus_trader.core.correctness import PyCondition +from nautilus_trader.core.nautilus_pyo3.persistence import ParquetWriter from nautilus_trader.model.data.base import GenericData +from nautilus_trader.model.data.tick import QuoteTick +from nautilus_trader.model.data.tick import TradeTick from nautilus_trader.model.instruments.base import Instrument from nautilus_trader.persistence.catalog.base import BaseDataCatalog from nautilus_trader.persistence.catalog.parquet import ParquetDataCatalog from nautilus_trader.persistence.external.metadata import load_mappings from nautilus_trader.persistence.external.metadata import write_partition_column_mappings from nautilus_trader.persistence.external.readers import Reader +from nautilus_trader.persistence.external.util import parse_filename_start +from nautilus_trader.persistence.external.util import py_type_to_parquet_type from nautilus_trader.persistence.funcs import parse_bytes from nautilus_trader.serialization.arrow.serializer import ParquetSerializer from nautilus_trader.serialization.arrow.serializer import get_cls_table @@ -95,13 +100,23 @@ def iter(self): yield raw -def process_raw_file(catalog: ParquetDataCatalog, raw_file: RawFile, reader: Reader): +def process_raw_file( + catalog: ParquetDataCatalog, + raw_file: RawFile, + reader: Reader, + use_rust=False, + instrument=None, +): n_rows = 0 for block in raw_file.iter(): objs = [x for x in reader.parse(block) if x is not None] - dicts = split_and_serialize(objs) - dataframes = dicts_to_dataframes(dicts) - n_rows += write_tables(catalog=catalog, tables=dataframes) + if use_rust: + write_parquet_rust(catalog, objs, instrument) + n_rows += len(objs) + else: + dicts = split_and_serialize(objs) + dataframes = dicts_to_dataframes(dicts) + n_rows += write_tables(catalog=catalog, tables=dataframes) reader.on_file_complete() return n_rows @@ -113,9 +128,13 @@ def process_files( block_size: str = "128mb", compression: str = "infer", executor: Optional[Executor] = None, + use_rust=False, + instrument: Instrument = None, **kwargs, ): PyCondition.type_or_none(executor, Executor, "executor") + if use_rust: + assert instrument, "Instrument needs to be provided when saving rust data." executor = executor or ThreadPoolExecutor() @@ -128,7 +147,14 @@ def process_files( futures = {} for rf in raw_files: - futures[rf] = executor.submit(process_raw_file, catalog=catalog, raw_file=rf, reader=reader) + futures[rf] = executor.submit( + process_raw_file, + catalog=catalog, + raw_file=rf, + reader=reader, + instrument=instrument, + use_rust=use_rust, + ) # Show progress for _ in tqdm(list(futures.values())): @@ -260,6 +286,39 @@ def write_tables( return rows_written +def write_parquet_rust(catalog: ParquetDataCatalog, objs: list, instrument: Instrument): + + cls = type(objs[0]) + + assert cls in (QuoteTick, TradeTick) + instrument_id = str(instrument.id) + + min_timestamp = str(objs[0].ts_init).rjust(19, "0") + max_timestamp = str(objs[-1].ts_init).rjust(19, "0") + + parent = catalog.make_path(cls=cls, instrument_id=instrument_id) + file_path = f"{parent}/{min_timestamp}-{max_timestamp}-0.parquet" + + metadata = { + "instrument_id": instrument_id, + "price_precision": str(instrument.price_precision), + "size_precision": str(instrument.size_precision), + } + writer = ParquetWriter(py_type_to_parquet_type(cls), metadata) + + capsule = cls.capsule_from_list(objs) + + writer.write(capsule) + + data: bytes = writer.flush_bytes() + + os.makedirs(os.path.dirname(file_path), exist_ok=True) + with open(file_path, "wb") as f: + f.write(data) + + write_objects(catalog, [instrument], existing_data_behavior="overwrite_or_ignore") + + def write_parquet( fs: fsspec.AbstractFileSystem, path: str, @@ -362,30 +421,6 @@ def inner(*args, **kwargs): return inner -def _parse_file_start_by_filename(fn: str): - """ - Parse start time by filename. - - >>> _parse_file_start_by_filename('/data/test/sample.parquet/instrument_id=a/1577836800000000000-1578182400000000000-0.parquet') - '1577836800000000000' - - >>> _parse_file_start_by_filename('/data/test/sample.parquet/instrument_id=a/0648140b1fd7491a97983c0c6ece8d57.parquet') - - """ - match = re.match(r"(?P\d{19})\-\d{19}\-\d", pathlib.Path(fn).stem) - if match: - return int(match.groups()[0]) - - -def _parse_file_start(fn: str) -> Optional[tuple[str, pd.Timestamp]]: - instrument_id = re.findall(r"instrument_id\=(.*)\/", fn)[0] if "instrument_id" in fn else None - start = _parse_file_start_by_filename(fn=fn) - if start is not None: - start = pd.Timestamp(start) - return instrument_id, start - return None - - def _validate_dataset(catalog: ParquetDataCatalog, path: str, new_partition_format="%Y%m%d"): """ Repartition dataset into sorted time chunks (default dates) and drop duplicates. @@ -393,7 +428,7 @@ def _validate_dataset(catalog: ParquetDataCatalog, path: str, new_partition_form fs = catalog.fs dataset = ds.dataset(path, filesystem=fs) fn_to_start = [ - (fn, _parse_file_start(fn=fn)) for fn in dataset.files if _parse_file_start(fn=fn) + (fn, parse_filename_start(fn=fn)) for fn in dataset.files if parse_filename_start(fn=fn) ] sort_key = lambda x: (x[1][0], x[1][1].strftime(new_partition_format)) # noqa: E731 diff --git a/nautilus_trader/persistence/external/util.py b/nautilus_trader/persistence/external/util.py new file mode 100644 index 000000000000..1067f5066dcc --- /dev/null +++ b/nautilus_trader/persistence/external/util.py @@ -0,0 +1,118 @@ +import inspect +import os +import re +import sys +from typing import Optional + +import pandas as pd + +from nautilus_trader.core.nautilus_pyo3.persistence import ParquetType +from nautilus_trader.model.data.tick import QuoteTick +from nautilus_trader.model.data.tick import TradeTick + + +class Singleton(type): + """ + The base class to ensure a singleton. + """ + + def __init__(cls, name, bases, dict_like): + super().__init__(name, bases, dict_like) + cls._instances = {} + + def __call__(cls, *args, **kw): + full_kwargs = resolve_kwargs(cls.__init__, None, *args, **kw) + if full_kwargs == {"self": None, "args": (), "kwargs": {}}: + full_kwargs = {} + full_kwargs.pop("self", None) + key = tuple(full_kwargs.items()) + if key not in cls._instances: + cls._instances[key] = super().__call__(*args, **kw) + return cls._instances[key] + + +def clear_singleton_instances(cls: type): + assert isinstance(cls, Singleton) + cls._instances = {} + + +def resolve_kwargs(func, *args, **kwargs): + kw = inspect.getcallargs(func, *args, **kwargs) + return {k: check_value(v) for k, v in kw.items()} + + +def check_value(v): + if isinstance(v, dict): + return freeze_dict(dict_like=v) + return v + + +def freeze_dict(dict_like: dict): + return tuple(sorted(dict_like.items())) + + +def parse_filename(fn: str) -> tuple[Optional[int], Optional[int]]: + match = re.match(r"\d{19}-\d{19}", fn) + + if match is None: + return (None, None) + + parts = fn.split("-") + return int(parts[0]), int(parts[1]) + + +def is_filename_in_time_range(fn: str, start: Optional[int], end: Optional[int]) -> bool: + """ + Return True if a filename is within a start and end timestamp range. + """ + timestamps = parse_filename(fn) + if timestamps == (None, None): + return False # invalid filename + + if start is None and end is None: + return True + + if start is None: + start = 0 + if end is None: + end = sys.maxsize + + a, b = start, end + x, y = timestamps + + no_overlap = y < a or b < x + + return not no_overlap + + +def parse_filename_start(fn: str) -> Optional[tuple[str, pd.Timestamp]]: + """ + Parse start time by filename. + + >>> parse_filename('/data/test/sample.parquet/instrument_id=a/1577836800000000000-1578182400000000000-0.parquet') + '1577836800000000000' + + >>> parse_filename(1546383600000000000-1577826000000000000-SIM-1-HOUR-BID-EXTERNAL-0.parquet) + '1546383600000000000' + + >>> parse_filename('/data/test/sample.parquet/instrument_id=a/0648140b1fd7491a97983c0c6ece8d57.parquet') + + """ + instrument_id = re.findall(r"instrument_id\=(.*)\/", fn)[0] if "instrument_id" in fn else None + + start, _ = parse_filename(os.path.basename(fn)) + + if start is None: + return None + + start = pd.Timestamp(start) + return instrument_id, start + + +def py_type_to_parquet_type(cls: type) -> ParquetType: + if cls == QuoteTick: + return ParquetType.QuoteTick + elif cls == TradeTick: + return ParquetType.TradeTick + else: + raise RuntimeError(f"Type {cls} not supported as a `ParquetType` yet.") diff --git a/nautilus_trader/test_kit/mocks/data.py b/nautilus_trader/test_kit/mocks/data.py index 98f648945de8..64bdb1e7c299 100644 --- a/nautilus_trader/test_kit/mocks/data.py +++ b/nautilus_trader/test_kit/mocks/data.py @@ -27,11 +27,11 @@ from nautilus_trader.model.identifiers import Venue from nautilus_trader.model.objects import Price from nautilus_trader.model.objects import Quantity -from nautilus_trader.persistence.base import clear_singleton_instances from nautilus_trader.persistence.catalog.parquet import ParquetDataCatalog from nautilus_trader.persistence.external.core import process_files from nautilus_trader.persistence.external.readers import CSVReader from nautilus_trader.persistence.external.readers import Reader +from nautilus_trader.persistence.external.util import clear_singleton_instances from nautilus_trader.trading.filters import NewsEvent diff --git a/tests/test_data/bars_eurusd_2019_sim.parquet b/tests/test_data/bars_eurusd_2019_sim.parquet new file mode 100644 index 0000000000000000000000000000000000000000..2aef74500dd988d124c6afd008073732558c6bc0 GIT binary patch literal 249174 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℘@ zaN^*%pnWq7wo3a`jfFO?lEIBuLHW`F`o^v&TGfO1uL&xX4s23QgIkP)PaFyf{%cL}(O1E}heGuKTCUn26@22# z!biqSs{dMBedK=d+M-3*+d}XBwdu}L(S_}={ir69Hirv>JFOO8mJZc7i9K<+HuxCZ zZ}@d+hGCp)dsX%E`h}y17Ki?oklWr>ee(XohsHr>hr;i)pSyEPbkVril2Vg3m)fsX zpPs#F@=!>NNm^|CUDdNI7rlGEwD+%c{i8m2&NVIid?oym$)@U~pYFgj{&i(p#i6a# z4$*~YltRRcBUBHE#&!_2i(Vnp35(23^Yjm|3GQ1HA}?Dr;K z)vPt`cvH=2{bHx$mBD6zrJd+lc=y)gu;PR?v!kC+^enum6y}|Q9qD!LmR>Y& zwIp9AMc@3=iEi~p&(wRFzrXZfIMozbz_fot`l)~U;H$kw}B$J<_S4YfJ8{2$p}vDS$v zuXl&qeO&&p?CxCa6x-p!P=WP|m$G}Zjv-pPOP`al;@#Aq-ec*U8!zY%f35g3wRim3 zrUN&=&>i2b_&v3cay*lBGil68H;#I};rp>|dGHyX!{TJy8wJPnXN|DyU3SG?DrpMl z6|WoNE_S;er%}2;jaT+%#A&gIa=dP7b2V?rx~qN+FYkEc#FozEH7{<3GkiYATKXQi z&Z}Q|d)0m4zIcJ}!MD7~@0x^`pORdT95= zA4KS`a+N!xYTLK&op((oi?_YzueOai`lViM`>5E?bUT-+_2@hSM4f2mS%h7 z%h+6vIGMzz#G|FQw-(;_*%iNk#nyvzCte*NJ$X0i!OG~w_Uk9k+uox+h|P*ON%E3|!B5SC({bXo4SzTNYa4{O6xQ!No%mJm?{Qnhntwk%*z#cY&$or2?f$!a z`G>ml|5l5b_egak`jTFfWt}WR^y>4er=HvonQy&HYavjf`_jx6`1aPe*9t0vo%YHuWlA_)n4>Jtkq489lo|lz}xv^^kIE;YU1$q z^8&uw%Zc5)ccnU%4BrvVS^si+$==(k8@J#1EO7k#Qe2~aeFz*)M_qXTlwy0ZwKA>mEqEsV{5U|N$hOjQ?K|-4}M(x>&@+~GyVpz-L%?F z(hj^EZ9Efn@byBizY@}pyt~tTCdA;)a;^5i(oVj+JAP)#!8hx)j$TbWKm5;$Gs`5V zviEdotm{8=kA5~ff2wj%SJ=91N5%waS4g~V+H-u@y3r%|L(eAUzwOd`b8Fp$?GMuC ztUlFs;Su7$ar4b><|+AS2ejU@*FWDLUw0P4;H^Dp!q!h6d35^hdWrW>_nh0cKA`yV zb@P_$)=2P#F+#P$m{@0P^0Tx@oz886XN+w_yWNcTZWT zOURGw+vlE|RuAt>O_zV4EP^A+#p^$CnUh!28LOXNH{1Tt2m8mD zpRLoem?%6~;XdOPF2&6-I6BdIuF6g{=<$_=;D(~-C+v5Aoe88}E6%W5{bIy^EcoN9 zz1PoX@GV}xI=5%hne^}*#f#^@e@QvtVDKrAIbyQG?foR{{QjMvsvh5p-mum8#w&;O zjR!w9Fh`3w1g?G^aQ@JW&quGe);p6z5ncAwoHC1*P+wy%V7AJ^~M$Q zZ(AHroO*MY`M`T!;*GcG9lF)NPCR}XwsGyzcVp+z=6{{p`}o1eO-HwWcKG}2SCM5; zls9cFn^v9MxBi>LvZo6+6_cwzM5k01ZMj^3Pl^z-Qp|495VYTwkZ&Bu>^ zahUsH{r{x*J%6+80P*$t+(+x*tkZt~X7h#jUnze-G5DFM{lR3*!255kzb6j!kMAH(MUR|6kVM(~Dm8|NBjMYv7Teb$@@% z|9xxUkFc#%M}M9E`>Vu%Pxt-Ywe{2LZ67**toZMp_V24(f0W&L>-hVh|9<}apI9cv zUxaWH4H0>z{mVLY*0cz_N4!-;O-C#!Q|=~ZZqKv?MYlnGccx16)nF$@Buk%A#kZ<+ z#a24Wo}>sOBtB(oD~RVgDe^@@m8$Fo z<tPp~W|9Z-lzawrFl_MXy!dtQ$IvAkX+@c3ZE_x0ZW48mqP? zDM*|X^3^4tKdGqSwzh+~-#e#3A~%?+;=dtTex?@z`hPk)vN_qw3QEq?L6UVlSoS#u zTtz0wHEuhNrE5B?^rqrmXAj97cB9_zoT?6`#TQMIr7R;kqdB|EmDf2tPDn}g7&Hrw zBvqoe`5Q{Rvy4o04@|2xT@0#~2^u$!$!))>dcm0xmx*pP-JN^9gLeO7SbW#3M`IGZnpwG zYTnucc}2FHOCeuTmF;@CfcAkM)nC%8XvcP+DqxOKH}^+MD$Q&1m=GRH)+=(U>;~N7 z*(~II(EFlORjU;I)GMaYtW&M^qGpV=TqY&C&_aXm-H$L^o|LDr{}>YDMz_*VBBp zS5Zrk7q3^ecyUQtg{BcDeyezKip9*O3zKSJpDq(G$$~chwqCyaS+=uYN$v=j>DC{T zskA>lsN{76-PCR1p1Ril=(Q!Hla|f{gNAei-Plzni!6VOU%C=U-xoF4DKGAWRn)+> zR=S<;3ey7r&fhVYh9~Kcx^cUm8WqJKX`e7;xa-D`I<^Fe*A3h*U@X|ba!Kjo0NzQr ze_CnJp925r46q$`yKktPus`u!DX9WHu(n)d!Oh^&Qm+)dnSsY13a;#kPo;k=^0Qrk zDr@FGOV-;S->FV>f0nLU@hqS!Zzw=u?*8JQ=ALKvaoZ`A#)}8yziHlxnp^91({gr` z%PYQC+cOW%!aF0g!`-K9wN5+(n$p`bXT#<9lFTuI~1%bh6imvHIwkaoW$srtOE!oJcY(DL0tOs(FIahpL$ zuS%A=C+C&IrKlYxWn1M3J?DC6iRi!2aw(}C5AeD>pvcp2i>W?dxu}wT(b;rNScPtNdnz>|M$hkcc zzDc;aLSu=GO&e$P(|Xh5r}@4oJ?*}8&dTpD*>Tn1??sN_w5*osp2ItSU-o-Ebg7&3 zA!hIBj*Yh{ABN@*ZT+dY@8^#7O8zt3oQw=5V)yZiR^H+(c}=A?78L@8GB%~YL}Jknk#-oX14m?-y9xZT(HH< zpKk2>yj8EdVLChrGN`8?Tsu$fx>nSMZrDi|ULsSe|cSJ4pSsZC;v3#OJeoFLXzjVovw$*_FmDz! zz5vFi|1o%p&$VakF_r3S#Z%wa`b1S&ymQ^st-5AB=A>4YC(EaQn@62__EhUNKyhIQ zu5PO1S}(tHt@gBs+;?9MH4Ye;cYu`Q?uUXSRCW}{`i>k z<+p<_CTxB&+FT!edu6!q;X0cW%lTvVOO7RM@NJs0 z36~z)+?`m;d}nH-S-2j9d(;;um0`ZL+4XpKKVpE&q}8G^?BOK8$9*dU6h() zH9YW&uWc|M%@*%ep}@qTrbo%7=Hr^E;cIOIgO@LY6m}m=-7tJZYId&6q`g8@ z>e@3h*23A|xi3a{x7=R4*Kf3LcJRyBL8^z3rJWi6XG}3-#Z=XvV>^)7cp#;cWbp2= z!|B_Bul-hZ&RKus?Zh5Vu9#NY+qWZ~HvgHF`SRxD~@twEtrSj$jH?kY&{$udJRr}uF-h5&HH@f5Vmp}VeKONh$P~v}M>)shWF;)Mz zBl5-kU#A_vzbxg~{%YL%f$t-p;%%A zrzBfaQ&pC3BQ7$Ft}Y%IzamamlvW-|8>o<^P5%<()I6KT*_|W)|EKfOO+oe(R}~cj z1CV?WAs-%on58=PSky>n+v&Gw7)cujqxpK~U;9AR>DnKU9S|H_%M_Vgv ze8~o)?%2#0yHbfmQT#04x3k6z#b~TvHi|j;%!-F^LtxbEk9wq?Vu&oZZ9{?*H9Nj| zYAui>-?6eYhRd~i4W#uqqvKTyJjXPl@k zx}Nf_l*jS5oQ<0Huxy0p`Igpgl>awAmBKYT(mG6Gic(eKMs~*dt{V4@H zSW=;HtmBh6@08ryA!`{N2!9n0)?W|qmsdc6k|S@fdXQi2RCqbco?t_V4<>tlqz(?v zSY7t^g?#cL5$yHnhj|@|_rttW@Y>@zuBQ9fAP}1`c|Fip_2k8>5F1kG1&LZV+1~Uu zo)Qg1PO$@#RcFd2SzJd&4wMumCR6Fr&{dw%gD54Nl|3O}9cN@)xw$5PbvM57=MCly zA4NQT@Km4W)e9ZCq%un3hiqSP5g**<0#B|!7@RAAMy1iwP;<;KkI}f|SYH0%Fozq4 zKm?$}>=bVX95xcaA#ZdbQV4rQ!)k8PQT8HL0V;pNmSgM>W2mY8;PoRd65Vf+-=ag=xThv`_ATOc16yN*X(l0ksP`59m>SM>JND5 zfmXm9w+?=D1gl|Zr~W3J%88b@MPb>lU@~u|LZv?oSt|KjHbR~P*+u_deY7M)_a{g( zR^-I_(Xl}O7^m{;p>5&N=3O@R%l_bVu}H$(?div<%O54bZG=kbP#@m89TB`bQ_6L& z;9&jX$e{i(N8s9wC7~sbgkyI2;Zf=<`%YD=5;<)lTYIIV7^H2+!}@jp)d6g%=9EeGG>`HBC~cHP7%?8JIq0_3AcQK=rDD~(MX*;Sc}k`cJHHQq^S2LK4shY!R`~61EWS` z&uBNwAZ1@er(uVTN^a!0pg&@ePdD>4x%Y*9CwcT%<7={T-)L9l7CL`J|Kd{q9} z(6(ih3ly`Z$>X``u^jL8Q&59^K$FklqOu)zD1gk*lAIHkZz*|lT$OK6Pv9ML7$z8| za-^Pp2_<2SsFm^nDQdOUH+EazK*-pIPM~~MS=3$H8&UE^{su}|ASU7+?Dj702_e5l z5QJ|$PztIF6#jZsLsvfgT5iPG01eG;>hCDta-(vLKCJuVo2H%+g@}=4)+7WCeS<;#RkcNV7gmOtuGMg}+d8rMA_T@V^aa)d2qX~Dt$C7NMr!0`W zQ6ne+F&5c?m9S+yqu^)OhO7fW>RgB9z4{>q`9AK4$<+LmKo8hBL0=0^kf-5&&Xi*a zG&M^FbYH{h&{(Ei-a+Nyl-zkfM{Z~s?MWw~F5GUJN!488QA~uHe1+ICPf)SP(Gx!6 zB$S=%oJ_=YfV}1sLDG8XSt;3jnWWs|v31aiSU=5F~F;$#G0 zT=4j~G;d7tcaoLQqC__}RC${|#0+wl!NB)}CXY>HAX z+}kA-??eJA5xHVHtY^gp7FB;h5w|fI2+zN%i73i-PRH<@Q)-MzS)Lr@Ek3%GUQ*QD zDX$6DQD>n;exmI2-;t-k=dGOvkgahrS~d?$3m@Q{8D%scW_I#5NQHOg&6&qT?e(TZ z^L2IDn&JWG#>S__vlL!oMM#pH(s;zPU#$Xzk;Tw&W*f)VIN>sLVDMcHwRgR# zI#o^op}#z9%j}z7=9j0eWnf93>gM(>cN>8;PLs|q%ifi0LDgJN@qqkZ6b(ye8NX6W zKQ#v#;r7N|NCjacX$NVWhAX@|pG3{O8>vvpFxJ6_z6Y^yC|)lf(b?m20;g1Zq|ob0jsiE=U7HRyXr4HXD2u6j)_IwMI0zFO ztdHgbo~l<82%;Iuz}dc?^ZqwVzGW?8^fqUUO-4-g?CN_I(s4UJ_&VsnnM$ciUu)eB zFDD!!(8?4Kpx*8Ra`rTnONlj#U*hQ;wC`a9-?$sL@H`%qzf~`RVbMsrTycbmi9-o~ zwa$^%sWp?+R(?iROM5noH%=HnOzyiW^A zHc5lz&~^G}dJvd4Qdz0RCaDJ@gO@kKyy0an;hiI07Ur$fXXe9FNp0*0G0+fw5OX?= zs#Y8^iq^B?X1&-R_PfHB+Ixwx211KrXki?AI2KwaOk7X(!oVGL0Bg0eG5UH+6nl0E zuatUY7t`cmMjpxKUjUIaLy#pUj-uO`7TGeFq(OFpaj+A)(c>&?9~cbTg&R6> z4@;J`ry)t=vfPv)aPN%J-5;ym~K6MkP9Lb9>HqQ)>OPFv{1) zbG#yP!lTX6=-X&2#^LQ}<=>ISb9H=nLog-3h9SqJNe(0?OON)yROpZHwET?7wWs`7 z?6?5mUr5#m<8J9ZXx!tBD?de3=1cXn3Dz%;s_meQT6-j7=1K)i4|*r^A0k3t4dzxD z5*cp~!w2-0)K+%0WOy2X1sV+r~PX({Q?`s|o7Z-wTWvMtE_7w$@FejlO~G)!b5K#sKfA*C@>y%sjw0Jl- z(Z7IO?7GfX_A8r7>r8sICnZ^(wM_1bl&2_Hq0lR_;LdM;4F82$)MyZIr%_EB*Af1&Y!tSaq{$M0{O#Y7H0uUjfGAfD3 zQY1ehTG1kSYDMw)rBtL?QrYek^rV=7&%r`W8y=4qm%AW8=e6!>&x_KYr|fTrI~F3z z9W4)03aa^JF}u(DfrXwOTyzvqC^RdM98R9TgnbZ&VF%PIr zmp%j;^rpQ1a4g-|f&K7PHUvsu)VSea3-D38opS%_99e7M74`!hHo36HoeV88?*Nam zZ-+T2@4Q+UY(m??pd^XVYs{PW@|_y-3$D506`l9)3-VaZ%XZ~NLX#dbU&a=x*yh!X`6xj}$R7wH{I-zhObpZ8uAR0bE4p(1u! z4;xzKe6hkl%@8{bioqlWI%hJsGO4&=9{n`3idC;XOZ+biey^!fRL;9E%2VL?p<3dJ zP-fcH<7G+QPLD*L2V=u)?b1<>$x1u|>&hg_w^bfmYb*&o$8t>{fNa^J+%dv3Xmd9` zj>O*SWHx+183lj>O7lHvp*P66+m8S(RL9)tcIYT5?e|#GzrAXcIxH}s$V-60*<;-= z^?+Sy-|!FZS@|KZQ7uBV(IYkJnZQ^gU=(C>>fTLX>-b4FpsMmDoXWgNsm^ef_YQny zpg+hLxtQr4QH8fDq3u-PCnr&;URV4#YbiHXlU(y7vK(z%q-U)4YLh0rx<8j2^T3|T zKcDiPk>BC0eKo=sYWhWDdgvUmpso|2rK9-t0^F9ZyEQG{{w1aDsWI|PXejJRieHP7 zx~;0lf-MW`b27mRB(^2j9KW(x4n&?a&*(dL#I{H7 zawAvhe(|H;ttn#)l^nXkIrf9?%dO&sYjBL%|dPn$$4HrO9)tvnH(lN&L=VwJO`D< zZdhy(rym&wt4DSUTxmgUXJ`{(2dekWC-MxEH`WvK!1k6A`d3%+h8IqXLC!$3Tqr$S zR`=oojYs5wI?|w?YU0uu-U^7W1vri#^aY1c~XYO z4ipnz+dok9oa&m!job_x?w)kDu5M_tKM^7`7SV&+e^Z&1G9~+=K^vAs{G( z?08^q4Qh82&Kv##rm-J-Z*ktwvq z?;BuI=@uj9r;oj9Q&3b=p(%j~P1K@Viao~TRj$&bG@Xy3UjqAdVy$LNshKfxl<^Xt zL8WzPk5J&Njd8x3yPE@aiX8?lW%!V98S4e)+tWmrm{rYyutO2;wSHuVhP_1--Hoif zjFGTHYh65gWJvg!4Y}eo@vIHrix!qMsh#d+RTkg%fFfzIk;f9-Ni1m3|CL7hIjD^= zi!?bp5N|%I_>dI)`bol8AV2Skd}EbSa6Rz)Jd zQDS4YvD#e4uEkUM!i~mVte9mp_! z7y?2#sNKrs?h#5T%<2r*apG`g1OPKk}G$WGIL`J;kb(_1fY{2tl zi>K{;N^t^PdC||s>TpJyeOQHlxy=6%DFZ14i2O?1SlRecDsD2`4i^6k(V>$DS<%6k zA3AHStyygY`YqpyU;viNhw;q$*tm3s$$gH+9CGPghep##t@N3*UX*g_IVeZ4`3UiQ zKY~?mOj)fjEMeA}8tj8xVZ5o=M#5{KAnCc9AFKH>@s?+S*p`r(<2fisCYnnQ&A{zl za%>KOB{cB9i%hW1U!iYTzWE*k%Of4`f9E|2xW=N}x7{BJ6ID@{x zSb1z0>{lM^d`FXJUYB*QjX4;HRbkA;yM?DZN@+){7?o1f`#S4U|V<`jPKF0Ov^fJ#un)U0UgoH= z_%P#o-lDdnHadZekik+QpTOnH_7BWo_OD8_{QNK{K zp;}%q7j~O``2+LUwucrFiM;qbi<3UC#Kg2l@Y+!)-t*f2kTbU9C*T2}B4t=1`noO^ zWe58qwsKkjn<~bnv+TRIIYYom%f+#FQ0hNCINan>@}J`rr+bO#(`A_48r4%o@{Sva zV~e#Uma)QRXn@j6=d%sVjZ4`aDZWZ!r|+H2!E(44-qbdB0859U3eCMP z?Tm-z8oEMM8t@cMIQBxHV$6YNRt+X|S_#kY482CTn^JRuXL%)dR+6YU$%FxGn|9i= z?s9f(xx7VLI2A}$MI5^IS7a|ua}%Z@RvnDh55(> z|9V6;J4U$dLtXggH8O{vthU?h(aNarYe$K(r$Y?SaTFd@!(gRst9-_}o z2@+$qb4vK~SPD7AgYAS)WfgK-t2jRlmf0CEin#ktk0H2_I4ImS>k0LsEkTS*qPI8t zL?SXna#@zW60Lji>H4N@mK(R$1+`t|Mp_FSwY2o#nrR1-g|@ zM!v+kaay-nc{wDpLLZv3{~&&^EEpbMo+2N(7xMlwZWewgz81KmG8~L~5#ngrFr9?l z#e82h{)A`ulZP#5@m5ZNNI~K=fCRfTsVNL?j`I z8{UWM9eH8xyo5zlExZis09iDYi#3CxOY7x_64_xL(VZ7-7BAV5VY50UnMHm!o^iP^ z2+yuf#ul?1d-D5VeR%7`wY}tnQ%HOob(kygspz_TAEYc9GC-uOzOLrvGuUv6jfFOZg&^ ze?*R8u60O(dLMFFcI%a7UOtTH#oZoa%8!T1NE+*&-HK)e9vvH!Hoz|n^Tc06xdXd( zLn^W9TOu;oMn|;bvD%5<+JT$d)`LxKw<;Ph=aYCj-d=QhAlQrj{Ufp4|C$8LuLmS;^n<4c@D&FPY-T6*-T~pShx&_$3r{4fM|4z)UAI5{idqz;kVK+5}JY% zvt@Aq&_Jx7*-lbbmo4K?W!Q%Ng~nYR_E7*;L}c~X8`_G)!K!O~TATo79RYaDM5F#B z(Mv<`tvtzs>a~HR^8p?pUi?okJZ{;N!Om{|u5C8P=tx`3)^1aU?hxS~Qs~8JskT zz6s;RL@I{~MHUnDyvYJWZL$#HypBkF*n_5ygmEpS(E)*~8na)-jTw4gRnUmZ)&_Kk zMl?c+PZqD`%`&38UO*y->-3w@lrK}ykj7v_cxygE&uMIoo^8ElUL^5~`&jsdgOGnH zm`>{x5+#|%l24R4j#MoT6$=AasLwKnAUxE%w%p!gp1=Ph z26Rb7ym)7{Nkj!E64Uk}$xI_We_7VuE6?yy7+WV`8ef$2iJaGIhDmz8=$;3SYO-x zII@8IW^ffDWe2XFKeUafebtIWirzJpr7Pm2H{OKn5%}?yzs~vd|0f$CJlCUeHQ;b1o+L8nFWlo!) zDfiJ_5)&RE5}nseeCvkl)@%LOcp&|2?0FzdpM%l9Rf8$}5}c%20_?O&jlfdNZv2E+ zF2`OmdlN4yZku@IA;L;(MDGL+D$!=(?fxk;GKO~A%O0aiUO=g}+(*Nop@%l6@deYY zPkU-ez>1voDL-qd>SNw)>p-v5r=6qII#eouNZ9c1p5Ks8am11D?j_K+dFhdkSGW7*jv&8lZW&;oNXqF7c zR)4^8&a~IxN3-kW#BfAcwZex*qMgargSeWyXM3VHd%QT^OKLS+wno$7y^=!LP`aAZ zBeC}O4eKjV<|E2dnSWI0u3l)JL|O_n(9Rx-^$I-Ta)R&^2J#~@k9uMy9``wDv&HvL z4BHpSi_1<=hyafGt+ziHvV6p>8{uu2@j##FszUVDM3CM?;5o(#iEwF--qieyn9y6Y zB}>C?d!lj^hq`(e2ZC$#e(;no`_Nr6`C;M@?IL)Xo&@Og!+|l;e(nwA)6Z?oCAMW! zN^($PPZRGvjEz!KB7S)oq-R2J2*pd}(jjdnrHr_VmO2RD?`}Mzfl;A_3=gm6eI^;{ z&ZFI`7M@c2BNJwsADea%@i!Nmcn3yIc@c16YT}e%;iEp_huc^+6o1`9evdM@xmNi{ zuD(*qF>vrd`iBCJt2I9!8f{%1q3wF8rl!TNJ*Dg)02aAMDJWR_C5F@)l0+rApd7ml zJkhk?c2$h%3|;3oTol@BzLc~vab<7Y|LzRH(g;d$u<0Esfj~bdw^C$vmIkVf zb|WdJY_aNdlq<}iSh#LhF|SsuwipXyKxe@Bah=&_iJifCrxVv?=XqC1O>jHc|5#VP zo=8R@WWI!erf2Z({bZJQyVSGH>Vf54Bt~_@mt(rjsrph}NyZ@WtJ#XID>{h1kHClg z>{uS!TI;)=nKF(-He8hoQ*o(i)g#f6zevLAb+?ytNppy?lx4Me2pC?#LOD5{x?4|X z1mi#&;J6J2*2+xqhqNv{zkd0FMUI*5m(~TR()w=#E`+%M7X^v+xA_AH1F-C|At>7; z%3Pj;EGBNfyS$SY8DZ@-kSV*7#(67NJk}Na+K)VmVhgQpSWN^9b)t(T>1@@x{TE3( zP~8X~42P>4>+vw@CqTywG@kOyk?K`HeMK42S*`1Ru;G2{pe_vfa%I+pKyuz4@vrW{ zKfl7VULDW}fu;>6v9Y%&;haQ|JajgUy2H1hfg0NL$L*zJYJjW$VpvTdmK*4lbBJ|% z6=SU=1&nP20ryb{L zKlfD^R1ag0w9OE!2Pd9H@%)<%Pp%TvTMaetLUF(++IRq8;-|@{YYIhyf_>}8;eD|? z-scNlQ891F)LuPIq6CNTDnD8w|2~ZV8D>#gRB@}#Un+wh(yuF@qO-GnIeNP>Jp-fi z_v1l*8^LMEAn$?9w^x}c!&^PNg%(J?O$b<(!zSdMg5uYK`J0qs zJ&7u3id#80xfER>(fs;Us&=_;_4NXc$YBTZ&Al zby4f$YHL!AJFMS$P_drF0I{VXOJgnkpO>EBn8`r!1wlQJ`thRNBoMi*5>}#18ZrrX zwuuz)J2t<~a1-yAv0`|%690-T{1~JU&`)Lhi_;0AYROVcGgt(Pv)Vr7SKA zOq9O=^nR=qcaj)J`E#|q$${sj0Wt9?BB|(}os_Kn<`QMfKxqBxYY6TXz+JC>riib* zn@!E_o8DpY-Fu|?iM(V29jf@!4hV_82r9;>!A8c@z{KKZiRA}sSe$*@@cBr~%BEzE z7;9(23O5$6RIVEW`#~|TBqP_9i!6q>p-(80WP8uV1LXrY?*%1TnxgJuY=_0kXpfXD zFKU<{mwCKo&DXx~Ab#)!!vk|MCghTey4*l7ojPrLngV!`4L(tGnt_E`Q|veA97<6trm?tf zWSIJhV;cpJxz`l+40GD??6eT#d4FW95d?`tlUSq^0ncdPKyv;lZDNvKXE8bu={V)r ztIc{5VMCC{QQivu5~kiZ8O#ZxY^hbqyil6sNtfZjtwc9Pk8Uzm7LQpy+9@CHG3;=@udi86>TTt#G2%GI8Tsj! zCYr{1>Xh9NFac-b0lLZ83oSGkG(I9sl&$F8<-hV^7ctBK9w|-@kd`grm$;Xge}~I` zC^8U>X$VaP5m=jH=I_t`ukoCQ;tqS6L7G$(=3W}S%*HLSOphl zN*1%|4f42Cd>By#8B5UIIj4+_3Owggw5X?!9m${j?tIM_&r^H~$kKd2 zY!&|q)_1T*TYrfUbNcAA|8S#c<`KW&&3X{3XQE*!s{>$F zbq~<67vjagAHZ>#I}X2brdaQiC~@^`*p>MQlnbNundKwMS{F}xjhBl3V30jh#Uejm zq16vKs4EHF0YEMF(F=ac=8NAm^r~cMM{X8#u2Lk=o|Sa$2r%TMGh0aB>p{=7UjF~< z#{(y^gycSEHZWD6b`ai@sQUCs5$1%2zP^-P0;k3p$KM2Q%9i;6AI{*}xf)!u7dJ9v z+4@??+NtKSb_|Kt)4o*{pNzXMp$LGO2y@!!_q(@!3pckQ1vw#p3m;+*@2bF>qnaZOxg^ch@s* z7r{?l%{N3-7Oip77U@H3{L~ZaeX(BNMt(jLKVnuVsa=07N4{QT{y4o4KrBLj>WWoN z{DWjj)uKst`h_vIpd<8RHWF*ocC+j3-md`iF#jM4N!(F7TAXj zR1*S?pvXFBU7u(t2muO%PSIxZ&|YEAY|ZWrSJT-yTkDX)*Tw1S%>81*Lj+mAO~*X} zJ78obnwlS%+sHPFyN~b7xKF9I#IZmi_?B!iXp>eq7fPy10YQKPRs z{ieEL)-pAhmY)=(E~s-MWpsc`2egp#Oh0_ChAf|fO9ap3GruN_F4BGi6!p*lPW=Ov z$|PmsWLgKywDg!MfiRq2!TsZL-bV;($y0h$oEkvg9MItSWPK%4QsJiHc|L6-B^Z6$ zz(gdV)ios4JF8v`Oq8I4mfN3uX%zWOS5698TM!Y%-AVy~^y}#DKiNLEfY(S76p)VS z3Z!}xqA3+}m`I-er0r;@Q!t9H>j>=5*Rx-gTf8JbueTx=gr|vvVGLxL* zy>3c<4!MDO?zr0{L! zJ9Eul9JW7Ys|?8qhKs#+oBP-&<{f#1kDcQxKI+a@Jn|4zq=M`Yw$z{LPW(FqyKYw;XyFhlSP(fjL_LaN@}WLyLc9611&`G6oU^ZRmsCdPJ(VmUGrRcT-WWbBuRLuqJ50My}$1F_m`RG~D~Xp>$sHK&+}HVPePzQI@zg3Zs>tEhHEDUMUNQRP=~%W zu0UTY^Vlh|kym)Y7_O2>Lso2Q`4r#{HMe}H&i8-dCkd&3?Kj{?W|}wSup9s4bNfDF zF1xmLC8je!P=1RZ`vW~r+K)1}K}7{ypb_~)Arsae z@N*WxmWQy35DUJe-H0Qg9Rk3}q^2vjGlgu%2f5vi_&{uPf6Q4mW0X-j(jgpX34P}LW!P9QU1R2P@>N zo6IsxkJPV^*D~HdUW*5UrDmF6t=%-D+4yuM=Xq-`Ckv;ykuN`6V0u-cfQiwKi}rR5QxA^u zpy|XZZV4qC)z8n|y!VN(%MvpW#EWTuCae3I)D?KO_q=lb7qLcbxj`W-`>=t<$K)f; zPo|9BoyMWi=VKqk)2Pgpm%kTYFcU`tHoe> zG@@yE-B9G>BoE@P$7)Q)G1HSH63prDT1 zq!h;M4?FtG$l$mQ`bJxXn7SmfF)vx3tzjrb^cT#BJK}hITRw+6+u}-EXRfE`xs`Ar zo6~9foYP^VS5nl;}u?Ym-c9x9?~5@a0Wlc!0-xF5tNVD1+WEuK|P zj=@p!^~l3_Y=uEWZ=;|UUAIp+}^G#4O|R%^oadz=<48({ry z6@6+K*`XJeeQ~JituutHAIGq!b>~?~9jBusa4IdUSVMnWZ#-;myOOn{N%%+RPXsYv zqYN-p>{@}O^sVY{;vW*uw|6j>2R7j?II{w2^Jn8Py!{mW+~Ex+SSNX+(W`A23Ae9s z%Id}w5zLwu;>apl)Jnf~X`eBVN(DTF<#ts3gB?j^|3Lgw0iHN|lr3cK%>;>E@X6D3 z^d3|e2&Ir=?Zb=tp%`C$^WyktQ>h`B($PmdVTn5P4j}p6;&!BnA4i!@G7gw&c_=cx zmglAx5M7X|;LVSKus@J4zV8CWMZqC;FHHQ}VUzg0t2A7>X=_G^Bn9&h;9ctD93rm^ z58|t?Tt_Vx?#eS2ddft_I~Dl!|LrE;?F=C8r?smR^gHtQ1BT*(Z&}1NnQ;3n9KoFO zw1ag0jC}TgV=P#CW(hdYSEHHl?T)J;5z2=%7zpUvVRzsQJ~<4X?^-L%ssa7ACzpVv zcu;H{(OrDfg>o8i^;r(uXO#YugwqaAHr5krzFzjLNOO{$bw-@;M7mW8h*BCLx37{3 zX8e-G%yYZMjjn_NuxN6K3BTM=TrZWaNlB0oh5_7RF$Kv!F)9nQs4|lKH#CIIl<}i6ltm)vD(UP_~^{Q zO~BT`DX>i;Tk0MrTRT9pi3kU>7l3Lp*7B$)=ypEKb+zRVYv5pLSlzUqh|*L{X6s*FoBkrh zpQQ5#A|w=&x)I>S(RhUF^t~bn(LQZeKheB`r0@I6RgDpQ0Xx)Rq}>mqtj>-EI5V4{ zK^2`b@ZMOapTh-&IcAU^Cr%bf)RAh-dQdE}d@QL9PHR_(RDLTV*)2WdP8SS4 zT>-c3YqZGcxPw}|Zp<~SkdH?=&L_iwKC2R~li6NQYv18w<0`)LA}VL_TwT!JTc9M7?oft!rKdM&Rv=a~F(TE72c zW12NY&oQ6WzKjufH<4_F7$+DR#o=sIud-fCljGHNlD{3+Yj)v!jtM;Djgay(hGj4v) z)j95Fi^1c9bFBvea2oUU_Zbr>)~J2SWY^h=*}kxaGd>&(?1Ludb*)zx1w;%%ExSf; z9_R%WbjL5Ic4ICshnME!9DM;xLw3Vm3>&3vHaS5%@*OH!Us`1lryT?+Kbte7Y3wNf zmkgYfPg`2{I?*;VB^xd3Pb7@*bBeYTSx$0PF*%;7|N3}xNen)@yl#AuE1t~rictPu zo|ZrBZ&8|=xMOov;5;lIK8gzicM|K*JtAJ&gbx+d0Wj`wZ9ore^#*Yxo`j>F<4J9? zYgHj8vjR~)o|G)R^-VHhDCm-Qc*1(&Nr<}yl6f6$B-;@oF432@&12YX(mwIp1iYRu zU{uO%L76N0_86UZ@HiT@2N^Zn`O|h6$N!vw+~+QW2>x8{Yr*X<_lk9y(rO2rNgaf# zC)J0^#8sx0>G6;K5sja)XTnl_Bj}eMxL^?tcFTY^X8r-~FEIc41C+KTH6m;5nix1L zo7!o$3=tm_W)X9yGrPHBnFgL+h_!ZZIu%RPj3^GXX!dhsXT>`bgSQ*R!WayyR~9kr zZ|%Y zg{LnW2B@gY^%C^lOCs@%USMR;jS#~I0RfyH#FX87SYl?z%GZ^rGq5cbtjC&Z;=6&= z89a~+Am-HqbC~wfz)c*)7+h;XkJ76ZRH_M%pTsYmo@aTMB!Xy7mIg z;o{XygjKKFFn*4U6LmbsC27Pg@1=3KeXJDDVb{dfGc zrEuDjr-gBR0~zF2(D36LSGzt1McRY7=}asCew8`5TN>Z~-5E&FZ$(Ht$UJt>0!nG5 z%ZOh)gJopj)ywOtRP%|bq(|fZNo)|iedEh8YQ)Ogy#m6*G*vlf)x3VfQ-|y>8OFC$ z6U%SwspP${l?@Zy>X^ni3?ujZarQ;!`*7n%>f!RWFY`F0Tlz1(i}~1V3LO%VQ=EIq zLOI0xhb_%966fLio{5)caf(&^^{_Ni{nmNySZ}dfRsTUH_Ze)FB4|gd#bXb{!yw+; zKkFkrNQ|7SVeIjPO_Y=noKjnK!GBoKn<`8q9I701fwo|&P$o-)7^b-DH$+ttm6Ijp zEl~-S*Ne!|G<~VSnE`s~-bNZ^Cst`nuGVPFLu* zt0IfPW>Cy=La?$Q=%pvcBDKoDJ%QMkPK9ff)@Aw#B8E>B*_XvY&6AJCP=M}iE)?Uv zEIl{m<^S{ok06l?4QZSEsfxK2H$RhX_SGQh*;V&oa`{I@XfFv{20{n+!ikF373>vX z#C8#z`4K{}2Z)uH;)xPiH>H%*xHUh5fE{t_PbxtJgW=p9?OspL^cuN3w)DVJfKT?` z6KJ~lz4lge@s!ANw)9DdQ;BXG&Js!TQ>NvReEie+TS=-KkzTl5fNqAxpq#!m+>n1% zd2bjf=cJmpvs#6l2^PaGi*;)M7MC$(3@CIxKfp}fqM7F=P@0sP?o+D zI;-@FrJ^x%2PYV;UX;*=R3P`t#55{~fsGe<* zwl7istPA&TPSre0V5R>j^ChiUCwU9)#fTpMgXTtU+X8ViGg9Vh{5O^MPuC6%2+Z9) zRy+SLx$t$`gbr-&iq-kE-ix~bjy7YZcwvUQvaV0KDe^iO)f7kub0JS=;9SUqRFe7v zy#VXSL|~M9s@}%WKK;Eu6Y=w$x(oV(tfQ?G*ERGBm*Mj>*&t}^G`CGnV89MGier)H z`x8e&-Ow{mpA(rG2q#ef&;!=p8OM$&N8-4OUA=iZ_YhL^gd6u1sxvL`Md>dQ`}SS5 zC{KC=4$H?;I7Rpkg(6{9Oz|9i&OIt4OJrh z>Mx!CjZRVobC2^Xbcg ze`lhVdj!5u4^r2V3F%V~wy&9g`v$Pei)!eH&-sDp%m6M-5xuhVt=iEMw7~lKK$Cy# zp&Dg0ir30haWZ8msBIY~4j9cQx(>ZfU(15&g-Gd^S=Bxt4RnydpJM0m`uTrr{nD8! zeHVKwgN^!}6jN|+aWzntLJ=SeZ~ z6OHLJMPN?V+W4a{PA4T|`&ngEmRM;dgVY1aR&w`-&iBJRfPx2f5aF>_=K@-$oFpk# zp08Ei4E_^-uu&;BI**Wqd$gV;vZ!eCsaG%DUW! zq-?vkm%xe)7Z~c=hMH3h*1plCo(2Zb`Z~}{Mp6ifVqxGp(<^nI`L9urS9#SvmYsrIwPI5m*M*;4xe$VqiOP!u)%@~*DHXlIf$kf0Zqi-c6gHI} zdY>@4IAT9LNaVpNdT z^<8I~2_s36v@%dc$5k;aO_n$v#>zJ@ zAWDPqrd66v7z_15?92&WI4dPK`(Gz-@tX%`?G$-W6CTlbB@#9Tf1Xqxr}mJCP(!l# z(P}O&dsoe+GSth1>Lw-C*G%TbpApQ=QlN7<$?!H|kv~yJG~hK6NFx=du0Qk6H0A6B zb&up*(jtbpm@0$1z?0rS7>ko#Qdb;ajBLw{Wc|sqPS!E3_OdR~ z?xbU26V)oM2zi0#)E=g$+Wi_#wd)2EF&S8^%cGq4&3Oz&@AiAdx#_z9t_qG4BbJluPNma)qXfowh`+~^kwx;V zx-RcLsUpN+{&bTA_XcUK)A9A)aRr#dKmfxZCJ}Q7xl)z$pAdgf<+NMZ^B5`WT}MwC zW*1{u0FeDT6PMegj3Z4a%dMw({~RF}jpN)yP5}D#7)wsC&4nxR`D*fze|et--l-n| zO2lT7+^Z^G`UOTgE(WIYAa1-btGLLsDgF+35y^T`yQjwl?vO#_l1;Y$eE>ALGtMl< z*SnOX&!m1Q^$13&Qzj91_HtszB}{Yx$IVe7Z? zXeN@tt$h+F7qIFt#kZ7V<1gn>b0j)<3vLpFvn0ag$S28d)_3CYVai^8m=Yu#mJ(B; z0#lCCi(!-$5nhCCuv%<_I$eonhA3%xvYAVy=;=M$wk4wMzih$JI)Y zX5!o!r*qu>kYSml#K#K5VRH%T$@xohMS5p2k$D|kIT)oCtVD4C&`=IVi5A00lhgSJ z%=T{+s$9~OClg`gY}Zym-R0oWOO7O(=1)<#(&b$^meV0AQ2!t)+^HSS(id?b>|PjsH|^3V?%D;=vc4Ev-!_SD z{(ll+XSOS;t;0$Bs>=uI@2U~MuI$p9H|vY_#;0|ux`+yWKV4LXK3f-&s_$A6epF|v zAn_zNwbhV{$bR~aitvNFq}0~aDvU?{cUmQ3Lep_VAZy5KR-V1G}RV2@A z{n*b)k8&SZDmrXxJ@<0-8GVl-ZU0x4Qr~*!S#jwYH`Uxv`Ly-pBV%qJ>wUEC-F_W! zwqA5L=H4;xP#-{bVtT*WsKtTohzM2fTDkOStYvZkg%Po;Hl%W8zqpLW0}n8tM=7G7g-w<0Ic9yM5>-Z{LszTLJQng~Vq0pa`Xi7(~kpN$yPlu@+w zmEDQ|Y&G^q)I&|(Dwfh-CtDSEHB4>F+`aV8-KO)c#!oYpHrb9YJ($}5TC0gK85T5U z?G1ltefwYE=TSd(tozSPkLZ$GEh$QhuB@>qEjwP36u)FrLG+p$NA|K0b!KhJ}ox4 zh4{R<$H?V8idS1N_(=SxxaaKUKVD9Gul2%+n5Vxf?zMjTts>=2>&J)0{I9ro{qo-~ zD_^&Maz)Io;y!1W|54QO$JUEJiut3s@6XHsy4>;J@uzjf7y3m zx+45os-<$t?AVxAIRhdh9ctGBOY39Xx9UGR!q}i@4_N+lELz}|5pfP}$bgjvvDR+} zjErbkt&Ki`UWKhy*1;9+^_?bFuGtpn-ZSXJisVqIIhAWK#P!-U`1chZ^_>@2u1|^| zuxCip%1)uq8!9&x#1GpuG&-_NbLZUyHtmiZH7R%G%8cT)SHre^6hCg#1G86VHKn~5 zwk;xI(w=^6R@wvBPbv}Hn5G3^TImU-UKsGq?1aY_=N?^|t#-K<_Uuaug~x`^UfMI% zVBg%_-E7d~m!5IR&+pUdWu?mIk+@<@n6JOHu zUs{!aEMwf#d9xEy3p}{IKyu1q z^(C!F-+IH+*9%Oa?kRMKKhm7Jd*GYvP5)XvZuIK4MYdN54nAtSxF>Jg>RHEZu>;<^ zVfy-;@fE9Y$7Y@QfX?~tr2O4&=ayxi9r*H)_WzqSv3@l@_3Q9=wu$DPhdwntp4a_m z_@RjQcUnDs)o^2X_us;g+-U#THxKKgPsQ59P99B73O`=dA-b~K-tOf4MM=iv#l50e zEn$qgZpNC6garwxQTLyv-*=%o!z9 zUWwk)c4O=GHM z;PGi4jC-ryn@)aSWI^Aom+_@I&)$_)h3#suRl)t)y`p1*9FczouQ#`z{}t*nYWe>PQeJs}{=u~<5l>o6F zzpH((wY~pR$KS3jy1edQP2V>^WPsj3{xsfbe7~@dbZTn&iHZ)fVH^5gJ^9thR6}LR z9ozO+ztkaN)u{=Ui$}*A<^=3R+BBvnS1y?s8*?krZ%9O(+NrW~Q*6R5dxtj1i;}v8 zE!`WN_-q_qk2v(yA<1DL;rani6*u8iRJfVQ{x9ze#CS= zaKMVB>Q0LXY*-Ors14h@GP}A@;Wc+OyR?CJp{ELixDU_|K3|Wu4cBZ5R+g)H!s*^6zJ-Y}&h_ zV8a88hgP)h{Bh?UlQ+)ZF#MaLeImO?riJ=%8n7-eCws`ybVu5YVVn2H=f9p?Ftl4) z+G}B3PHgaKJtjwfGp)lLm0P}uA3x`T*^yU1N_%_q*0A`8K7ZiJk6C1pnT<}EG-}wI zj~xN)#{;%!CQSW&*v^k#A#3A+9m5i4j0&c%@Eo#U=={vIjkDelzP-|S*Lu10&J`QW za)y7h((msw;_5TIH_qEL{Cs53>0N%j`s~q-3$+o~B75)a^4ry2mp4ASXT)zG_qo{R z-rnba-}rQ^k*!wsGj|QE-JP@vUDxo%C(oWc zsIhDE(&y*3TX!q3U+$2&^mCK*6%Yui}v(sL@vFX|OAAEe(h`Z_i(q4+#T$3|;)v7#y#*nmqT{qWmaBRzcuq
    {%Wg3T{~GqQeF-#kmMOJV?K>S5hEhJZtv!pJ!8&%Jg(8=U0U~M;#;@IT+W?P-EHpDSHIZ& z_8!m8PbMDgRzC2xC)dC09PxYZ!_D0gI2~3U8DB7aMUgqPvbH{H%kjkp@vA2lX0EAi z$lmf{t3qw{KB#uXSe)k()dzC>BqLWr@Zy^7N*lh zrykR1p*8wa;@8dzD;_9wWPKQZsB`;E8$4fpQks`_Xv!h`)@$EP*kzbEy-VZ3w~ID@ zmosty>iLJVKKx*1(bk*Vgjbg>xR`aR_VB%gpKeV&`oO}t?v^R)WFqzIh6p;_lLSZ?wO;c>HgM zr@!m|*SAM>+jO0Z+C+!a?QO0d-PSH_W6_?Kiwo`1FC5F>*0y!Awx)8sz1<7%7i=>u zDRvH9)@V;&cD!JlxKi9FdPSr|n|fmXw)m1s!=qOXb!41;>XjtZza~xibalwFIql>} z+syq-t`4edaQNDMcr__yWATFMb>BJqT>DVBJ+;&1Wzp4#tOHMe)WO_&9$tf!`Jw;iAI_NT6z)Q6@%`=r^PF!jWz z+f-N4^Pg_p?s|Uw*NaU?Qt)|m*R|Ig`|TLgX}Z-|dnjXbn<)i59{7K3oq1f0 z`}_YJifNf!+e}T%)ZEpyPR+iwnk0%TZB#^wD2h-=_Q(>2kd&oUmMn!LvdfaS$R2TY zEM-@gb8eO<5TvQ$aoo;KOG0h8vG?d?eQXYJi5 zs|dbVU=v^mWO zJLG4Y9{#jSG@G8+e3-AeykfcK*5qZ=Hx$efZs8%tZJ6yIczc|xs?Zq5bqu!sQZL1tI@2xf~Rhd`)8EaFyVXAKP6uE8hRe=jGB*Uv1BQ`Auigb1^%2*k>c&@gZ{!)^(_TS4|o3$h&-GuH~&A zQ+?NVcI)U}HOq0l^)iIBsj4S-$BktaVL4+ z>dS95e)~F|_wKmge_m|(yG*}B1HWJ5wMpks4gXNpTCpFMqjn!RA3%ir%DboAEu4*#cD z{#ju6*=*6JTPn+dmsLNvlz&;a=uY^Lh=6x1e;u{^>&v3&;XjK4z&5;R_s{IbpTqxc z4fto}e_!qX`?6SPl#c1(ep7W!e(yJF31!?bbg;oxhS>hUyGv|t_g^~LWOzTVy+L%Y z$8Eg}gSo@|r`uEaa;L5vC<*xdynnZyvH8-#^-L>`<6hm;-(fRc(ilW)+*j$<+gp5H znmEd6gGRb)z2_qV8w_6Z~c`dcJItv9Up5;8dnb1wAQmt+8onVCdd~LcZW$ zUPG?gyg+~fLn>T@mhT_M-X9pVN&eUwDO}d@A{C%^QI2! z=KI`R`SZ5j58Wj$#`<)WXcaSpzoGw%E*K|)n^u{((i4LUubTML!_D&@P3*S>Z5mF+ z&?DyLiy{PRx(kMzrPE>e@>RL6pM%Po=F8~_+3MjDu9BfuOgOV*j;@Z4aGy2w5R-G6 zj+3pKY9ib}^jH_CqjKEQ{&QMA{~UVess;3i%?n1ix|;@HTxH32NZz!1t%;;^=(XWo zp+m~O{$)kddBL|`tbWm{vckO)^19%MOzX)G=|>A2B9uMBe=u#9@62#q+hn448}_=( zref!uU27kh_@oW{*k#+ebFSmM*Cu}Zhkftjb?jWQYu$H~fVabdmt*W&;<$c*sm7q6 z-Zgu@++}&|O*aI_58t$eFLzw|b-jIL(1zh=`E-oqnmI+H$lyD}tyBArb6k6`$allA zAH$=bn=IM2-f@Hfo#8)*JMMEV-L=u2@zNwTG=z1HU(>Jn=!V$H5wk)-aJ%eSD%&_U zQd<}Drc2PVYum1k*^xHSLw5Y`+`Fq>chk}hqpU(5_Br=DRz`0sh#c-6YN7Ay7&~f|RTOAI4Ej}L^YcOJBhx=XRjj}Csb&}jhu6QHj@42*V3&$)uZRFZFV$Ghbj->*#$@@oc zej`cRb8}az!feXhkvrZJTl)p>F)GO zZT&UH(>CPYTZ9jeYw|?OrA-;uG4focdxp51B{f6_Z@=M;1 zI~(UkhQG`At9SnE>kg*b0)z1G5Py@M@v`!+sD*Xmf9VIja{hI+{O_p6J>mc957u*G zeA_m_JlAd1fVYGBdtoAS%$KE&qTXsWdznrZ0&`R~&fCDGy=|K+WcOCMjkMW6B*!J` zYsKJunM;Eg^FxCQT})#tN8Za1jBpJdTI0gGUpelccwU6aE%@4A?$DjjqYCRHlm^3I zx!AI|W}B~j8&P|07-O%U-maya)>}me?H|tF=dgU&hUg96BSON0#oBvI}lUmqkWR zTdl2k9q?+;v*_HVQF?A+*Y>H0R=wS{{bbZ!x9~Un{4Q7h*i`-_YKhw@{r#Gudku;! zyrY*IL^!z(t=}s!+v&}4{yk#5&aiKL?PG978&^lhxQ)o#CyLo~C%P~#Dsz9>t9}0W ztNO)Mghu7>k8s-Wc6TQov-^7Vb~juk{>8^s?~ggLe~jLNiN)1rBaf^eecx?t)`6KZ z2k*p4&TIYlUOswYZp@*cnA58Rf9=0?Y5S6xi)@> zt39HQ%o=^mZM5b<61&E&_-NVad;8<#4oq8KGc)E`TJ+9@X^;Kan6R5cY74q*Ts&$ zF)Pl?Fr~hFi_?*m2iGg(4$5y}E;(2iN1{r%lVOIrysnEa#EI*oo7o7uD2F zKGwo{96B~_T6%5Gu~)~QJ$SNoZ03lJX5q=0_{`UcA}FJ_Hf_%8FC$5r+T$A>Q~#}(h0mEd`^_=N1?>p#bB zzcG8E=WX`M{hPb25_TP!Q+n|3@{^GzZzB^9tjVhPeE98T;+Xdv5{|6N4yk@T`Ba9* zqYDXD?!Ub``1JCrc_p8IB%F7jtAD6t=;_rZ-QMFDUvOwjRIqJNkXeBNLS z!zYpXeu1N(`k@@Be(T@z0lY}>ypAlJdE>3!6v@=P5bH!7U&E|`99#Jon{BExNC9>yS z%6nw&FmdS3RkmWs&6n0&nnq0=QIIbeJAc3Qd#w4!iBScsW5n*+mk-Bs?oJ$Aou66j z`TFu%OH0G#iPH;;Y9%R6EtXazlGCOa*48RsH$AhoS(7|#`r6i7@02U+AK6|^&KbGx zgV_JUm7kC7`cKLoxt=8n9DUWOjXz{k{>UPsB>2Hqn>KilibihGN3o>=YJ;s?y)JqE+Y7PXsN5urnLP^ZA>#=d}vnL-i+I(3Or?@dSPIzSU zaifw~l7wS7=5oE>O+Nd+gmENMaB~G$?4Hu}ehdG|q&+t`bEVT#THcpxj!YFa@8Qa4 zr`*3;mLQ$Jx%p_D^hrv4_12stGmhOjAEz>#@}gi{v2=EJ%k4Pts44FXw%1F4``+?A z&R06AXZnuTBlA*jedPMznDS$KdH0b;uW$X!9c(zYU;6JRN0+ADrmO--Of^Wa5FcIf z`gZjrj7dzimD-~@!L4p(Lr+b$6z)ttTG-V3XWXzaQ|(y0mdlD1ca~U&xTgwgR&AGU z+I%OvEOczDN6qdNvXbw265~f2PL!nYX_l2`UrUMC9!XWEuj@FvE&Jk}GTxI^-WYYk+d#qvh1lE%eOyB&WF6r3KJ#7l>DetE5_;4im*loe%VCz)(^fKX|BKh6y z$D_ukMWr7QuB|=x;PvD2*3;Ld*QFn8J@z=|NrrXCjr5c0$GeX`d;MgQ^-RNz3+W9S z$)B6=uYQsy&A1}WFI2qT+`j3_tmKU5<`aJPodcd$S|@MJxLbHK;h59tr`1og%+enT zPo*CJ(Dd}=lWAWv{$QP6uIyGkyE-;UI-|32WySHYd!9Y8oXBr5es`nm4o;s<~He6qOzOh(vwRzqy?bAxTU4l{K>p655{Rz2s~ zESoly`q-#xU^;gQY?dFG$@zF8slmviX~4D>PiER2#8B+&_m08iR+(jPoPMdO!F0-> zVK%ErWV&Tsu5I9S{yE;JU`?hZtf^ULHRi?i_QH#q>aZ&xRJ?~T7PPPHKT8t^E-*dj z<=-VmKC|?Vt}#wHR=wP8vvJz2u#eX?CtRFg?Xf97Fl+S3>q#d(s$Lyy-~3`$Ld%VX zYLUh3OYK`6W>0Rpxm_(Qd9B(~CQY2SwmJ2%LfP2fzIEB`%&?YbwfC6L_wCy+&dv$D zHTkgLmChgA%Kw?Y#PhcPNzJe>gY6YQb5^x1bn+g0yz9Ef&S`Vjer(m89Pa!^V7vRk zoXsEaB%Q>-PVjWki#gj{?k@BmW$`xX>0XDd-7WXFdqq(rx^dWhCSB$q>cGd{j~mKc3aq!bf1ieA5=?@|C9aPvpxS*=CF^~x1U&(-AO&& z?UQx<f2J=EEIx_sc*r8{n0&s!XHsEd1M=gO}ecH9{|Z{(pj z>N8aXzg0fFwqah>y0c)J(`K)co z{M7IdyU)~L`Tlsvllk*!dVRch=EShS-tKsMYW`fWPjAkgx$@VK9e?~Ye~H&;eg81! z-^>Xe(giCAbvyZAKK{35`JY4P%skkwK6_=%Tl)zwH!j$?t|!L-#&5kMyVo}sl+E~( zdA6mq*Wa$oaAD<)uSI9?O!*OJ_cnRLzE2C%z3*3bjkbF?ZQ;RB-x|+8a{ec!eCN)E z^|!u1^>4R0H>>>PlZB@b{ndN+kAXjzmVdThbm`FF+;cBh{`@?yCu-4+b-nU{&d#4Z z?7ps9bZ5qoh;whJ{5oj&{l=olGycgu_p$TWyg&Zxzqlj(XJJ6kn12tJ_xddE^7^$e z;E&_~KKkQV+TxF&el?!^+xfrOcE1iR{{HFTq=26#|9zcM{ABUZL;v-j`){C5sm(^i zCH-b%c>GU{4(mB12p#PW*YkRhUNG(ZPhUcPW(1yRx-gvWb#^Ubum3CVyx}T_^!FV{ z7Fo^gKkGcz#R#xx8s*Y6bvK+ho!T$l-e6>|+f2Q>^PH}J6YPx&awRhd+&OPGwf_uz z>SnI@%z-`Ud0qW8pAXVqsu{(k8r_EJu1VC9Egfd8FKBeB(*5#>>7=FF!>`qi&P=@% z3FhYUkuwcuE!n1bB$mgnUK)18V7$g-i(dV6&da3IbwXTF+TF!mqVS4f?3@ ze>Cvdb35I<+)>7FmuiMH4J-ITLson?rY{8TWm@nZrsu64#r6#x-Y|f!aP%0salPs2 z3zn1gz4(Hgd1W)rW?cyD(huOf7%i`yX};k?I))w*oiA*4Ivcdb z(rk8zdQ-l%Nbn$N>(zxBblA;&?@Z^OOFO#E7Sp3%`fDOwsG*gy=7oPo1g#Esps1q(+E(Ys<&jU)FT!iMgTWwX}DoL;RqaW}YFy_bEQ9;3T5q18S9 zceo<9hR2$(N$$2^f4NS@ZvHbaZ{Trb;hv%O4V<=#W9O`yVdB{|^n}Xd&BTPDfL~Jt zk1wBEW$~qQeEXUOCcnO3u8KAPRXO47n%$qh`Zb-mWbdqm`@%3&WZHCLE}QitDX1Vn zLX5bb2Fp)>j-OOenCTXHxvtC7&S6qP!M*2p!<(*kE%GoPP+3siY7^gdqien>IYs7Q zmMNw4jOK8c-AZ{`u)S5fDEPLem3aNsmjwfoWZ#F}fy(KN)S$wB5%Pn<4<1=1yuiFq zZG@sF_;JIa(aGtPmLKa@q?P}WTJ~R`da3Y4=C7`%(<-yy9MHI(6}$Z#{M-_5k{{f) zmugjM{1o^*Tt@LXYj6d+eR6tUixu$ zsi1Ps2u_j|cgw}>AZ zl=AxkH65}`>QN-<397tmzKUHE$Q4U4}&S5hH#oMYjx@9GW1CEkJ%rxT8)lQz!T zFt{h=L6D2S&CXo~n>O639Ts-&uOZxhj#H{PjP41Y7*f$-aAeo!_6;|RMr^+1`Nrwg z%aWfP5;xf9h1j^7Uw#>Hv?4iDr|8=ISi!}erMrs8caIDU$y_C<4KIt{XgyWCf4Cwf z_wmaOb2hTOw0DN9xX$}D(Kdy%dXz08`>zh7=`JHg?omBpH<6_fM141=wmtT$*9-CN=qj;e~r7rAOm6J9V-ih!J z9nN)in*95wO&cZ7qppXhy4r?^H*8vO8kG{NJ>jaJT=jKRxoNcN_34c!fxGvb7w_B< z9l1O*)h*m<|H$I~vtsJ5kACAe!Ku2SxNd`WOK5zYd-|)IOT{N5$9xPO-{!vX6($W; z{8)nJrg!D#miZOQ^KPD% z+O=Q4qFc(J?X&7;QHbQ-g)6>3PST{Zn;T2r6@u#{OWmSAzu#VbO)^&SJhRlxETw7W z2YYF_?$|A*vLh*vZ*I+(&fe2fZS1pg%J-4wz0%y2+c(*BG*jbOR+LKeQ(9$Z!^~0z z%`ug-62aZ+Wno`Z1Dp4{%BuwTtII}zNtM)R}3 zWV{}Ex>Wh?^|O~-7v7!mul77wrPuk~aoh5{GkIZ`N>%KtxY%t48;8omuDp@{mvV0I zw)J0T28Uf!t6itOI=ZdIY<5amv%aOmx%2(Dlnb*zuDYe~T^`hBzTJK2#yOkA?&f$$ zOnEEw**`n0F6^({-U*%We0@yJvYW!%-ngbXf7rD>yfXW7%aaq{7j-}0+P>VMz)Tx|LKBMa%Uzk2=I&OZyvyQQKaX0JNn&6Ho4 z45w&Bk8ah^4tN-tb-p~odGXs@tJ~bZS3T`0ANa-V>#ev(^}sz1U(5e9TQXo2W3}6W zu5pJc-J?saZueh3IAPhO;lB%x<{H_sjkt1ModI0S{?ljt#_q>ipTzmNp2=DDZ-2qr zKMe%c%b5TFXg;n7V*Pa4{d5ojNdD-MKPP(!u)F^3H_8iquMdLt}{Js^vJ5O5TUPQ<^A^uu8=C&;`&QtEEIiq>q3HfEG64JbXlj zs__MMLmBIr5%3}k`~+x25|Bf%(j(M-J*{-x6}f5{XcTFCuORxZa@GLm^WR^>(r* zP#1Xl0<2M~0Iw$y8r*?GjBAC4C0D7d0>~bBq$QXUM0OiPl~f9JIligHx1?@?)z5#305+RTLftna@UM|Otf_^7v z0Y9SxA}^7}N4k0p_Et)fiY+giNDdUE&`3e6#DhTEiqy6h%9E1Xf#4Mai%}~jzk+-o z01fa-RAf(gxLHNt!zaifsR^OiOT{%} zGvYil+#%e9qK|I`YmMwwb#_b#{sI$=tY{ZL^$|+NZDbv?)A2_E{ToH?N93@s$2Tw=kop9=Ir3FW z#TD!K@B?g{z)e-h81u7HdpPMckx^Sl_JAfklvb{ikMcs~<-58P%>4o+5|Z2*XalVy zJJbw>T0R3AECM76SdJl(>d zL3Roe`-5yl6RyJWLoGRrw0RZzBPXhH$R#*`5neSiXsILP|6_P3t+aDAri!_5}`{azDT|!W8k8Zkqq2yl04XqjPE`S zyA1Gkh#n$2m~2xQYRDf|%?l$0(~BXzrYn;|$CP}NK>S^b1|l8t{i*s4<%iRHz-8kL zk*UwD!#0VS(0U|D?u9%QL6J+qn!#x@Es-)23l;!5*==M>7ZIgWF}Tp8i1Rv9ykkKU;~E+r4#9y$$ht>vI4KqJu7R{DNLsQJz4$68OlSPD)tWTnFKLJXCMLo@c%JB} zq27u{0iQ$>`DEE0hoiq)^gS)Ui~!v^=(VNvSY*Z(v2hjmXW<*q2ohy10KY*&D8qN? z-f`kUZN%A#wwsi`!Qz)#rQk-PQLliu6j_6u=P9^uB{;|UUTX@)`gQn8u}Kd3VLWPb z5gq&h&TJMxv0F$sSs4sMnkZhPAr8W^iq=6`stCamVmQdbx_>>%0ir&Hi!3(Ton-kz zn42C0ZU_W~#ia(tKa;+4Ge|~PZ(QzeB$G*6r{q4#nhXeR?nreTTK-NQQRW-m6+K=* zSCLNW?@FRd*2y!+HF`dRx;<3k$=V`=+=;B7ip|_6z|c|f@V=GMKO-Nx5J~KwM|Kj& zpj@=~6;K68?Gle^GYCvVl5udPBpjP2?W)+qy!1S=D=m}Yh zL&*}^{C$6ZC+Cs(QRV@gK{-g&QGlu*TX+chU;#RWl}D-%@Zu+4=m6A)Z(>wIDfsc^ z21rpRuym=FW{^hwbR)8S0YE3EUX7)s=yC_a`61a1pj+%o^405N-lN~#&jWxRDUO;O z=~ZYU9_Yxy(UhWORT|+v7Mtf9K*l1e4n`Udj(eaZ1qlbAfGC4KgHIB1NynAmTGVc= zMfxQE$dIHcg(@_I?r7lj*GBnbRr4p6mpY3ku!^HA7LrUsYB;i zNj_O!Mt(cQyz-!BBq_a&{lMHtvk%oU5mLRjY2PyrYtKU#sMW2+s6GUTK0795AOOMxV0#&sW@0iClLhe-_h_WKc(jil8CPnQU6t{^;Lh}0xph(;R zs%H_oCZwT3DWM4x@Z#R@gbfU8+uLxyt@!%`~m>t7%arl+bHN7BDu{FW)G=G4LY(+hYDLFQWWQl z_BIP^RUmTRVgeG;hjfhaZ5)J0r9#%V8deWBmoC$h!^oC{ccFk75pV~Rw(r;=&vDQ} zeku7RZb5~rHZ zR-BFgOeO<79&Z(uPa%d8Ni%F29(Vz;`mkC`*qdU&{wR?NxTO%%KW9?&5o<&?NEW;Z zVp`2{>?Yy08!~po3X^BfC(1Pd3yI@MF_A~?Pl`vLuo(9uDIG&gNb5&;uqu`y8(7hH zT}P2HTkJ}WB^XE4c8FLi6MUhtN4_9|e4sTHF6$|YUHS@0oCTZEBL^k*BXVaXjnvRB z(Fk>8t0@xM@bXn{t1uqn@w#mFc4Op(7N||cqir=0kY}wt(W|3GPO*s{gp}}mdaS~3 z)Z~@+U|(iJm`aetp{`QR=;&115;Z?JLFS zBLR7>6@ycohDR4zd=dw$Pyui%$Uw;iCy+vU!XGOY-6|~QNE^l|eGpEJBzhf1?Ld5zqjcl0iZsCRB5!iB z9nsE2x72D#RzuIHgiZ=VH>b8;M<~`Z!35)^$$i*Q5uvlo+k&CR*$=s~aeAn_fYLyN zTuwuYz%Zg7l?5UO^xt;qwVz|`E{Di1lI-z`z_a;U&;)4YEeDzqfVnvH4XWO903y0f zv4CuSKSyYRD+K?TVXLBOEta4n8uk4-!o~(qF4@3?WTisj$Y$iE$KwPH=+7a)pP#O# zo;e~#RrWuQ2xK4?g3shkGNM$Yi0i<9>6YL+K(ZO?IoP0zxPif)I0D_3pvD3VDBd5j z*FGSFLDX*y^mt6NdMT5P0d4sJkLoCU_YmPC0>^HIfdTOnEil^H++B;M9KQ`V5HUN* zpFY5RgbhAR#Q|7~GoO+5+`#)AQ73FML?wjZY$Ke^%K(CVkjG4S0yIh~=p=-Cg}4Q3 zR;|DY+{Y?8537`14*iuYcI8sz|w zvFK3Wm6MnEKP)#P4$O1Wp3i+vRD5!((3@gp5KA6x_rIX2p(V*-!LEgZ8V*u}m#h#x z=8@xpyil67lTV-*P=kR~$EIj&vKJ~O=pcknhoP&I1})VP_8T^s@S$UH3cZyc%25Vz zz+^u^v+lTx5MhAEnx(8rLj@WD%#9$P3*31qUCtu+33W-VI%I_floFJ5wScj^NP*u= zA>|PDvawGQ50zGm;gdp8Pk_aw4nrRiwXKh8t;R4IPOQuzvLr2>fHX z*GvxTC_XCC9pR4@I`AWyfPKs9r!GiI_Cx&@{MN@WUx%g@h75L+fGjN+Jher(`(-P1 zA=^kJfmZq+To`C?PIpR ztP;dnP`~TRdw&dZE6Ogw@bGq7g59}JBeHV-y_L@{Hve`X0fN7tF%Q!#;a#h zdkuuH{DSm1mZpz}lb60$B#_x**Op>8{q8U)kqzRPnx2xDWte48gTW zJIVtuQz2;LLqUy(4zLtLstrPDFt{;U869Cjc`60FoI1MD6DWCZW|Wt^Ft=j{2AHJ% zNi_#b7?7u5r;FxN9H@~|pPBd$ryc>rkV=BGyTKj3i?p^GrpId?m8TA!N7&!GkHZhAQMAvJ(ZkK8X=$@4b%ZpL8pqmh)7w^SAd+L%ecrBO{^HiUp|Wi zwf6d6{wLr8l!7c{s>zU*)1d-SbfF1WpdDA1UZyf2&8sU{BkBDy(c*bU&^!vDby|#Y zyX}TVbs#f{@LvNn&rq$z9jYoV#k#A|-$NbF38JsKBsp+@MdVWgPYA@TJn20yRJ0f> z9Q~l;brFyT_9+@rx^}dE096{`{qa30J0_@T5fbPFe9*`BDQJ69g*0;}k#<0f6ZaPZ zyjzjdc*qGGp_>C04520^vaSOs-?xnL)c@>0NeE5~3L zCv~x=9_mA>WT*n*9CWF&wsCwJx2y;lFx)JW<3Qv6%>Za3IQnF~@#bg{e`$z&Rzgsh za)F~eWoHJZg~3edMle-LEDZL@$c4IY0)rk-8?*Tq8*@9_kuH*E;4)61_ zi!f+{qCf7Qw1x2GLyJ;Q4}?lhZAH?|m__Ks-WR{dst_?Gj6!K=H|(lH_bw+bih?dt zF_eN<6sL%M>;x+0?%EgVgWG8%ThelLuV*By+Xy$M=qI*^N%=LkS+J%$btFLCWgD?F za@Pt2@TkR^Hk} z^BL8#(jt9wQa{u%FsJFIGU%VL>O-)$7@-DBi2ny~MiL=TdWPn(uY`af4@UtRE{Q=t zv~Xc5Nr@WePUTua8z@)BXczqA(3>16GYjosxw~N2t1tIothGMH<9pn9%q-H08y?q~%7K-}qy zFX*8Z0-zyXE&9p^m>+>?3lv3T@bb`@Q@$)TTGp1@VS?8_;0Ij<9v*K(Njxc}Ifluq z5cIbfNPU4b`GU``Mvy24=bYzEc98O>8Brg735-Ti=21~WlnZfb9NI!kluh|@-ohJxdf#^{wJ@aHQ54!c6dGoGMM%k++KQD{{eSR5YR(`ipxQDl`6odeURv zsmEs8dYF=MFc1Me4Ew3y?NAZ@2D9M9hbc9UlxlUQ+jLMCTfbAQ_2n{pr8`d8X9TtG zqc++=QyDKwhsZ_^H9!fT4Z3>)EVl-FsVgc;EPW8{X(M?XEq3VmLP{}VIt3EsSC|yF z-bd>nB1q=S^o~76U5;E7`QY6Qys4dwk=~ z;J^x%3z$Y40nV*#n?lc$kbVvQO59(?-RRR%K-CzGsiA44?w*%B5t2`D4WKt={L0M#AeX$U5lToA?>m41hg={wXu50PFwjg*uR|}uH6-p9A`go}p2+Qke(eO@9UoJNfGv6%uzDKr z10{CNy<-oTBUl2NcOKAPv0Oe_ipBaEjRHc>n;P`$3Z6_I1l=*J(Sx!(Y%gL*f(58T z<0LP=O&8nYT{Yw$f}G6)$Sc#YeW-5+cm;6SQB_=F0S+A2F%J(K-Z=(b;^2R*9fVr3 zPy($Q>;{7gr<6f-dY!x=W)o#^1!>Q}0L^lxXZbY-qcfNAo^dN);7JwhlfnnYB>uo# zMYqGZK?A&HsGb({7d4+szlzdu*@-H*6#l>g=&&)Q>oCHL+~or!=0r(GTmmyxHY!y+HDHty2oMF5JL(;CqrNCv%*bVqk9fgxV zmXT@#ln0-9p13_gCt!sFf|d~Xhip)+k{aa9JyoOt>!Tb*8&|NvTIEbSPD(}C;7NHK zQ2)AMKa?CF8$2N5RGiME6oWvE6BX?O`T*L-d?i3seg04t`Uk0?6=s9L$R9pUHM$iC zZvg+4cdI%c7Y85%m?O_|FWVZwvkwC~Qqimqz{6mwI_N$# zc?+}Ahn*4gp1Mncn=2+2S$yIG3}r~3L5%7bHOHK-&6>$?BVA+8d`hXsx(uSGcE=R@ zZL5Y;i}itvP+|H}2Dd29cB00z*{8B5Q`Rh)kUuQazjn;ya))6agcG6ihfcoA zpA}ni9JV);hVtFg0O;mT@ne-$EMa+&*$0sg%7&5~dw&-GsxsjX7yRXk#YrD5zOkuz zZ%9@}4JNh~4j-$*lYilhrpXryVcHJt(+P`BFw`9&UTB$54<%R--kuOJoOnP!LI(@8 zJVYloM2y2<`(VgYjtN(&L9cYDOnpf`2Rm(m9P87>@W(ZaF((bjA?Qb&eQ_s{_5LV^ z^GYIaU}({bu0uoVtWS7`Idmk8+UJL6MGX(1GIIqEQ^7m7DX34qd#M>^7l8TL-RM=8 z^56iI@$Q7cbiGR9w=*@=J3knh3LbE+3;oce0`*M%=L&G0iOaJ#4X@y?7Zx^l#2Gr= z4j&KLkxi{mSjzCA#DWqbm94`3hX_(ZB_K2dPFmjx&+t*}-v)NjV@&P#~KSLgT5b z-HfH2_sAIiaEt|hYo0AWEppI-8>HYXT!tVhm4qht{C@h13 z(TX`!LqnWHhNuWk-M7BOf&UQ>{Um=d?|FG1)Q1tQKoIBYECG>0Y%@Yw5pa?)gr4I& z$Yp%YRcJA7FQRjx{IA`QP=Fo+q7ezcxL`4pi}OmkJ#>^EHFhKzim8Li57qi(8#G0= zFd(Fm$rlMWdEyjbDMlHTHuibHu@YpB7}0_8zkoW1^UU1 z!`&8)CN8J-J63|9MQ%tOSI+xyD9Borfm1=qFnF?FLx2)ir7{!p#)2Ypk4zPIRH4aQ zLB1Utgcu4f4cdZ8;5?Y04Z|UIFo>q@5Rb+xJx}Z`Yt_}%>jyd|=1;`@>;zm}F`2i& zZ3S&*d-7-$pkq-}!>!nWiWJ@kO|BWbtr?hdL>$jLFds&P5DyW`UQiT4!!=Z5GQ0yq zepdSM1E)XJ7m6cDlqr6(uzD*$bvA<cmFPMqrPh&V> zg=hgSX2?}E9Q5!W@jn%k=p0oh3#+Uhu)}$l#+V?6=oS^Z-xC2t_2A1=(M^~jMKD7K zro|8d1eaqTyi4#?C4aEMQAZ5)GB6{qqMzH~+jfLTpvX!gO}USFO)u)G?u)3Kqid?E z@uM+_E_O@53XWQOJOMAkm?i?p>6QXJS1{+#K$W^4Wv&ObE)fpQDKerV`$vKdu^#e-}p&1#qF4!Yzs9w8}DM)e0 zg+V$N*gYLY7{MX9#+m_k1eGg98|^BDUk$uA8(paq;uMtAcsR}A0K*VxhXhtkfJ#V6 z*_h9!rbuc+VCtQgQYXh1okY78NPU+;gMNsVcr3AHn>Q3enZ$Ipdc_3P4<(jkb!ZS~ z<HTqftX0kmJE%RMuqGfUpb@O_gvP;+HWJdu~H4^~@ql&jA<-Xmhrk z^gv1!F@8$=zJz}f3>=LwmCb?1f#_$OkQ)evb0u(V3<7nZWU47Ia>sW}G$;Yw zTcChBAd=uJf*hfV3I{Je7Zs5QSiF)RkJ}&`7oFM?ti=A0DF|j1ll< z1H}d83)m54@=A*7rTIXN@^(RB8uJ-a(6_5FTQozeP9#&~nAaK{3WhkZC>?U_a28?U zFG%-c@Brp$vS0J+t@G z;}mN!{%(bOX|))x5L0oZ4@~F_;`AUG%JA;-aTA(3Nq1FI| zR3V1+`a^hJTY;wafpH6T!+D+#zv2b#eR~l){V5W3%C-Pz@Qy}-L)4?`0Q^D@9Z-KU zoGTmsV3;X!PvJxP4YnRY37-QZt~P`^=f};!9frsF(v3+1Q#e*yBjCZK37H4O6NMG> zUUG@T{V%vTwjfAUFau#k1h63j4_3#JpRL`GXC`Fwr>RSzrmXYX<;#wQKnsl{ypnIc;FVn4%`eoZyuEt0P|vJ z014-kmwXFLnyYbYyS^ zO`MYZ6V=#0g2BNw=#KH!fWa78T`ZlNHG_-7gpq>HKq{JNNYbVT&%IP9+>|_p9i{LmKj!a9eBGaXrYBT{&=s3&3I)dq1 zEr1z<`7r9Kl*OUiJrFP}Qi7%JI*HQtlb7&5jKIMxN+QS0703C;>=&dv@nayOiWx3U z#L+9bB({&owdc#ayYL%>1F-*OV&ae@2Hsl^@k($riqkcb@H4}a8~9Cy;TQf`P>DiR zECnyk7(QHJel{4$W%q-DL+6$W?!g5M&L+?WS=)FY6Ozur;UosGa+Id#MbhNH37-7@z4ebO9(~Y$OZMG31tB_c_m`>h)E7u62Ab&eS%nnpeY_#fueVk zdI}&X%=KK%p+i@EP2^BLgj`5LPEp{lr+O0akSAs)+Ig2nx$Z<4y1__ZL;IOiZ#CNY zXdXluUbxBlKQgaE#9e?X-&kz+N!3Kc%7Jcee+@$K9>ZV3Jq7QO!ZzoOKW__ZC|?C6 zS;D<0#W(7KC$xQk3uPQGJ&7T!C!A1dPE$ufk=f7?ZAmN7E+(l8<4JgtcoWAF<^a9O zGItDK?Nl`glrhu{7nuDMCkS7%+t;2lcBr{M=Au22}EN*-$z!uEd9wP{HN^$e;n zJ;j8QxRH{D8g|`=ABw@8k_)#9WZKsV5PU^uz0~3oxZwL6P_iL~?g%1>=={sxdQ!2HKgC!~4c&^k7)x=niB2wH3K#9ohC}Id!~skS*r@!=ye3WX1Dx zJT$(LL(eTmDOC`F51))0$^W?bJlT+>@VG_v-{v5(gNx0>w9m|;u&{=*spY0{-2p9l z7!C>W#YWkiat+XeX#-()XP{G?Bn4~+3Tc>}R^hi}b;PZ@xH2EK88CR7jqxNfFTfk= z13MxCVLKf_!2Ly9E~XJRF&MulLX(>N8kOLRDXTU2Hrh--eYi~;cnjQ2#MGt-4>r6MV<%|u%jvg}BZm{$eimE#?O#EY2Ob8jKsLf@IDPT!+2DQ zw@N(-f@ZlIuU3WIQlZpfzLcR%uWHg0~%f?v_^M?{7QG-xmVj5_A5i zI0S3Z;|c8mkr^Bg^;(qGPe-265htLx@Tlzy?mC1IDWB?+Mh&gYAMX4%o|geBd|G(N z03luMhBDEE8Irl^<-kgs1A;G|#02RB*OO>O1q{JBM})W~5qc(pkAz_eT7~yW1_!15 zKQ9zUC{f1>E`SL~N#Opa>b&6OTHz&ZpjcjL){l+W)>ArKu&W78KuTa^3sXsWn_vZs z9w>`$_Jf9Yn8!r%u+_p4_4LAMbB;M?HDFsQVM>(8b2Gp!FT*fi3xL){5u+0` zm|zoQS`~WNd9mr12#~=%A)4t{E&3#eDdsoP>i|UP!8pZ3s8iuIGy&D(8h^53H$|sm z04cS`6NRQaf_Q)1ZEFm_Y{{3jKdjP6n-V|qcK_4&Z`;)0*NoY+8AK3 zqgOZz7Ei}XF#jyBAu}@NYmd3|Q3}5n zxVmL%^w1#%SWrnaoDH!WCuF!CB{8{UhG3!t=bwY2JuiVp#f5M^{NNqdk&_52g!550 zay)@pLrp$6N<$_)t*5yJ$-)M(L9k-)iR9VtST>iWEO zpn1SAg9u0P$bHCU?n84-xaWf|jfaOoWa`6UD9yjOq@sr*@N+icN{ZTE*t~oi5i2s9 z#uygy4B24wQju!bx{lLu^2r3Y*3^t>c@1y%U>JqB={iTMQWc|z9;R`lIJ@zjFc(8S zjTN;D=|%$P3>2mX6EO`*B23MwfwO@Fcr8LdGEfY#v_$Zy9l=j}Ac=tu`@9dRS>}QJ zN8FuY<6=b22__T!79S@0<5aNHQTFMxh~T8gh|n)}Pr;xkE(<+i%_KY_F_@9m^;BVB zY&)umZYqS~@6AQ4-z@^AC>ifPk954%#*}m_UW=G6)56_vK!v5j$J1g&ol8aAsKn`D z@$Y7I!vByA3u4Vw^fmB|fI-b@hdG_YiX{Po1vIYmiY;yx9x*jBglc90b=j!|T@2l+ zTozMK?UR7JK$EjunNSC&_qqCHyb>d7Z#wMey#~~^bRiG3GqJoBxJQt!(pm4%qqV?M=*ouM3xOA*44zIP{JZhk^`fG-QGRyePlVBb(1*vQhUZQg zwLogVOpn(LLjxyIkLSNc9*5GTiNM(Y@G7H-|^~xzwa3D7tF;S1dyd4-|(4 zHuq(6$q)-uy98#Ptk51_ftT24coH%}s`+Nzb-cG@2m?chq&10TLUa%+#LDAJXt)^A{72Lyz!igph4EJKBE@n$dE?zsm>Yz65IwQ5 zq%f4p19cWL#)j#Wsg)D8o2YCd>z5!Z33~wAif+})#2lXwS;2O|>}((p9#oVXD>PYZ z5>Ti{`K}gVRVyE@m4dcd(`RLZR7Jf?8GNq+$5bJxG&} zF=h4;2B-EozArA!ohz=Pwyr_LUu%oFE*TtXGAooY4=XRWrUUa|$tVNV0XI0YQ`}*S z=kbjs@RL~mt*;jd%TEDU3~nMYb3Q|vrRLSE8G+!{6{7q57KYmfS{Xh+4mi}iwZt(g z1zA@`uN=bX<&iq*SA$#QZs&eR#HM=+oo$;4w=!Bt|5Uk;c!Cc!0zS z7!K)m%8sQRZx?ob{zKE%C zDDnZ;oz5hIP6B-@pzjO@p94K_9xrzu9y$wdJb)zgFs|nL=R3(rfLILzqJTn;-%#SWgmf4`wwGyxDC)3xA9BRj#uX+h*U+l4H%7_M$~|sR*%JfRKW}j`3w)tnW$-Y zFe8JESS5wP)N~KxCt_HK)sI0J0?U408Y31oq?YwyNpjg#v#)+K%qS1BODssAYAl{N%( z${ zf#_TW7AtcPr592C*W$tdX{YJPxl8Xgz)kEJj-hQLRZc=g%2`NGc|$m=h|g8`8ewx+ z8X4>1Loyx)7-+vRn-RB&XSsoqeBu%BaiUXbRuS*3Bclf#LE_Xg>{e;c!?z%ZTSQ%2 zN>vyOv(Xm2y`glJIoov9m~Z(+!a&vZsEtbuRD+@hLJwbL^!M&uSKK@5T$ zzBz*5a$++N;kfv4W@cC^qVE2(2aHMx)475N2GZ>-kh3Sa(+CZKlFRbs$T!t;(TT259G^~i7 zM+nduME8opwZUAD;1UcRo{=eb*U8|A3J}Z*avx7))i{Vm=Ax2|5Im9l1mVwOfjN^G z1R`ze<^N;rJAj(pf`1cwC?60Y5K15+gpQ#Xk=_EK3JQoyC{hHZD%fbDw-7ogQWa4Y z6bmX!7Z4DvV7V6*>=m)=_3h_-^X9#o|BQG-_{u4}dv<@jd$#BM(kJhGFj_{N9mY`TNS?v}Ls#xM+s;>1Q1Ax{_U4xrdt0#RgI9>kW1 z72t%`kzKyPj-Z;QibFwK9t4H5XkahwL%@n4=m2wkGK9*>5bEb*3|j1MB-0EGAMa#< zH}0d(%m+1qc~N|UKD>mH1IeIln}Y`v>X(847N!nra^ntw%Z`?egKfb!NuM;x2M~oi zF69^+?MdnOhw7IoHNU;WijO_kY^Yn@9F1m{q7InYcnIxW1~umo7wW@7Fbz<$^~!%} z*{D5GSDG z=;TDH0#axP$w-hgdjWc0)dpm34!h7nWIq!&(xhC7DcP3?Tv_9Rmyt(JOQ_UqK-tX! zzt|}utUIJzl0l>2425rD`_8d~PelKcU-15650pQkMBd$W*fE9kAtykAgaIQYj$1MF6}JH~-D%6@e?XGG$f)ANNoqvi%B>tYXkq zFM1$3N&v@MMFLHomPc?Tpn*UynCyNp2!;vw0))^Ilxky=ZYH=v(ooGL^lbxp#0){j zy+M=n0=yhBC+0}96SLomNW%9)P*QR z!Aj6@R!9(GZ9wz2gzz6?{o7AaA{FWo8qnSvBZbO<*rO$%jpXl2!YmjUgTY`(@g(vY zLj5UJxQ~%eN1(qzKAZ?z3ygML9WIdH27h9sMI6!G7i0Mg68vc}4^S6AK}B9(692CX zi0lIJ!hmZOHa8P6$^)rDerQ#8HDHPDH25V$0eM7zdj%OI%fE$fy9d%jDTP@`85u%s z&3s=Tg-=jl&xiuk0gwRpG8nsnii>K*-bQqN!NQneBK8{cY!RfLtPi=;dL(cekb3|m z0Waottm(%Fu)dm`x}cx z=aO_m0^)-KG62O42@W9oL+DrXCg9vp8W4(?h1NYcqNpW^h%@vjq67~s(}G*2vX0A1`A^Z`Js@A2!ujH*F! z&b&VB5QKQ?Lm-Nl5$vLn-2yQq<(p{^TeKpuo4?b-&K zIrp#C_70FDTD=|OQhV1M)NKW@^Ft{*?npy5xL(Km5}OYNQs1C zWyrh?1%1enVv`99ttaBS|8M9Jl2Qr944LwvfI-b`I#guD=tJz(j{sCjgP>^08HG@T z1n@Y;9z*Ku0ZmyFL%viQ7n5@EU}a>2V22v+yro1)4TpzcP6KnRjP}SMqKd#Cc~SzQ zBnhP;oDFCmVCxG*{N?Yzc6#Euq7Z!1Nnvn8hd4e4AFF#X&Y*h zGyrdrf30F{H{>HZLQ1y-$Fje8^rK7l>hP*A5h)$ojqy2$5J z5RyUaP)cyOC{d@!3ULQx3`PTXu>tKd;1^2%V$W%VwPY#MW^r${_-p~?%p>b_Y-CjcmM*K3D8%fNPs>CF9Bdu z8O72QHj^E7u=_$Vb`J!qk^KEZ$5(}-w8k|Q5j&(?1RNEL(zaXCx~iy5fU@VvV-4eB z)Wtx78h8#{K&oxi0@~515b6v$BR1?WdR&xwAu5poNTun=T@dYKN`hVPdF(#O$OR$?zJ$zc zcz%H%a;gQUxEsJrM`4;cIH=?;WDqmFygvcN;u`2XUlFUV#P0G-q;qK#UM{1ja1QuL`j2X9K99f5}l%31dH=b z9JG!<7D!$jTFRGZX%2tbi~a!|`VgrGzg^q~zoG8fv~kcGz{uDN%&K4xXe_`-!AE68 zjE&H!7cg+*;2o+&>?)emg9`@FnXVF2D@8g8apn}p5u{HFEOiwCS0LBb0DNcdWPX$- z^-<$j1mc>WK@bupv<&;m{5|Qkn(bEDU(ZWJ*n&Roi;L|+AQnOJOQLGp?G8|r4bgB! z0Nur_KvF_BxgJYOA2PmB-h1`KHiIA?G67jJf>|+!0<|cROF+JQqF#5}E?=;pLn4qgcR=)CtAjN2R)?j&zX^G7 z4;%6)#`G6tUjpnMSDC2DkX%K5ZuMk{6|X@amBHmzHb6|qG-Dye5CXxG%?E&d&N#xB zVMIEC110i<5F(cc`eOzRu4fvcfGS9IgbYQfObdA%Cg7TPaf1$)nSz53B6CNb{;HlU zQphS`;GkYZ)QA}^Nem&w=`B#e7vRYCis0cA0bF!K8!VG%D7pfA@CpgIqZmQ7VRI6r z0(sdpAjfU`F71j2a>k1UQN*$rpnGI=gZc`&PRRXJ;I1~Oh`^Bx$bDsSQyIyEHA1P8 zMH-aAC1?bZ;B9o7=M>cK2urP4f_2y54L5Ab1|-87K6HhlIhlxix7ZkMNfpW2E;ff} z1^X>pk6hIND?8AkwVr(WqBhcouwoa3o595i4>wAj+&kFryzbx9{5H?z5^5zVi`q! z&yokj8F|8*J`QY93{)zFF$MLCEDv6Cf8e*T;po!|aFhr9rvq^cpvC!5i^Cm|XlsNP zmxps>$eOSzMaq$bryFfM(a0h&hCM1WtfmD!qeh?9`)3KnW6oNYaDlv312pmhEa7j->{93uY9%I--c!iM5Ew=7f(z->6;MeYkS)*z4BVIae}iyA zv+|qgfxh7oCo&1n88Qat$!?A>^OvS3x#pusW+{#h``K5q<_yTqH(m%L#U22w;~*do zj{x(`CC@+<3aa1}0Av0s){JV>|faAShS_LS&3! zg{+QX-Qg%5yMV0}XR*NubM(NSRrF*6bIMm$a%RB5-*8WJm>&srO`-E5eM(|>juM&x zexYX!5bCr^KXO<+<};-2G%Nv5aWO?z2zU2`&D9WOGzSj<97Cw^4fW}D+~Hlf;kGDg z7Ej+(eH0NIhRBbQ{uB(H2?kRK#-#i~IWWV*K(o}Oypn-#)L(`>29>e(v8t@`l2UA0 zS~-X}Z44bm+!{nV!{Gd51Di9z!T=vf=)xq4;RDvvJ$RIsRC5tn0R+f+a}B_T{)LLq z!#OdKq-o;*6=Dqqa6lm@*ypeoW@7bf?_t5u?*SnQLgPv_I4J`dKnh|+L|uzO6;_iX zfZ+mmgjz8mU|?tNy?}#V1NG_?>wn)O4bk|?I25yoBoMUN`&RIjir7jJO~GwOf}y#i z^^g1JwX7m3M^qsvkOK0_3<|5e!_iQ7sN1G4k#-vV!154&CL&(|ydmaK?pnA8VCO|q zTLt-RlD7PC(t#0*c9TmU@{3R)q00cUR#W+h;;=O0V9x1~zCX-FR% z_jWIK?)N9!2^{6xHn>BBh9q19oJmBRUhY$(~;ChBJ_}&9EX2lOkAO(kRzC-%tE|3DWeefB! zztHcGDTZ5-&i<-cjA`s2a*bF!xrRtCmm%*2VWI;{BzW&-K~*BS0gBV51XMbVF@?l? zQ;>zGeJOyg#GxkK+oQkHxtK;92RYRDrIT}@l(v^B5~A!iLD6aes@)`#R}h%hZk zZI8xvBVWb9UO9t6$df+ho+`fdIQTB0%m5@DW8M3vN%O#$kb^{mqecj5vJvEH3423T za(RCdevuR&k^wl=y72sS;MF!wJ4V_G=T<=+0D`O%2;lp$gHR9?@Cy`}vsBR;E@83) z7b-|wJ%RO^6lo(sTyb$A3Tr?HT7&bPl#Qx90Y!+bJ&Yy|Wjb1^IK+pLf5<06cx==e z*d83PtF%o*BwfS7FYX1bKj41Q2NjH;jk^qNGKOWt;S&aM%#gu?JcSLZaTJwCmZ#Wi z_&!`avMlO;k9DkWGn~>!u1m!jLA0k1{Dd9|>`KALp?i}1R&pjdHAHAyLL@^8$hHHo zE(F)9k-!z3dlvB?^s6MT>;$nuA~|Z?MwZBc?*^Q};B0?t3DVvbNKF;7-75AQOV}y_ z!v8Y-h|reANVgGwLP|fWR~@jHU08qGBN5UD$Vdbq0G&49i6rkyfe=aa0!+z&0|5;Cyx(WHE>< zf(fas0q|`IxT3(rf`Tbl&P3DcMbI;5V4Y{Z!AyfwLMVk=&|e=p{y3sL3hhAH3qYNZ zK`urje~4%JcV5eRQ0O9AQu zCA7(?HSZ3QqW)l?n7}a>vB(uAKYq$mKd98`R-!42d{G2Xh?TzMR>*MKCJoP@K+ln( zLx>(m|GPc%elHmM2NcCbXyC&qgK^<3hHM=1i=>2~kb z=jTHbv?Ln=OprVR*?emVGNo)hOxmi7%pQ>63}I{Okd7dJcg3E}aEQH9Y5ebqy$Ql)3=fXZHJFkNzsenOaC!q-zXi(%C?iA-d>RInt-#2D;OT&4bhihHWqvU=BS<}QKvby!BrpK6 z0grkFTu9$YHbN`MN5g@ZX#CtmHGqQX8?p6puo0N7bBMOuhKr!nPM?DSN__-}+KZ4c z0p1QBG@uSeL~^gfI!wiYFloXem(+lKV(F+NLnO$@2$8$Mj?}z@qId8_1Wx`0h?o>1 z326~JF!A8o zGL^gxww@iF%V0<$#=^G{<@DD)e+|*1bG-<52rL&170`75)z+OMyUN7fy$c>!m?sE8 z97YQIQB7XH62j;=%s^bppjOI0Qp#3D=qRv3uZtL*(5XkNrXiC}U{fEA2i+gn0;Yg| zJix{mlHk}E6=Zi9*z$QQU{GF528Dak1(-`I5_n2&n;3{+ioFs{#ts<-(+w{Twr#e>30_ z5Vvpx5ek4=$qs^r2WyG~DSd{(2(~_uP;N-s1>P8dMfmjq3nJ-)Dg>!+wv~{w)jR+I zK#>BPumW?|qXgFmZNoyuAoK`wxnWHm5D+k6F{0_IC7aRwElP(B;<*l>QcE!i>v@WX ziu#jM0Djc9hV;iDfS|h{Vf}$PE0d2oNRu&~%V0<`uxU;KlrGo|W_z0!WZVJL|4SYs zV<9_*FQKU=&~uumhC+bftju3lK|lC5Wbjya`CYrnsV^ zR;wa#tQR1eaJJlY2#ji>z^VZLScXPlP}Jaga7+uv@H+>nfk1%cnEXYMujViXZR^2m zr<2qqA+!+Qww(qUO!^d5hY$v#)qWXdqn9nn!QFsS>jH!h_NOsz3*@xxFQ5YM-w=2= zM9s{BS`WJc4WWqvcKVJM>xryh29A0o260X|5aMQ;?z_FnPfg$?QHbS(K$Xwhx^N^S zjps1(UR0?JmiS+7MCKZaE@_JrSI9L;1%q@SS}JI1TBZ!(fZFT9&~z7pM~hCw)IKgkmmhG~!amqK#vn%*NE$*`R_){z${lAA48^O_i<_2({ab+V`rm+#`p2LDe zjRj-`{_hdUCi=#5g8BxK;B8_CM;;e?t`(^B!UqluE0&H?!3bqwZ7{+aSTl@}3RWY7 ze*hz&!JCSaP~maOz}aB6GWaiIW=)`~%h+BgBU#)A{mkBsF-P7KKzYsZD%^ z1NdMbnTR$(8SVo(i%j)zd}0`BHjg)5*d5xzRQRHU7IH_jBRzRY*HTPoGg^8?NrtHHsSS<(s;{%dMdHgzrH!W!e=bUVkInT5Ch48i|?Tnmm z6~Y)#fQHD9C7q@mn6G<0TOvi`mUIVlE~>~r=&NxFGLP5t;6IF zsK^QMvNS|5PkpwS_LI~;l6;pkwou0i7~0`ni_L$YIP#d@1f=5rrrC?sab&5K=WHvN|S zFGevdJ6;YMX*QF~dzzuh%}(@3rkc&o@}6UqTC$UCkvq*6clcj>$)4ifJq}A@9wYc} zR_V$x)OAf?vb8K0T=gPN4a34fUDlI=UuH>e1Hb+7#%g$FwB!NtK z$eoo?$g)os68}iHambgDRLydz6p~7yOz{-DNNR;RcIOk8DBJjo^Cb;JoM!XoS*me- zrQ?zoArv0d0xe$XA(<_X%)wVl?xD>)G{j5o;_-+nFe9r!<2&3T zwfl+3Vu59|`WL<gj{{n9v~7o`y9QS2i1L61Wjb)O#Av5v`RpbjY2mB^tHvm@jU~G)&B$nI~Gd zMtmzif;Gx=ij)wu3yqX0X<-?a3fyv$cMXlAmmFO(Y81GWA@6M;%@phOHaX{sVeHPNMX!q!|XZ{?yQ z6!?lPg(}PzdC$KoHmN4(m(JN*xaPeyQ|eGn=_tKBXc3tAI#nsXTXpw%>4NS0=)AWV zl`g98c~Sa!aQ)sqsEIP5nkrNVELE8IkxshKn?@C1>9MSJ{uEET$-CD<{H6Ye4(HF^ zr2D-4j7r}$Z#Xab^#$plr%C(8KS)}QTnCZZ(z?I4>{F)It-K%p%5Pc^TrB&FwSJQK zt5$if_27Kj&rIvpy#MBvf3>E6EBk}B;mOAkRruO60A7bP>tAC^(;j0woK9F>EDA15pYh_nf;x{I` zdNH)T)lRUhdhs?-T(z!i*C}>WtH-$kAuf>&-5#|Id^OAXt!La)C!W55QZ7jY$jc?S zgE%^lcC{^4sA689sfdG=q|xPMxr)#y>p8ghF0{$8I(o&9C(Oo)-x;(yz6_U&C_DGg z0^@Rp`+N;2%VS#H&*MyA(x8$?hLjH8V`KsZ&dSK4LsH3Hk2@0s?!ioJ?7wFUQr# zTgy!?hF*2_iEpE@Z-JIic&Aqt$g)$ynKHU48FK;5Lz%0^Kv==Vz-2y|={N zSh}cd#D8I#@c7J@RJUCpb+7SX*=npaAMg&a05Er$lx~sV_KQj*)$LYwML8d$8-4Rxah&e6-s(c_UeK-LBy)hh*2z z1l=my8?a7`-QQa?;IsMBjeVKxK8Bwhula7b{Z-L{zIET!uDz)FJ+xiIHT~ndKl}rH za#+J)A!epNU2v>hrIxQXm>`-RMMuUOEaimkw^E8t%b_yOfLE<>C`(T?cY-dzV>rGR zgtL`sJ`TLKBb-`%Xo!bsAwyql$7pvg!9Fl(swh)m-)U@0PJUx(OmRt{eg^OLWjUp@ zp{dNWIUTEK*D*wMe3<`@2uXvZ;o}l@l+du!sWMvwhSP)&k#;*Qq_`r@z>7BNQm1Jj z-dS8Fskd3+Mi^1I!d>e|^-EgN(UN51x=-N)Oc3>`i6%+z z4~iSo4C4f5Mu?4+2$!it5T7?TGb^<>G2#QWu}^!!@n#!m!*Im6Dcnm#?U9@NE3BVx z#Y}Hs(3n!2?Uk}^-6}9GF{sBrI_nE(UPp?SG{4kKe>S@)<4_SPJ>3#ugnu@8MJjP9 zQof`$%IMwloK3xpY80&`z1b+g{g!{dTXvK|$#*vSIpGdu3h)(2L2#Z1Fy)8}+wv~uh4&b=j2PiKHOFQm|= zACvFiqi=FuVBrNPtTm=u>}-_D%(DfX2H1ve|8Hw$_9cPh!In5LZeV`%r z_NHa`v0Bq~D5*K$u&*NSgZtGv)1SHj&K4hhy6Zo&0h}36#A0Oqd%JkPne=iP>Y|)t zVq(06#}LEpzT;A=yyMw;KDT3J9noV;sZx2L;^jm}aO)(UpD+~*Hzw$K9P2ZaQ-2z% zXmBRMthDW=87Z$hroQZHq|FSRX+nuu<|uAB>)~2DVQx+mTxnCRI-BV4abt<1_k3mG z5bTjVW696Tt((_>q8>mO!4>G?4B%LFkc_3{`R8K-sasGhaa%k$6kMXMd{*My*S55QR0aa z?`99Dd+&Ku+J_O{Blvzi!aqL!qh2P1vMQ zQa&~bf?74g`}Cu1$@Pyqq_q#JDmOX#mfVO{ug1x2Nu8T3gO+v9pJGTeXVopl8$MVb zarVg|SxfG9@N|~j&~|LCmK2$=*X!mAV?$@e=Qh&4+iHPjuc6wI;Fl57g5kbM&$pSl z^UuF5Ha=?Iw_E)E(uV%)Uw@GnrS`LLejrC0tb>a5p}{WpK)XLkB(*?$Yx3weLml>dB6$9O^cM&5jkj0!d^ z1Cij7$KhPCx+Db9idA?d>7t|HvKUyH+k$wp3tH>yc&P0Y+oaMeaxh%ZM)l8SMX zz-igiUhp+HNmXX~&I*Uv>W=gGtH?=bhs}z%*cy)uJXTSx%ub#~p4e^}7hGt9dWl&a z33)pxkw|P-WwW^)`~zFJe4H^^eU05DA)RICr6J5{hC+y)9GOdY{u&}t&AMxB_N@35 zyTD+Pg(kh_oU0OY!uBDNqHlQ&dPOEVL<{>!4YA|R#v{28B(&n~cL^b^OfzcUGYO?m z`}Ex}kfmnJwmhRj(kkEn4)O2J)<(h@Zxy^lcD{rR#XdP-!kgmb00jz-DVEFmR|Y_b zDR;rUQ(S!uq|4PVIj}KO;S`gm0uxE~R}N5lFtf#rTIeLHspWX|g>-X^Z(HFeN$qII zQ!iu&Tl}d-JLYs+9M6pt9#euUi<0N`o;dc8%l@VWOBb`fwd9>fI^>A0p!aag4eA|l z1*F~y;;zun?E~ooA-EvVVP)(th0kO@g^DxK^ zY6+F=%VpkT*fBI2Y6n`Xq)YvM+!7h#l+*UsU6i` z*IRYb$KjRhwwKzO?cLPsF?_%+=WvE{UHe&f^%ua?t8^gHFiUI>a+KuEo`o3%87^y5*`;|@y23wsPj$?zXrayV32kznC5{!)N? zOrhyewNz5H$IMG}+hZ@NhmT67bb8!-X~8=7rtR=~sXebepoCfGu@BV7F{v~y&u2K3 z!|H3jJhv+LW;wks*l_Mx$a3R9w?X&o(jbnie>dWNCUv0G^UF)C&y-)4N1DnHKJXkX zu>P$6XYB~aHv{j*J83Q10a_sZ(OVFL7EN^|` zbrT&#$JXe|l1tuZTFx&!ltx?kR+jmASqr=HYm(a=bEV6*e7F-XN}ANqHC$<9A0MMh zg03d*Zd-;VJ<-Qg*ws;UAGQ6cw3)o4Z>MX(aqXP;GnLhQ9k&#^rD(4E+&*xpMtI|P zS7wQ(!RMCA%DPU^h)D~5b)(NUw<_xcH^xNs_iCDcKDy|8h`TYV(BrP=`jlfId>e0V z+*j!NS=0LSF^pdm-Z#6@OGe8+r9;B6#mBd((A&7v@?3}F-PSDMa#tUBE!WWw9>2y; z-|AOBtWIY8aTA$iuY4O{ZH(3OlIe7~+hO3|_R6qIYhy~MmtUuk-_a<)p3Y4xo!jo7 z$nxuP-88Kg=yxLKE@-xWuAAR#Z8JWxx9ao{zX2_O+zD1n-F#&?Wz&V@{=^fZIVXc` z&iHJ)Q5f*JbH~cbMwwn8-#OPnckO87&d#cHmp097ZHYPo`tPNx3-z1+6%NYLPB1<- z?$Ztc?EefQL(yO*;ze{|i(>P$=NeBsxBX7g9!?JL@e z{KwYrUVXCp_vH59Cj$7pdG1}q`!7caOP)oN>5!T|R(%BCPIYMa~(Y>dC+WofF}G zCu{i6(ru==0ft38?&>t8oQ>Z!b1PtdQN(ARBcIP^Y?{Rf+80I2=(eWxayH#c3~=2U zHFvUhw70(c_TE4*?Px~VamRC=)psui`fKlu>gpOjcd7dRkHBE`< zwPTNWof|#>p!(t7E%DlM6J33d7hY9AzO*Ivb=-<>|G5j_2>-s?a`5#o?5P2@ivqGs zT0yzmJC*cCsxC_3e-aW@aw8$O>pFiQrDnN4sPaT&z^NNKeNQ$$yA{;nwj<%x%u1h? z>*OUq^418%XKbo0qv9cK?lVK<|m&3#T6D zTu!Wc_h9Rl6MH1}{{4J8WAg|6w&9{wnRQQ7u5dQ5`fR&VlxDo{`R6N5vR4YXrQh1Q z*ZcIV@cvV>UnaLb*tsv^^t+Y*7x%vMY+rKQU$So14@ymbx7hwd`@oXU=W|!*H~&oB z{$AwZ=(_L5*IsP?eP;Vtk@S^yzt3I!w)xNM_CF#S{B*3zfRI1dFj!DDQ;p7len8Hj zHz`T&7!?wH+M@P9okeY z6w1<_%IT-e5r)I&MO#_MQ@M}niq*r(^T<=yhN-;YbQQt~dtO2{#BnNLNuOFhQaKNX zgZos$27L{}XxqGWYlz=e;Z}W}>e1d>!o!fw#YHLl>%NQ*_{&L!h87oB=^O4IoAsC9 z7`n5#vvzTmLuH=-5XQ(u?Osn|KwCe!m?sGl4tC8j3Y#f zTUcRCx&9faXX7LywJ3}m!}UJnx?@6*NV^eMA5)QV#^c$96;b1LSTnP-#K33MBy#~Q zkK@d$E`v??CW98Vv%`Cs)zb!nn{LD`=njYXF>Br$Y`b@3|H3-n9fQnTF~g8eQ^gDV z_B$q+b-IQ-?oA!4Gu*dhR;QkEHp*$bv(EVPj(MGisI#$S)0gT@f9_b+IaF{q!D(i) z&U}5u3!THq&w_#SplTX-k~8X{8uMqNi2dK+?YC)RsBjI!)H_PzIr zz@4l>FR5tzk`5W;*4=kFfj%3f-Aaxd8z1{}rzz04B6?#MhEMjEI8NWbZ@d=-Ee1wSXaC8x%2nt1GgmY+!cFb@Z3e({bx`|I67te z)MMi-_ij__Ja6pW8+-cWIhT9)e?8dF8&-ElGkM^$CCV`NSV?E|`8!7+Y>}Tz425|2;sF^m-aU(13R!OP!f}K87X|JwDn63h7w_wS-1ef7`qIpUfQESK zQ$qn4Me>#m9wpq4S127$F%$o~WD}Gxm7rQWQe`H!=ZV*&e76LxxY3@Agy&DTDHazc z7{rauU6hY_8mCx#Bf%o>dK#Qg^K}2CvZo0)dgHi0vh(bw2a2kRjx*y*=G6O(>_G0u zM7PojV{`Sd%T0=vMTtIfliqz=5i6$@t8XL*#@$Hh(|f*hRk2niDI{*Hyw4!w*^FX+ zYjBjubeFlw{b!FJHDo6xc+5;$j_tpMn$kXV=dguu%`4x>V6k=0-0ipUmwg@f_+)F!xtV*9ErM!ZCqF**G^Ky${%?z5 z**EOR-Kx7sXXceI|IB&AJ$z=%?x|A?=9eP`-?kj?ZP0_}`||RE>+gR&zPY;R53sDP@ z_1|_QDQ%rN-^lt}*-v4l7xigIr=KlcX~^q~-!^kQ&1~26k5?Lxo!b8opuD!8FT^&q zY+ft=XU={v!}FzXf93rPPYAcQ)7)>qynFeW;OCZYcPjR7EPLhG-xmJ)6zSfLy@9)4 zC-k3s{`o3tUSwa$t~cfVXCl7LkRG`0OOM|5wx|F6^DqC99u@6NT=#Bz!==q%-~97< zcwd_5`>K90Cx2{v^l4wZ_y>NgLDO$S+m{UYKYsUte0Aj5H-hrho%>7Qf3&?i?);j1 zS8wEfH#qVvDW#$8Q;OBin%5bRpYtBT=&Ti3VN8CwY=3EZpy~Zu^VK`Y zet128btC2E>Cb&v??1m5y!}nZfwN^_?pi(E^D|!g-Hij6c76SL^`GZI4=R5UIXJZI zo8+}85x*KAe^`HTSp2)1^~#0s)ykiC9-Mg(-lG-S-_l3c8V(kh{qVDXz2|rL_Aj>& zKD>D+;o7_BzppBP6G?xv>sR@;)rkLQlz+ITzw(^vvi@@azeoQH?o0o$?!Sp^-<|)w z+TL{{1?G^no{PKth8@ZBdAtsW!{kX!9kH zXJgavjhWhB*>~xLd7iC{HTyCZBm~B6h~v+8sE9E$jAsS@u~Dq$O<6>iGdIi%{%4~i z$CtAxLCtcU6(ZSEYx$}c@nKo+vp6eT4LSbyMd`LIzgc0ALG670vrU9YSwY<*dk5DE z2@E&MDQ1Uti`EYs<_p|z`n558Cr9kOt?4&`1cG95c49noYw(Mo;M+x|k?aQTmG+aC z9fDGKNXyv=X2nBVt%Zax`_>4vv*IOYXkdQbiJ2dUtXzb7a5RZT}{c$)Yp)167zA^B`V9PdRR0 ztiRc`E%#pu#au8|(#Oc=I(fshijDTkwaDEii?F=6vr1R(Q{}{0$nWd&)+Chf+3!mg z|H!hsllPOO+^7sV!S5w&zI?2=3gVC@mnGa{Ynh)eq)*lw$r+bu^|DXS7nh_sITV;l zYKAyc^9XZ4gB*%e4;ZvKJ<3(*bb8eOv#a@r zXN52;3k=I?_eQovO6{U}OchupsIQIyDv(a`5-qe#(3s?Dk&`Y8@ir=Smeiz;hBryq zP&URCQpPo{96RKsT3dV%6^?MUHaVV1mFWrDbf@rsymsO!fCs}%oB4`Z-a4g@XZ#7% zPXZi@qP%raIiBwjd)TsNe^LCL&X{ALnQSw5>&c?s<#PWxii}8JCU2W6+MBR$b@b{B z**}zE(c-KGI(}?uURLZW%b{3QLtkrb)L)F)y5*5g$(+8GQ#^*K+8TDKxH19b=X4{L z_&mh%P*H8U_711%i$wF#fL~UJ=L`>yU3ej;H_Ly?rb*u@&gr6t{OD37wdBb$ql3z~ zQ|0wlqnRc1d0N#DF&*;PnhpI+PWBj|ce?*go)sEXS#q}Aa z^+SAow8>= zT8-Cd@=4D<_xhH-F5f`2FA$Ov3)vS__U_|`q3ho?l%I!gtmI$&sCMsqv;<}~^MH=k zS4rz<&c81ze{T)hU-Em-diU_3)B}^`48>xsj}79&6QCltyxEfTM9`Mz!v9@Hw#{+M zdWmB@rabyWJUlcznIn$3b8@+$K~|^coa7LE__n%;){%{ubC)@H;t9K5#0#WYPx97s zr11{9E(n!U_9UNLuIA(Lz(J}mD`u&{p*&QpH?q( zSDxENH|Z^?^)GMv#nqH#o>%50(*Bgy^4X+^`jBcJ3<79|ExggCJ64%36VHaJx7+H2 zD&A+fpJ#NY60V13R|t-Q;gxylQ*fp~~8F{TV0o@WY*_X5tpF>xNTP20 zN^h^_oz;L%Zae$-o?3Als9x~d66Y4TpxeI!PQxXipuKJjhI$j>eMU9&Pl8%*BroXP zUAYui^Y+%(t8OZ|bx+%`9I9D6v+co+xHND9UAFirzwwvW8KxYac~ob4HsS{p zpGiLWY!9a1c4H*s-U7J5(YcfBos&MQOt&33s^YyFRL?vcWifrMK&ffRoh!&a+h`B> z&H&@CXLm>H12*oAb3gT+aEUgzR=-^;CfB{k+(dzQL8oEIr`edpq{3 z`$eMZ%Cp)zO{4I*ZHkuyOy53RFjLqgwdhW+6I5T~(@J@I zL)v3F%}g@lNzA&!qy&SRv6p7b>dVE4);lM9cuWSE>povmL~0F_qGujoLc=b8v>Ss`ARIJU+w06|o`lYP+Z9LBDHhmg#AN-~=} zi;uSR?6~>?(J;#Coc+>mm&T73tJ9JGs3^2DYqwQnO9!$}I(N*AXki}}DR!=zc8@Kg zC?iTV=EDdO<@iHmb|r<&@DMmG!c?sgft|%iTUv7x z{$7P&`@q8sA0S$g*EF9S#u%bA>}V`GEmw+KSPPZG)xb&RvgCL~88jwWgHelN;SIuu z*o;~gGNaUcoWsBigmE?8Tyhz96xUiR6K=1@FyI51!>|*Oz^02S?LdCUH8ABWfmrkydoT!F-=}fK)xsHvKQe$vcSX}4;oh3&RB^rdm z=UNP|f?B|(OAb>RuNMa+9M*~I4I_fPhNz4dDh}ovK5#kGSXpK*U9^Gi0zK!lal;&J1)bqi4r8=|w~TOL zjCd@AInEB@y7=SO7!)U*y}pnX&=Zw4BoPcFg`vrDz5d?L80T~0YPR8NlA|ucwN%lH zzr#nztHG$GqOWiYu!BzJ#^4XdRyvZy$C4VUi9V*t;jbRTRj%5(cBe7;>x19kH0n8nI zNtJ^4qD1B`VE~g(ouC7a0{1Y+HK8+b8BQt(<`V8LK!t%C9_z`h;1fkFIAl2*NCFA0tOG2KJuoM9zEE^HA3Bfa(s31RU?*G$ zO03e@@L(>LI!wh)NC5pU)K-d)a$LBaFc)Yn` zV^wOvsD-4sFoB{Ik}h&w7M`nE0Tju`5p#gr^6>_+e9{wqEW|7=~Hlz^bI@ zLs#u`aYQ^$NZsC08UdRgHqK#=({WNj%4{YKS&l9`!DiyQ z&2m8N97%=&wSbD!-UO8_MTg}R1s;>5vq0Q(ImIwbK>Z*Q>eysebm7Q2ctUQ$5W32u z!2d2ZrW`%Y4th?d5;@Jl+rSTU4k#r9>Ei$Ll0A)uhc#js1Hto9nRvAVbcU(Szx)=4 zcK{kjg-AXs5kQWx;4YXtiUIUWK~%~B1`{SkC7*?R;AL!7@&Wq-Yuc$b0~g|{w^{46YB{?PkY?@k(dIys3FpatBpk5V+Fc*sSXxCPvw~I*zCmhbafE=VS{nr!r9K!{Xt< z_T@dqVe+uUGRsin+63l|N$Xw%Na@#*)hOjd9N>~A@TwJzSc`b)E_)Ul&Qj1lWV`Tx+ zP?>6yLcXtN8Ko7857$Q=q2vWC&S3-Lf*b*X5{6d;o&+XljdOtR8621?AS448hh2cm6gup3Fk{?elow!8 zD?n!B;4zXDRIGrCy_Pgq0eqM8PsCtoO(*^z zB~4)Wu9%eA|D&Z%su4QWX!E%ev~M{QsE+o(HK+ekFUu$pkoh&h&-|OC0m|6`oA-ks zCiuXc?uRKd7&vwJ>p##oy>s921X`P82eVh8UEF?p;svxBa*N_`pjDZZ*z_LSfdbn@ ztI!79B|iBKE#_y7#&>9&&fbpug|!il;X4Q6t{?2F#y@#>!|jj5Z3UoJsjkk$K|A1a zeOeUSV8L%v63}7>GyocQ-wy#{SOU& z_|ySME%fG`Q$@Py%^UumqoX%}5tlPUZ$4$S%@o>7XJ~lBdEKD7T*dH!CTXn98=E1j^YyMTG%M4_HXuQAF)IlSx{?8$3hBi~1 zpxKun-3pCSU-waH-n{&B9GZ6d4PDS|bIb0AhOmF)EHpPyieG>x@3G${Xl#%}{m^{3 zdNKe_&n}G-bYg{uYr^)x#14LX906@p8kM>eS{nnu=s0K>U*&WsK%3Ee{!22nDnZXJ zQ=uJ@mdoA;ZSb_q#6fsmZkjkcG^g> z+*^g-%$@mB3oXX&kIf-yn|SGYN1(O25Ad4&{^K})?RD*G<;?b;v@W4U zL{>;=G-PFkgcA)?8dfQt5?LuCDx9(#%1RMO84+b9+wb*x|NeSD%IE#PzTG$9PxtIu ztJAdEIa*W&vU7a>Irk)RaLDV^z>KNpXYmAS#H~CJ+&XvRBCzH4-%4PF{up+SpS_c+ zffMT*ZvYF9>E8kRw*=Jz50wu$0Ld=z?gL|&ST_NGv!hyovbxGvAo8#D3&7ZFTs!b0 zc-I>sEA`$ zgMmGNt_@=c_p?+nVZ-vllg-)im0kO+*zj6$t1TP0{xrl9Q#z{*j>lKvGlw0bsP^yMw?VXX`xR z+@h!gU|CjWAuzl~nq4!`fBCR$X21EaGk|~4!*hUAO23Oh)#Z7WKy=s9D}WvEV-@h; z-=1AFhqpxE27=CrYXRLhd3MdzYx}WlCSi2OBfw)#b2IS0U?96@iXVkYfJF)?+knB& zpI-wl;m){by1i^FkM3BHC*xh`hh$KcH^4p(JH2usJGHR>Il20vndzcdddA zSKL&-0&-S=_pbs)<|g~DgZx*4t+zl)&W<7M9H&<-x(ldxp1ud%?Emc{AoO!>#*;o` zQ|wdVQ_-#G!13p5ZNPkuY3v;Pc^>Eh9;|rL3GB=>>ITN#U-||3D^vOnIB);+2e3SZ z|A&3RMtkEq(fEKRH)>@-US_YB0w|tr9HkjT9f?^0x=j^PE~h3e0=~AUF9A-eH7^H3JO-`;3?oCL zfF{w2^}w#iPwckgBke2%ICh)10v8utiw9QjR^9=aUiVK1+Ise;0@=e`Gk~cxhU^1W z6BlIx*DsvT0XB8?hybU)uI%FJ@{K(T6mGb69GG`f4Hpl$PwUhY;GXJ%QecPsi!#7# zxlsl1J9p`2ptRxaRbZ*q&uhSNTmCKJ+5Gq#U|&XUJuvB-7Q1E?duFn0=IXHA$H4mO zubu$531-iM_hle$zt*Chp&aK-kR5|}98y@J=afb~*UTjs*hEucG zG_v7wbrlaliPT@I$Dpv>(&q`tTNRx03>2HCK6rt{wTt`WB)K`C1aE<9gO9!k)B-+y z#AR}0tKDZ{bLsjX!1>jc??AV%JUho@V<*r#&R?4@9fODMTliQGLBk=#3wCKeC~jg9w=B=yRi?*G5S;8ABTHxq09sJ&-EVy`0v^~oSmbR_^AmS zuIe6a!G@y;EwX0Ac2iEwg_EVa%WgCFoXa2DJMr@l`Ea0NEBM$g7x*-ub zxkhg%u<*#7UBG~*yma7^LdPB;#n~zo@Lseg8{lMB^YT)QgxtoAMd%_*SV03yN z(75_ByKn3$Fnj=vdbF?!_$7a$1vu-3U)q69bhs0{W`<>MYzJCzSHA)F{#0Vu%p^1a zkAT9Qy`KQ_&ZlgzvF_5~?|{w6@L#|?!_$A+)0#J_M`|OU#@Rv_Igq1MvPBUT)xNl? z0`lXu)YL(Vo7Yq=Q21?CmM+LUeOO`uio+fbXV(l*e(6A<+41aPAT8|2Fm`a?{T?Q4 zSo(IHIUBC{QDeo1SDR|tv0?MsGaW&})}ot_K#uEk)fQ0nRefqJ$Unu;lHhP5D5B0(0ELC8w)ex~<~_S>2=r0c zW9N9!V-B5TQe>Vn8}>T%){G7RZnU&w!==(|Y}xQqy9x)8H+-Sg2vGcNx3?=O*mphI z1LXLByFUttQ#9&37PvY?Fb-Iscw_=#d$E(8sJRZjsMfp#rY z?3xLV-u@cUKXJDMXn3Og0ocx+(+zmK<$VEuE`7^x8)pw%vTJ7X-PM2Cr8|^U!P$(b z@u{_xEGXPF&szcHO-xT#2F3E#_fF!wlMNd#QZaM@C84ehok8KirN>=B z-s7BZcTk*K@5rv1asOh*0MgdgK0vuZiCr_R(k8KM#;j@&+iSFceliWn88Vn%Gt;Jq z&%u(DN1J}L4fCe!`GPU3E}+C9D!?5SHXh0J#Nl={Js%B>QXJ0C@t5-tGi=&X%b_bNFhu zpGwOt-wXKO;bZ|azsBYO6=umIVD+5)`G9$HpQFI5OTov1gC7e{0n-O|76a=3wroFj zOSq2hr-UV!%YhLuWiJDtw8mcrj(eqD0~V}qx(W0v7*GQ|c(|Y*NR~g|2#j&+X19&M zVUBD+b$)-$6JYu6s%L=lPsNvj#B|atAanMfx4@L0PuOik`O+Y^ovQv2_8HhH)>m(JW5H>9`VS=853l?O$4M+M*)4srO2t1`Kugn#(CIw<_{ zV3;<@J0rJ54-`i@p6LS$hJ=0ZkHbCP=gzK~-M6+40sMa48O{zaXR3+c=gkpbo;AaQ z4X;hgv1Y?om2Gxx_-&_&6UaFV}4diQW{>=v^cTSID*UVN)qBr2GdDj>C z>Zv;sIJI&%yJkZ353#L=;e$79tI;HD$zHnI<*<4#miUC0&jY0Pao9C;@#a`|&8+;s zb1`6Ma(@}n7TAYfGubzE&$iqgl$0&tScAgI-DB-Qp2_u{j-dEO&%F_#AZvIZHykctMlc^xNi6UJ zu3dOPmK{8%!^W2lJM>-W$A&-pUiN3hM>fb#Wy8TIeP@6igVxkppr}E$DG20mcONhh zlz1**un>p)nR_e@INQ+0ZX1iG9N9SzwT)SUPfF|js@1@r48?W8#A_2b0P;P%H(|+@ zVNbRI>!uHi1FRFm5`m5~{H8&CS-t&|0?g_&G9A$N7489QqHpX6ww>T+18z^JTubM8K{+2A+3Z8(n`)dGB4pV$f6IHHbx(`VB4ub^{c)D=l@;o1(sQJegQ`F#{Okb$n&(FQd{wa z?60~f3-Ts^F;E1>N<)KHKta{i0yU7cA@02B#Mu`9bI z8t|^`-3W00y0L5Kg5|btz>45I34m#;2D@fnil?({=D??Hw$+$AxNSGU4KQKX%=N9y z4*(lW&m9CDU;R1+bm@*_*UZteiG}PF^f$BDXB$32Wp~^rfTHEUwoU^1#^!gXfD*|Z zjp?8;GkJO-$eVIGdk!d8?s^#v3aSU1uv3fiUmgnBZ#lOJ_)zi-cZ+VXf|s6=Y@g9f5yoo0hC09uiAmb4avNi3_QL4 zkKHzQ{~W{4(a&t>K769(=iJKzF7Grr2&}y{_Yh$9@$g|Rd29IoC~#62W-{yy#Tm+rB(o6S3SB6oI2dU3J85T|2kkOf9w{}u z1lvwI6>nzSscy;jA3&id_YeEz%^Nj^6NgVu-&Ol%K+(PYXYwFF>A_HCP~s)GSQQlh zb|}#RdFMiVwL$UHeQx@oVEC=AeQ~(w-)|29_L*o50{mx9AIc7{n3QeAhObtlzIlSqY6DXQ<+S3K(>q-*bK}nrPy(bPA@2N8e@K`y^2k6Zc zjR%VFzh+yFMY0xbJ2l8*)ij_bu#3lX z$6Q=I+}GuYmjk))->n2@^tXuuH2l`Gt;X$*mtue|MKVHQh`gb|t{IbFKX%Q$Fxq__ z$eP)53JBQFW7mvId1xtc?cGVX)!5kYOF7^$VFbHoKE`am3LH6g{TdMbOod%D2I^DT zHPhg+zn*>4jWQIL$K#VKsrFk5iuQlaSOfAW4{KQmN|dJaVnAV4eCTG7x1sE0EGV{n z`#BC2bQ+M%C>TdFBPg0=hTaJ#GrGO%pG@A%h7-8^GubfT?O8S({=Rgmhz*wi-eVyDiM87)P?9l^tj2_NvKn&LWHl~*A*&HJ6jlRYR?}cL@E=|rtOowW zJHPBEF!N369h{@4!LK^tj*n*}5W7C%0pN1Ht_k?sqWuIosWR&s5aKF&0Ss9Bx*d3w zWBwM{Rlo8*;QjByM}TAf?-OuQFs27sk(TrwFs*9*1-$&M|Bs#Tfgy9H6L6kWryiCA z#oV}eilE^7IcqM++4MR}9TYk0U(y2kUEVT!pycSfaeY9c;MlJII9#8WhwQe|sN8=D zu)}5kaCUI7C50wz_*eEvb2eOBXK&4hm;8;kW5dI&t~!FeXTb_1LGj*HKQ~Y?>1qZa zp?Y$sXgfx`EjqY6OY;o{0d zP(0^_^hr>lt2OR44p%>lWJdfdk{KR{nat?+>U}V;k_{Kj6<%S(iyS{zvEe~s_BYsY z%f9H_ASdINxE2(R{~_N1@?}l^?t_xbS!6ZVB$3sytR$=Px|6I%?f_T~d|6F|)qr{1 z467mJ-afsFy>hcfa;+O5)JRPgoFn&(C;lu0{=-|j?K+K$J3C>tRQ z@>3j&6+p?@&~M71@ZVlnHIR4VW~?SCj{JU02Nal?s2hNsmx0sz;c!_y4j2Lf6)y$> zDxF5d*oj~3zs#5oZ}clQW5W)cep<5Ok41c2HhlDXyaOl-)~Is^`39cauAro0MW6>L z+@6;^3WpnY{}sD!{FE^t2b{HEIRRJ_a)EspK6LM2cH3yZF`8|s_Vn&x+o_4hjdQR> zAyA)fr><-dVtbBt<@s#SVe`I&-8MS|maAZ=mvZeZLV@jgKMhWr7b;@gCS zz$&ARLxA~A(nalwq>FMchMd4Vr@v#mh^yA`a|v*Byz@Cg7_*6OHJndfs{lT=DqjJP zsZFi|=6meB4)lv`Ww(v{BCIQ9!{2M@(i*>%86u&sxFWv+R#Ck$gVG0qE273Lc$axI3C)OZy@QsdyZ|E0!^ zb5=4*cvSV*Yve(}Exk%5kh8^GS``$HSm&bw@;?`*XoHgD%@6cI;R5A;eL-G-mw9YE z^jchf}R$k=P_yYRVdm8Svvh5l?i z)$n*P`z}0Dsg>O}JVy>;+o>NB`0ej}?wJFp!-2)MJ?yqIJ8LtrcF>Dhzf{ zwo_Z9YBK@Xqgre`)zdVSZKqBt=H>&TBVHW=21b~%?Nn3Nic>&pO<6JE^ZO6GZAe*+ zE(6Mg$QG?kAzNg2g=|rK7qdm&Y+n9NVA|vkw$)ILwWnOp<8yB1j^bw@gSP2<795Q{VzY319?hahDxCL>c9{#D2Vnyp$>BFwtUh8Mej?T^g#aMmmB+l zk~!Mf`s1j&qe&9guO>+pU+}*q@_6*rgbja}#~&fXaIw=OYc{+n{Ine#9+cVR2nt(n zyNm>R89%qUf#UIIxA>qSZxcz4*{4ZrXiG?H)M_Zd$0Kg@gwzmn-BvY#X4;8ZP8 zV7?q9f*vY;7!Nz0zlOFQy~yI-id9eG-EchJ;$As z)n|aMtx9Y=<)Y?~JqP!z``$|63Zo2}=_i8Xz0VTgUE7U+?sZE(C z$Xh!9j}9mvmf>Xp3Z7MyB-;0tB+;Z{B#9KK|1XKe@p;BXp4`q@F7GkZp!Y(}Mw!&m(P~_TtUlru{DD~9@ zC8tITbUl90N+6#*)?ouL z{tJ8zsexDLl%7t*sooz!QX?q>QbWiceV`~9_+9gr-8Rns9*J}0F0)am$2=W-Pto+f%k3SzV#&5sGtaEP1!%OD*8sP* zrS1Z;qrL9|BiAH91ilp9XSa=$kNU9f)ItRT+fEH|KJpTH7~aXY8Y!8!9f0?pb)CS! zpO?FU3uba(fE9Dbe*;XC(|!OiFEL4!n)Pu2Cl$|Qz`%tvAdl;RTpko(6Lu?sf{i7P zs;O!m%Ly-IH1M|QqgJ&x$Uo|(q|e^3xSAZ1K><0U#)ssHw#yI3C1XCyDclHaIlsb+ zO!0_k_kXnjmfRj`0}TBsvbZpda5xuK5D za6{NS7sCy~>b!s(!q!*xgp;l;&4#a!UpM9I{$B5xC|k;bOBt3kh^sEtU3nhv?`tg>QpBc z1M%*A*e=R_`IGZNZ|T7wHtMSOb@pa zs2mpX09ZXevk9rAe zAJ%iM+3bf0c+z(3t68hE!6Rvw9p}a(n7O*poOq& zUJosVUGp($A?(dspoOq&R@t)&=y4$}RJ4S&P-qTmp@H?Jg_{177D}}uEi_I*T1YC5 zv{3m~(n6~~lNK@?LRzRjfV5EdHqt`V&XE>Ud(E^^x7WfEgZwk_sTmLv765V{Wu2N1 zic)I61cH3;-y?!R$v<nUZKWys@o!BO7+qao@s*KaSqE4HO(*b0-1B6Qq#@iVT{jr-1xMg`6~eHnuyH z#u&ATG{&z?(ims&Kx4pQ|ANNASClz423{tZ1C0TLom_tca}zJ?;2gOMU9;E*N<8q; zIbfatn+t%=7K=*YUCAo8fy#S%u?m>2&A9<+j~;s)s9n9Y7KkgjcNcJbWWY91-{gZI z0jHe`nt`zJ_fG*{ri}!6e0yyhkoNP^Yrxk`_8lNS$M*wJu`{(BSbgacyHS{b?B5H# zGF#jWA}j=YMQAW3$UmZ&nMhQmC7>WPa-0UI`#i~;P6dwqaTiQ5FAKy%w9VD6|p zY+s?bN@F^3H-CB{koX{b4#1ad3kH5TkQyopB{dYWkJQkRTTBgcpM3X>1a_Mwt^)jK z)kgtxNjlNM<%(Gwfv8Rq+gDf(c*FJ;Z~ZJ10MRBgLV>5r2x&>k2;I>byBq(u)e}Yt z8|IZTLfA0p!3g2|(EBh#*f7f$uziK00~w*F5Hdo$_L33uxk*Nd^PP;)MPo8TD+9?0 zneHGX)K*SLDEmDbp{f1J2&wv!5xTySjL@bcGD1$znGx#tO18C$+JmQZ?1HuHLC(L7 zOEI9R?3#=at>5TwLoz;v&YEiHmM8 zhYcHY*zkuFS4C|2$Ww*GY&ckT;!!rN@4ovuC}>#Lath>Z&*hbXBF~1Pk=+?dY=KaqTSfuN&CcBws)#|a{Dc?jjPd#&5Nts^e#NSuxDxZXS}!Q;L9E$ z^sey_U?6AtZ{V>t3tg;J=lzo2i;r|%x~KeJT&>*d1SR5^Ixcajjs|1+v{~9%Cuolo z>4BV_^RN4WqG@lab5S!`#lt~w_)zDvc|Ge~xX#DNu)R}vOOgd}Oy!<6FyGa{9_YJt zt`l%S=kQ1%ss5cCF#4ahC-BFL@|JS~%3GGDQQl%yMS08f&&XS_Pag7UD&RjAc?}LB}X>(QTo;rCxdW8Z1e0p}fUo3FR%{ zvng*WsiVAQ(O=4223wI4Y6&JIl#xnCXu?&>TVy{|-g0U1{!AP!DuD79%WagmyeXx; zCHEEOEi?6c*}g*4oAQ?1Ye@vf7Lo{ZX(kc$MQM6DmYf_35rlp6V#-?vWV5`5`=}No z2>aweB!avxNd$3%Nd#TkMIva0m_(4tClWy~2ayOmFoi@=z*Z7L+)@%j*V{=1ZPdNU z_7#p}Nd$e2A`x`-D2X7!VfVhCOHiWo8rH^+B^5dw;kbPYZ8LLL5VNL7`#TNdu7Aw2$hRUAL%i@%ce@i4t1XZ{U?|%*O`RAq0#Myt>G@7grb%~dnsyZxj|7&dM`yS zh)DlzZzTQmd?>RN-Cp~r?Yqu~C&#zmV#7*hLu%P@)tg0k*>JQ$@jXyzH}2a*kk`51 zwHXv2J|6oN6a+mXU7^QSXWOQFSJD*;OG#IF9E7eAa=+I@R|vT!|DY@IGX&Pq75MS? zd88|zr2VfeGOFS^S$Nn9UutDQiR@5q1yFctYM?U6i;Bxt1;v)nUoveT<@(*cKMGM7pRn8nM|gF8XF{_T-WvOEw#llW&uSFEPdX4tAe- z^w)}Qn^LTzTmWxDr8~e$qn_o$Rq9z*e5RhobjZW;__vn<=vnY{I@{2*K&zcY&w`)R zd5xY0Kc}N-KL^<8O+Aa_I_g=v3aMu~+Dtu*KskeLn+#m2XK7qaJRCqBQP1-0 z5A`gimejK>38tQ9SSs}_tzzm~_I~nD#!)5>-j@m}1W?Z+-by{o`cmpyY+g~%@=o`g z2ut$DQqMAbE%huqh19duKBk@}PH9>Rmbj0kp5?jpF%y$t61t;4xT0RGrgV6kGk#!wxzf=hRlyJ zigK0_M=59d)I>SUaYg-aSh8RQnV)_UWPToG{cnDfYpi4r;MpJZdyPEE|7%{U1WL{a zNvndwgbUn}m1)02leL>EY$z*<%W6AtfpCR+Jp^cfJNPC@VQ#Siy zwAg~p7OauCVY73OOt3$|u9~KFwyn6U(9E_KiOvJr<-%VS!nPGZGD-TJxkJ)N(U2s? z)rll2qBoPIuq`G@@m@lbB41Pd9dmO=k)+UF^}nR3%TJfi#zV$Gcq|8UJmdx{fudfA z5H83s4n3g`O2YSj(gKBpZaL|Jyq519`+(wf6Ot6;XOX0kO(IEAS@FN5Skw8`gbiB^ z7-GSOU;8bxX2ZFgPTR5J8K-+3L4k(EWhBVCtr6=6ine&(;)DE=E2(7poJS?giThNt zER=oWhx6#~Kqbq=5Gq+x_EO0*_9m4q|9YupDKn0rhn10mRI-@tppxZ9Ih8C~@2O-7 z=>KX3mZ_6Sq^zB417=%awOjvaIWO^gNbW zPoR>eBZf+ryi-)N%z8#8i?+Hv+d9>FP|30_GQAFOxgDaCrKgcfmLlnpCcHDuj!Kq+ z3#nvzyqly?>UEMnpP|1?hm!!|M@&8L7wU{2M z96XITPrAr~;?1pF6hVQr+AS53^T}OJ9TXi~K2;0k&lhFsf|9MT_(Z`-J1Iiyqe6iWifP{pDZ<;6A^HAkpo z+1f-Ei>sm@+dzGFri$g%BC1$Iv#4S*tf7jf>DT`iOR9MVCl^nJPmq)>$d^j-Rsbaz zFDEO5!j)b3RY9H^udgO3Zu1xDfP(BTNAy9?v@=w(sJ2nXg1>h#2oJnzG!s6>PHW_h z*=%>gcr!Lz_$bYi&CXMJY{O>zIvYBGqI=;BopagOM&@z0z3{rz&F&Y!e^SD7&TQit zytQ-=*`DFaWP6@pqJ(ANM@m@y2g3H?m1Td}9=x(Fq=Y591hxmSEWae%^Ipp(6mK2& zBHJ@(HQ65B0V~ovLS{N7Q0iFuynRk!cw3ncNt59JSbt&i=>35UPK8?LL((CeCdVtSn}PL z5|)w$l(0nXri5kibxK&C^iaZ*Fzh1Wl+N6eT@vJJyInXw9W@&0;%@f_j=GWh5iBG1(y@EDs9@Q(Q>E&#Dv80;nm0@40UU@b&c`@F(cN!U=N%3TS6wAo? zh~JR$S#JR2gIAWvQN8kRJ*!u^`Nv^=@XGQNGCn%o@jLNWohuoixTR!#+z*oR`BqQH z=kz}^KH=76e0cN7_%x@H@ky^DcPQVZ&{)gW}k5_SvvRHaxBUR1zCj)%}_R3T})UnGSL`M+x_UBB!G_ z_JjQHCejpziUGO!Y|I-$nxbz6X^MMU&=f*$QVlc(ob+#K3RpCA)GI>nxge@nmZng> zGW-hFE6=;AUfIW+QH_K7Po{cBDVFM$t7oWQiEg8M#a8G1eJpuDn(CFqYeu!;tvN@i zUeRr$dZk`Lw;k_{ccyy9V-eLWy_s*=?V|V&)hmmBQN1$Ayu26hY?(v#N=7oPS2V_7 z-YJ!jtMhHwJ=uJGf+`30Q3Qo+{DW0Mp5>MTHBkJz>+$bD&}3vy;^uQLEe8lx#* zxxJd=l`RDnuZ(=e;uY=}`6jl*IN>yay)v^fd;!~G49Gl&9R~N|?Jfr(<);JNJdHJr zVVkFav#Z!EGZ%JJvl4lUniZ3e)U3QTd@=$5mNf~@3cmjoqFKRixdhD$exUsYniYKi zsWmbPaPXpL<>M-9R*oK~W+nI`Ssw%W%%xb;;7HbIdl)qcN{uAEHSq&AD+-1rd#+3**|Tml$sU_xl06*~YF6?ze;>lY ztWnggXs=?;3b!_&WY4w-BzxTCW}m@3zd4ZXDGDXo6Sj|J&%j$Gdmeu$*^_2MvS-{Z zl0DK%Bzr0m;>haq-vwt^hPg%$~* z=+W*~J3#)f>lc$jiTAgEsi2T!GtKmC4CKs0!q3Eh5`a@xMDN#Z2CmDdD@#m&B~+;)T}6WP_rWL z6V`!$TklKFitUCkU3lx=32IjIpHj0jTlK~dyi>=WH7lug%cgJ&@WICA?w0`t?hVi6 zK~67cm@+6jZL?Ul0ADTP^Gh`FwuG0_s|^a9tKIZLUiw!`R(yxu8GwVzOs8a}GM*(X z=BvxHjj)Dq@%ANq_5PKCi6wAw+;SUWM)W!Ms*L)HU+f-n>xn1ZHf`Z1vTf4{H%e7L zFQruF_(4il7Tl#&r9bD*L@aq=jZ_6c%{dRL3ci}sk*eV5*sCa2Df>dH%JQK*=i{Bm z(|!tKc>(Q~Sf zL{I2n5xTvzf=5#-stwB~~14z0E7N7+|}*Cj2y z&6&MgMh_He7mVuz@@pSbsj^M}(Lj7s-JGaY`4+}X74xF~g(g@d2)q4}-6VKF?X7{w zrW@>lwAoi3fpI$(*p^B9k{{bLReZ=`Tc%ZpEna~6Bq~&1ZKgscrI;K$ds zMzAf@%~2>+@NMTR6e`#vAErX((*r70j?3*|hVg^KcKDpaaYQ=zg!LWPRG=CA!& z(&Q{otmB`dhVcK`0rP7EgLQyrqIBKBd7b_2SvsS8IM5zi?ZfsP?Gs}U@It` z(uWjS$6FF(*h-I6S37LnH)1kL@K;E8d>IR_LFP;>Id>JW< zD{n|qtTixZ=jduZZkaI~e!ITZj13<;{?n2T&w9eQWy4yyGC)C%Yn?O5*|t>M6%@H1 z4D>k0zRjqoLgmyyDpW$PsZbd>j|!EhG%8e5tEf;J_k{`-si8@%P${2Eh04k}DpbtQ zQ=!uSnhKR{{dbG-G)(iMLPc#o6)HE5QK7QAg$flXm2uHn((OWp%CRL>sLacuLZxp# z6)N}tQlXM$MTN>}0Tn90Q>jonca;j2WuK`~89v1CAP)9CfC`oU+o(|SKSza%(rYSI zuIimE!jfojDpc&&QK9m_kP4N<%~YraDXUz;5?vSOez^6EsZfc}rb5M|jtZ6Uf2dF? zwxmL3Q7{!MgHx$cX%WY=jYY;MQaz!Hq*Xyd!C@Z_kTdsTiZ&?HlYgiO z^6xtK>kCQ}!bnl@_miUdaf=j1$&dfzlnB#kQ#L$gw%CFVKS`3eVZ*yCC)l&$2_Mp( zK%tyr^GJ|)dE!8KP#m>6#1j-)6_clUDcQ3^$rN)2HV&&36ic^N|pg84OImIda-cy`1secf58C*p_ zic_v`q&Q`L5ydIC&nZrMr(XF0OY->?r_5PFaf(hJ#VK|7C{Br&>Gujt-0jKx^e!at zb9xVXpYR*xeFpWC_h~jF?~^{0ywCXU|9c;q@^tBwcp59;J(dF{tNRU90)-Y6LbxFB zb<7EMP<-&zCoNDg!j#kxzi*xso=XC^zfgnF_Q9{-q~==S<r%MTP}4B6oiawbe|2mnR0ae6aA{-txIDb~CuO<}1|n&OQ&X^Px+&=d$ik3mxi zxtcA|6bL_+eO3UmF4U!rTtZ#Smu%`%PS#PEvhXi;DFdviOL-JbT}nzSbt&FgsZ06y ziMo^vgD334IjsnwF2!UUbtx}PsY^NVin^2ly%Pmk!u6&u<=R^6QZ^P+m*Ut=UCKu# z>vWg0pMkI$OI^ylDC$!3j#8I0`!RJX+Df%Qv7~kc z>ryo0B4$bzsd46T4;++byj9b#P=qJr^lwuYP!wjdLJj2e=9X!KlE*23bU0_An;i^YfH~=?~NU_&s zgiYSKWVj;~Q#^oA&QzluUqm&^{4A`BZd*~7m>vggN7l07A6Bzq#}kn9|V?a^<%4#2wKPO*l!f7?(tndeuCgE*io$THzATQqG=`>L69!gfCcQ09q;+te8 z!oM>sVLr&jB@}BU&4HvP(sz)S7+*nJLZ&l-omypo(h_U@NK063A}#T{h_uAP=cFZO zX#9xBN)1oa61P^6me`Ur|nf`9>AWVxueU z8|8CtAMvpDaP^^mTxruM3G9Q5~chCl_;w^s6;XAD;bR??Y>l^I}To?kOpr zPOelI-a70?iYI6pDIVQiQatr{N%16b1nhe8up!0seI6;El5|o$i>gWS4E{=rr)4N9 zo{VXvcqYU%#S@v=%n2#M(=l7wI~?TbxFjzIMYW6XFDt>9m3elbm3Uj?Ru{a6z0Lho zu#VB#vU3C8FAfVP8^KE@8__H#8QC-wQGb$H!}=2q{%;fMGuX3m%_HTA zLFW}23sZipoWYmLkSn9qLCKS@L@iLbn|D_i51tUN>AEK7A(X&vtLqrGF7`f z0&l5~ru5{-YD!Nw6_DI1pQ+ks0^Avlf-plN3 z3h;Y2r6;93DLq+wiPDqdAIa%FGbE?8ZxT5je<3*?#S%CjnCBOio~+k8a~f~idcoYVK(yon>DyGBZ*bm)Pf|oyFHx&%(CdAirpj z$$n50dSm$kTGxB-AkpaeAx6cSO&|x+9zzZy=M*`J zY0t<(sHvB;Z=-K|kb~G9Ne;sK5IKnMMsg6xr2DWln{P)BqVGa-5chYJgV=eUf|JqT zC^-2u{PJt8JU4@alVyn%oETlC;N*D+1t_$>SHWosm)u3r;kuPIhrh+1Eg; zgDg=kMuF&FrA%C|sKyvBny6Wd|9kWrck6(ndZ{P+AV0yDdJ~TY)SG=# z$xKyuW~(&asWrL1j9Qb}Txv~R8mKk-!pWI~B`0mDH3^wdt;v84YE2$hQ)`m)m0A<; zVWT3kk~5uJlMC_Gnye_J*2MHJwI(kO4hga3z&L760-~uk;hvz@&fKU9H-XgT?@4)c`8*8F*n;4CI=2? zDVdzw95Oj^^<;9~|B=c0W<@6Fw17-bSQ?oeUKN>~=Fenu(uR=9@tykrZy%G1o5DGV zr@rFcei@Lz`t>t;P-3n>Oc@lu@?NY8@(!*m(E!C?Yj`D~;MDKXQjimBLFU15E}4g> z6fzIHt}yd}S2??{Ut_~kgYY*cFnn?Hlo~d?GIoDG8#X)ptPvEowGDj;^0RdoKL#aJ z$CNw)g{o1cAZ{EX1+l4#6oixFowxWjbUQ;q2)Tuepdf_Yd09{pLT;ZLC*=yn_B$5WkauNuS4~p1bTR4~mv{B`AUX;ktgiDtYc^XKZeY)5<1^&f_ak(agpZ#|)oC$;qH(N={lXQF4<0k&=_~15+pCoMioxoZxq# z2A0Bb}%@`5Z>g z$%*~coGiRe%}M{C)SNst{St?jDYL0L8M~93lYf=eoRoc_<|NW^%3drnnMBRWi_O%W zWEE3$67YhW6BVtJBUo~66g4L?tEf3~I84pS#|O9BUgC(HMi~(7NNUF*jMPrUK2kf| zZ&7pN`GcC1pC;$-VCC6a)SN6%qUL01B{e6loz$G{8KCn7OD0a7^$d{TOh)I*X)-$N zBxH1~HCMgIzjb(0bCS1`nv+@i)SPHPpys4T_TDcn+2+8EPPdm^=v?VCJdHhj56gjq zqMPp&K~C6rYc41nXtGuvUk{G&OWou=Vn&1Tg+H`tZ2OJfCDodMU$LOkjr3$bfBSqSeLWFa_-WFanG zAPcdggDiw;U$PJ{eaS)`*gzIy>PfN?+*Yy>*Ht%QtJLka(fz8_1w5qVas@e%*Olw1 z2#Sw3WT=1wfmDkc$mwIt(*i|}^Fwt({*H{32B2irHL?)DzLJF~9Yz*n$@Kp%#IOVv z6E^&;Y_d5U-urf+6&s$^=b0@VR`4C_2nxl~5hFm}`V+;jpxEZ=HxE$oj_c-yry}2t z!jsv{C_K^0rSPQoF2WNbH;$9dezAkQ4Z;)r-jjLs*HliYQ+N_yP2maeD}^V`Lp{TA zu(W9up7_R7cp_6q;Yr0C3QtxWh}L3>#W)I2UahC_ z(XXfQUavs>ikG0t8?ZmS)GW_6rKzj0;_`zI)K8H-P>Sw;GNHr)scVw zzty>{H&p5(p2oG_5wf7fYF)7cD12M^O&R1JYIaov#evFOH9>)v%WWNyQ?ppz02FP_ zCadFGM^@+SAF?{9ESc3w4Gmsq%w`Q!OU>A9llZ44n@#=1w`H?FgA*JsvM-H*I`$I% z#jV=xrI(eZf$XIhvsYwv+H_x!!CTp5$>>a5OGZbvkc`fa$1pm0M0<_I_K(Wf` zmHMFI+G;Wo8w1 z(??NxqPB|4lbiWeo@{m=+Yt;4^`w2t-nUMrajJc%X7QSue+b;-a= zCE^ZgRmQ|}9}WDwB;$RGHYl9X|Dhhplk@A>7ZhLGNLDARh^&s)bFw;b)a{M%xFUYE zDKK+|*aFbZleYox+?&8&eTkLH_>ZRZj!Wu&|1f42D6Yy}S-1fgW|pEAZnP{LmRgoJ z9SyB4MNQm*dv8U}Ld(+1Qq;tOqU~L_Y^yEXiuLLDocsIw{q^p^eV+pyh{xwT*TXUt zc1u7y`LPS6lQRV%o$T%hn4|L>V9o<$fH_${0CU{92^kEuDFb7Q35(Ph&)+SE0pZsCqI{>B*hTz z=_o}@mIHYBv>(93$w~kZ+iqguVZuorSLT5=-=w|t#F{yQ#_O10n1NGA(> zKstH&4WyH7{Ta1rFU*Y~oya7EbRuj4>EzHmkWNh01F^BwYd4Ti$|FHK@vaByL~$6T zlYV(oFDi+%1L~oC zzV~Qn`!|4eVw42Z$%~61ofN$V>BMWk{v;|PxPf%ieGHRMR9J$aGM(6`;IW+?Bq0Hh zrGPF`=tSpZYp9Yk$^fL&;D1JEDP-vC6m|@)%Y#t_W{WWZMepMm>otYN_=ybdRpcA3?7W;}YcLm+#-BHj@s!oA!;`0b} z6BUB9Cn_1VMY;*Pm@EWz6M7lwCf~0D$2t86ILvbVle-YdL2PUS z)))InLL3KOnz;@*PFMnP9LfdYIIms<$0?n6Djt<=Z~=~^a1=OB-$~#&u@8acSmS5q zqLR-xz;R9m1IO833LHn{DsY@z)4*}KroeF={V|S%#LcDv_bcc{UH9_jYLqznIiJ>| zfqYzA!GXr3n?4AVEWFh53 zE2@0p3#LgH4@?ucb}&tFAHg(fTQFx3m4thOX+k{?rpfEGV49S@0MlfnN;vi>Hqil0 z6LC10CdX^QG+BBVOp`CNx5iM3U&%rc_ zQnp%%4!oEFrpb61W}1Z6R?CsG-Ys`lnP9#1WOL22-s@J6EW5Cg(yoQpsA)yPE^NT` zTMVAbxnJO!1Q{=LL^qOrz%zNw0na424LlQ%apakxOEa|#u>n)ZD)3B>!~oGTYXqY6 z_8AbJN~NDWP|4<%;F&0g0nm}~0qDfv2B2d*W8+~|@@+8yorawNbav(g&{-e`pmXmh z0G$jY06MOl0qFdf3P9&_D*&B?V*qr>S^#ujdH~QVi3Xsv{tN&eg=YYCdKE99LdT0) z0YGQTK>#|RssQMmyahmKo6LOd?V`RIfX=NQ0CYHc0CXJs0O(Bp0HAZ;5P(kTCJg9! zb=QX-6rpp;8oqQ0CC*CWZ-fZ@FxWXoVaeIYjX}4MY^}H%hmx&!LoE>{>BfIsQc#Lg zEC4;M3;^^nnGNWnsTn!_0Sp9y;d(dWdiX^k5zd z=;56J&_mT@Ko36hUDyP@iXEVb!B9XC3FR1iAkwdcUGn`e*d?dU!7kagz2P>xPbVAf zlKUdCOESNLUE-$K`yAc(Z#~#0t%+clgk1!?gz^^bl2>YaUr>jP z#PSq~C1VdkEUCdeIinIZ59PIuJ`{B!F1bejdb>UsF`aiZ5|zAl2C<|f0z)~bn@&b1 zU{6r#A+Vf**>YS|!?ppIV-pN4=W7YDoQ5G_IXk9-DFIsQSwa^{x+%egrSEGJb8EQetNEN5y9u$<;}U^yY3z;g6H1Iu}`5LizBT8!m* zjn}XXde8@}_HKVMO5E0&ise1%10}HDP=&S`*LRoHpd3jqd{Kvzy<)(y9wq7b@6aU}0@%Yl<*-}m305+I zJ!}pK_Mlt?>_Kt|*h9SR%@I^$y9C(7w_U&QBT+-+IMGSX}Vey-;18+%b4v7aC>8&5|%;FKd7SHNeaq~)liCh zS4L`}42TH>pTxQve3DPM!6!L6BL^EDZL}Cvo@%K8e(5DR%9` z`OV;ygmS}v#bd8l0`<7v1sezCh$qJ zQ^6;3Zv~%3W(<51q2^|cQXTRDpTr~@e3Cb(!6zwy3O z6Kf`YjlP3Y^hmeoKFWZc_4vmqaUO|lo}%Q>xCj)Y{S8ov!)m(k(DN{L1q$)@D54PP z2i8s@3c)6BdW0who2X1+V=H|O*aC%Mhy0I1*p#iuiP2%dUM-eINox4>d=^U44l_e} zlmXh?LKRWs?qxQspya1_{ho`G;WDvY9p%VhQj!)r@TGMilpIU|p~U#YCNxB98hKrA zjP;hxdrif9*SS#4v0nM32Q9JQo|7$0P_m;RPA?Oqj~pKFXovRo(FUxNx?r$Mwv~ca zGXE-AB{!$RDoHg(RtcNP@P{8jnaTjGr1=V1B_UtHD$ysoVlysJyud2Sj|Zz{^?9&L z%c1EKLD%5clJySTdCTBRWcL=R!L$BSS5BtV3quk zf>qLJQlEz^cW(u&WMKwaB@a5mD#`i`Rta+v{sbz)d4W~Z#s;h8P%~I1)K_4Yyq>f7 zA}T3!0=Bd92(TT+I$%5E`(TwEpVf~IC6+A%tK`dGuu25QV3h<6f>oj^1*_x^HS`gx zz+&YuoIIu}0y(%HWdkdEP6uu7iCfmKq}1Xjt~mtd9P=TN55Ub-DIq(h53 z-0}}Qhl@{4&*)de@l5YI%<4zyuvU&kz_tZTZxGS8Xx`p=DkuZ47x~RaiA$02=cDAW zn0lv)lJT1YNavg%Af2EzKsve|uzc9#kFb1Lu1-2OmRPk0mJgGSgXP0I&cX6wM@CN2 z(S2sBAC{wd=Kw6nz5vj}Pcfi}vuMD8?%QJw=wXo$pofPXKo8k%fF9h(0X@iQ19}jy z`X72Y9J6i40D7n%7B|VDYvAKEH3S^bAiB<=Z zRf2w}vkHFqB|ZnhDp9Qh zt7PydW|f#G;O3pgo*~^5w36?Epp~4-1+8RPFK8t?KR_#aV9?fv_Lk`lT8Uc?HjGO8>;dS+?gyZ=v=V^M=NkZY zPW=O*6F>u?qY(%|=XMSN9c~W*9mnqgbf)$H2XroM?3R(B^SCd0wj4^F;U!NzO8&EV zc?u{Qh3dmfC`Z;XNpn!LXGiT(LrD_VpV2@miW~-@W2s=f2tBrOI{-R0`!JxBy`@5d zg7wb5vCb6hz4l*`1=gEv@q97XyF9>PsRaA~GaFVBJJ;QW&AjaW2CImX^a18P+5j*o zCkbGVM+?B58Sen*w9iK{2VH>a2CImfM#3s$Zv_BzDjoyO*(5)`6O}02VK4{%NM~l# z>_PNw7k2%UM~Qp*#a;;|KZ}&8iju)xH#iUF2rfZc10}odytg(=(xKO73sH)w^Iqwq z-Mx0k-~+Kd;s6=N#*-J(g$)x$^gNT z?@E-o!09SSlzdIocP=OyceVn6;AH@SaJqs31iHZHGXRK-ix7Z7hNc%NB}VZGKp;c2 z87U=fV$my5O4iOju?IEaok1z-J_1TfR2?WKiywee@?n-NHh!vI21?1+AW%x?m4H%m zeGrtA6e%brD@+7DRQY=gC?!qlpp*o4f>NUU8I+R83ul+1lH9eRl&oTdQX<<7N=e5{ zP)Z`^>^Y4}%$z_ed3P9;lFAdHlx)5aN{PxWaXTuJELG@25x*Cd5_&NxCEp~Vl$@Rd zrDP{HXItO zA@sIgJ^5AzZR5BU(|IWQ4t|H!(d}FDJm5L!+kxkVegvLlpo8%o)2E)djZtkt!Etr; zo*}L|=Z{?sGi#(4o1O1c8DE0pm;>k}v_qhijMacnQhgUVj^9jo7gRFW8aU3?-N12@ z3PC5a9{`=?=WoPu&>EU##BtEqh%a!QMLgg*kA%Q+vOfUFabEx&N5&I4jxZKD&f&Ab zaZFwS$9bcCCJODPf&m=II~+KUQVnpN{yV^N;$#)Dw~EaYjN?o=Wu}VEuA*n?);h}) zB@Q>XdI?H?t7hIZl#FnX$8?kJ)o1^`vy8mx<2bC+L_Bn&`JJF z0-fYi3#OB(9DJ8M;~F+Qul`sL%YAN(2-na{S`xXNh;n3I{b^;CZ294@bD&P%Ww)7i6M^H%Tm+8O_69i4AvOOj)IfCwj`R8`aGdf}z;QM{0*<3d=%_*^{kFhyj)wrp zSyl!d=gU>#ID$XGaRSXSj?;9ReQU#Y^n!*p&)mv4|ZBpO;_7H3vKWoMTbU^E6z#TrL2`RL7vKZqID%%IOqOlsXG~hVu zVc((xdEjmV^5BpT8qmnplFi33n{O^}dCJKFIZlG7DexPzDO45$WH?vWSv?wMDC|#~W?WF$D-nl46$IA)oCyuCTkuDotCc#Sk|I~|N4$=K zKY|y4KhpgO{1Fym3JoNPi|OExdXJR@JCX%fj_b`3;dDa zUEq&2O@KcVr297l?M!z)_#?v!;E&{90DolF8?{7q-%PcwDJVKz0OCX(1&CvI3LwtA zhX8S^@a@=@k(+G+;;4iG#2G9Fh!cMmAP)TxK%8%;0C7(H1H{>t2@q#N7eJi*UjX7{ zBCvpV=DH3b&cAqoIG4`@#0h&15Qj2v;Z;=f(itF5X#_x=4JQHOC_Ds+(>FWz2`Y)P z0f=KA3=rpY2|%2aLjZBMPXol!Fa?NnYb!t;ZU#UchbsVaq@MxeTv&ub92(*9MAdC{ z;(KbB*4|cPb0qg-PNHqTm0WKT(h;t9+l?>I1r)#rdY$c+GV=u|Pn@YGQUU=HSo zfH}Oc0p`HJ3(Uc1X7XoLqG}DyVQ4q_BMF7zkJt@>Kl1(e|9-#Z44FFP4tl7&eZ%BX zigb9F@F)Ww2>&Rc#ASVOR7S~nTfm)zl7aKQsfKc-HC9a%B|H2q2qe@OAdtLL9@Rq! zE@NN$k8?ekaKSnfSim}068wL z0CN5t1(0)D6F|;E4*)sjXaG4Qrvc=YJOz-mUeWw3I-dLr06Dz}0OZ6}0mxZ$>wiCM z{|T2Na~GY$I?KB=QL?w~(8Qx8spoB1KqIlO7<5;0=Hvbdvx<3|~YDf=$eQix32xxN1H^5a=p% zH-sS2Rp!S4K|~1thak)!f5F{DhkZv_Iuj+MiXJl?<;dpH9(j~(m2$iiO48u9HL55@ z@qhE@p$wp#KT$`C`@Rhj#OW;XNp^|ACt2_n^GU+)>(a?sZ{~Wo3D)bH*l&*Y{<}b2 zd=L9byaA^qOs&KQoeGL8I3+KSf>Tm@3Y?M+kH9HWAWSk*NuMn^C9xsklvtO6Q}X#L zI3*|lfK#&F44e{;ZQzvL$^@r`+XYUE;{-S*QqrG&XlEDJfm0Hi08WX)1#n89y#}YG zVBVHkRI^TnmA?yPDxELI3<3i;FQe03QozjX$_26 zC7FU#vfLk>lAjsil$^a{iOsm|{Q^$OBGQ#+wDrgfoRXY)a7x_I1J;@G8nBLVt`5RF z#KX?ul$b_Pk`z5_g*r-+)iQuNANK;xsVfGU<3EVO9O8Uw zIX0NML48d{k-7yI2Vd%Z^HmPR8|9a*n9`W z4iiqwvaVFDSz?uLhBdQy-JxO4wgnnXu;y>#ZOc%K8h)LiqYT(-{9!pt+yWm<2bBDK zoMX=D>14ElOX4yPF3Epd9@uldyb4^BgE8QekQ>1zdHD>vBy3`dQvEhmvVP^4Kos&} zppx|RK_!W~4Jyf!8TfH=tj3&-m3@XXh9iWmV=7CCL-v=tm&mW+YG#VZpK$Uwofl9J46;zUkmq8`T z9tD+zsd?ieDv|L3l|;z$!-f@yP6N*|c?vw|HPH;?IpxcN=Xf6go}*X=Jg5IA@SHeY z`Xnk@W(hoJBJh7aN07U1#zS-(1A9-%p(JVj7{{X&-7&Bvq6|p$KBkNk=bX|x2POZ{ zCE58X87=SKHBpZ2*8rYlvB84Z;DvlHL6Q8M=G+AcylvS@vr9!mD31hFAXQqBbh3QCdtn{}or17@fdTcF*w zyF6cv;_y-U?Gn>d7%edh(dm=a9L2Pb1NNS|xF*#a#r`0>#Na4iv}Z98etD5ui97Dmzlq z@s2nE#W6bs6z6RXP@KxUKyfzD>@Gqj%GMagnQ+>}IchhIUfD%$9V<}sACAp%M9Ik3 za(6*F!d#WbM9G$kxxWe}Nq9zkElSa$XMyWc2AC)bywUF7tN`jzeh{dGHy@)8G{xJZ z9awL_%TP zu*UTps3gxF6p~deP)KH;28E>a$y@Bt>IkCg3l!$dK_Pj!9~6?RN>E6AZh}Ihf=k0D zUI2Sk+z2>FnglrKLJN2# z``*c8?->L2bv`JbxfN|eQ5Xpx$(nlo9cXLzFnAk0CiY81k~Y+6sUv11gJy6R-g`=89*KGbOLqYeFo~_v8Wnhnt z?*)6LwixV@t%G2X%#(sWa-F*99;!^<0`|y?bg)N$cYr-|?i1J}K?{$+K_$9t!5(=W z2lhyA6WAjjFToy>o%8GmD(P?pd*sMrut&^JVD`v_)4O{=WuBooYo(mM97@LKr3rYH zBg%Uv3MkoAJs1HMs^RfNvVX+O|McBX+ovY-R>k$WF8eIzVH zhe5%5UDu?TV!i(zzh;5;UOqR+>KXPy7y*TZtXhT5%)fL1g{0&VC?xA^K_OAN2MS5= z%>S^9Bx9^WA+g>A3dyHJP)JS=fI_lu5)=}3O07StyyXiD35N#?i9v?wvZ72>QoRH~ zj^AzoIdcjDY5+Ozu>f*p z&H~60J_nFdOfSmGb06E@w0OTmiCfr3O{Z;^S;&uVZSylibXF?1h zr~Vg!oIqm?Ul^lT@PvF0};a@Hr5e!>L?g4%>TyIcWR<=5X5pm;=`vn1f>qFo)?& zz#J~T2j;L(1DJz>J1~c5QNSDu8-O{ic>>H~Hqit_R3dw@MBbVlTtwkjV-9Q{Mjs$UJL;&JQ$zu>l*2$kk;|QX>9f%`6p&*V# zmxDO6<;w8G08IQB17|ailpB#F3DT zAdcw21##qw+MIk;lJ5!(XZ0~)IC27DI9DD4!#PR_KY>at=)iDBLxAD%%YfndUIT`s z`WG0^kQp$X#BIQE?6QF2{OAIP(>MVPXSXgeoQ3Ow;XFtHhLd#x7!LCdFdUrPy+`PH zZ7#rY4jl!CLp=oy=k-HiIAwUNF;ueA78s6V2rwLRDKMPlSApRy`vVN;iz&u%ngsp} zaU+h-20m zb(nCf-ZL=cC3>uWMT)afj?9s4AfRMloh%`uBqdQslu?T8{fy?K4EV|0HyWV+ z_#hNMS^XUJk(tVR7^Lc42`DEb3{Z}FHK3gLw*lo;&4?dD zB|cVwa#VK#${EZDl#?I^ltcdoDCfHopqw+C0p;xC0Lsy61C;Y%3{Xy{7N8urRe*B- zMFYxdJp(8w{28Deiqac2hzNVNf;!_BI)h~g!{ku%H}EgvQKFT5{wTac6O;R7oRrZv zJ9aU54ocF}oj27`iazJ5X=3-|`+(#G`~;GtVFV=S_9j>gjGGEefjPFqQee}gIP4;q z3z}|LDCGA8b?B`C>JWVcsKb(fKpj3>0ClJf0P3(U2dKmR9-t04zX5gN=mT|NYy|2s zl?2qGxdo_0$h-eh2YvMnnb+u{K5@G{6JRxaiMDh4Fk~Ema+7}_9#di9VbE3hFcW;2T|(q%u!%Y!ke|UOKG4Q( zMNc(zwJSCjaf<~%19ukujMnGiXM`(n#}E*O0e;4-Fz_?Vs=?3La0mPhqU@CzR3f$l zKO=S*_!&zJz|Z(B#{7&Wr+zKSz#eFT@y_hGYHB#O{7M6U_@My?`lV{7=;P50FGFwiB(_+g; z;#5d8m}~+=hRT*}#c7aagV>5989ICRBocULa@eYj86IqcjSQJ2Ct$0IX85rcB4uc# zSrWD;LpFr1*eYW~nmx+a5y`UH%9An-5*{C~%b3YytJ=(9k_hB@L($Aq_T0!BUL<*D zJcS|Gz*cLW;X_ggiZ>I<3EAqCGXh9NPP`>!)(~6MMmC6~D2TTf%^GHFN6LnglqB)C zjM?LComSaMlJaQ$a?xxld(osUo1}tIaA4r&<8^Ija!9J=1Q!urD_%cxW;SUKGr^rf zpvD`v&MYR)4NCA75$N&8lQa3Gd7OlG40(@uijACrq$Wu47Rmd?n?%Yrk>*Phd>INM z@n)@Ztt9o)1b>kNE8b#KPDIkcCk8Tzym(8SSrU>aIdQj$SQ>8?IqMcliXQ;?0IN0Fvy1L|~WRZ$if>R`3MOTlR#AT>b6I@#H z8oK&HNf{zldV<>|o}_EQNy=f&@knsDA&_+q1xW>>IerPNA_+8IBS}&TV{S--XDh)* z*LXCkLNu3^uy&Hb&?V!Os~Pin3F~a+nYt8ma-C>iX~Kp`c`scmGr69j){x-cD(|Cf z5|rF1QWGX@o|F&JHRU8XGv*H^_}VB0>6!_WTSW7R6ShPugz1_~lG_;S;|cz)3X!@N zqsg5jb!o!(Nd>kp4WH7@(2!3Iv>|eIEy*cjk%m^{&PZam?qX)jAVZUyxVx2DtZNmN za$TfJPux36aOC>3f8QLL<`&$)Tb(f8% zJQHcN5)V!)iga!8sV^A|c!}XQN)laLa_U>rg3`ppkxI98>CDtIhE79bM61#xUAv&v zPa+**;;~7k5nX#u>Q~0Xp~NT~vu35LC zFsW#APJrHO4%gDbXeg<~W^Rz4r+{nSZ8V%z7CAReZ;ga&>tH;dRM9#&Qg7`j7fpak zld2}?vh}?1JO>A|d~&tTJdWNvGS8)(td(3FIWJppJ(K6|K%pkrwazQn+YrR_?55C@ zPfgC_>uu!l);Ul;lIv~M1bW^Ao_9CZFZpz&T9e);3D4KTBqX`9RjpNT^C-{1+k};T zZc&#X%XG#!^!QD>Jxg~Bxz9&7URjCt?H9{+eg!4yDg;2 zU6blK{Q!J=yaP=>rQ1dWuOCQGPwu8^rSwK>sOaxtrgI%EsVU-C4GsOBLFpOYmh_Z? zNez3!m!(xw=K^sl7{%%2fLHA<6l&g`NH2po2^b!ZFkd*7Knl}1-N7F01tyn2H zCp8)RLHLYnhb6p}+csKE{a|uNUH6jGl)I5yUiu--jCu#_hLroQT0Z)rK^cwR*20vB zlUf1#`#2fR4oinp9@}UK>F*b0v~({WPI(fk9j1RklF{a{Y&_*zt9GRR!O@J)?q$-H z7n9m-{V;rHw}Xv*>Pwpi9Q|-|rnuWiEA@5cf^7Xm%*;UtTWadt)&<4-hl4V&ciYla z-%l>!>mT7{-gcmSq>kC>2=pTanfJTteyJZKb(-{#N-`fi*oCBiYSn4gKQ@~AtlN&2 z`ejl_q#udTdg)-#OZ{rIP@*42&U)KzUz++oa^Wp~7Bg$iVR=L9&(?*H^rM5aK6Nh_ zrv9E>IHDiJ$@=QBVkmXWX3?mAtRU-W_ln`v>BvQK6Z*#`SyK)x$5a2dE}GPj8_oLL zy;7R`Z*mdNfQ`?Vab(DIWNb-zgLrbbTn|HwBO66hF-Tx$6C53=9Jw}g6m^f<9N z%2T=w0}eh%*U_2BQMJ`$8gR)uhCR-uoVihYUIsj74#m->fuq)@=VOo-lw;Q8BIKw~ z=>-_1b8;*lU57ZDw)#N^8G;<^9@k-xc9edYL8c_f*3oU8qtm7zX^=IVv%JSm%2_m} z&o;=$=Q=nt<+-}H1{{MNa;{4cQ;Vw~Wsq%<%gl9mbfujk^!+LT-UC+i+-i9cumth05px)8Df#=;u^)WmhRM6PtE#z&Uq6QeA;S@AG zZW`kG+L{CzHVO(_dNvL7wnUkP8J?9Cv^j1b=lQppL>itOE$HmoEah#VGGQAw;S0MR zedN;uZB03b&E!IHkB?T`&M4Dt!}H9-K}TO|+U_>fV#5nTh1Yw0>1lhXO!SAuXcK{E=aMP|>Fze_`6ODf1D-4o=Zm z$8AGtQMMLwqlTS=qMtq6hSQ>>EG7)ENQ$N$w~wdAwpmOXc8wPO?b$9(i<`2*8Hw=4 zGEM>V>G8HSyiqr~SgtofD?Kraref5?EG9SwQqz;$Xc|VnLB)!_f%Np$DH_SBk5jDb zw8JBvYimh15(|pedUyDxr$t%PjQS5C1$<5h3Q38ivx_VaY`(m z_6(($*jfb{T^E#C_wE@^FN?AYGrA!uv31%zo?g*r6=`&Hv}AejUTJ#Oloi|P7QWQM zDM&t}+I9)Y=r+03r8h_`qc&7!HKVR=NwLx0pisU>`)dz{jB zP9Yu{^|samqx*tV@7@rA5TF?z}= z3wJs&l+k9pY}DwPpe&;Iz;H%;)UpYq=aRB0r-S1eoo&k|jb4nF#r7VQW^_$0!x@j@ z%j2EGqttVOp8JQ8p^ZubAarr*LYfxXnhx_;pZuMsGMhb70DbWc-Fx zp5t`LBXiK!mTdf1P+rh`$S?D1lr7Epous_P>2OHq^)_1@yCCCFf{K>jqr;g`qU^$qKT9gwoQ{oWK5Mg!H2yML(b;=Un)zbNj%_@F zuk3b;l+Su;YtJ$MO0E?5MrvifjH2vbc&*8y=}8EHvS$|dA&D^p7nmpo^Skv zQ+eBo<&iaJyIf%WQ&4%om*toBA!>P(@h?f`W2fkltWRyrTaAB@RzB;EW@UYuS}ro4 z#8pm$CwOEN=niDETs>c{FTpQcf#pCW&l==wIwyu?D+(QK$g{`zI(><(Y-OnfgN!Fs z>pCa#vQ_DhOfrE|ZP=Gonmw20=tY)yuckOBH)N{`9ev0O!PRDc$--=Psbc_{$gQ?? zP8rJ9q&o$X73-_5`%;FpwOLMKWTnAsTj$jAY#pIfBw2Z^dU;=}G<%WMiA`1^)HpbE z?SijtR@9i9CO>#@9J5B*#qX(n?kztMTtkW93*#T|{IJLT#XPIxokP z?kXW`QfhbirI+Sdv0QJFwcKlioiiG8tc9+R$lAfR`};D4Im@K3Bjg3#+HmL0p&VPf z+bCJ5zBZySb2!J2R)b3Jn1>F#8T zVf~4MzFfcDRV;TJ#c1$EiE~~^uBXu5hGIN+qM|R4mAh8z&Y+M9b=A)Kyxetk4<>~| zsjKVDFU{S+^6;Wi-RtU|3mS60g&sZ>li<3>z5-$HW~oO2#gtpu>|8jM>q}o1L@}$c zYw0T-&fUUV6-F^1tZQ>F8qf6?u8O2sjMa7a6-jfqOINWeG{VVl=VJN1K>BJ9#gcMT z+*hoXx0AIxo3hyb1sY@3HRh}=TeWnV7jM(VqJgo zeqX6yUMS16iL!L?+4dK*F=WjS8(x%HMVCx`M&=o^Blp7r(C;*-Pq zWvmTh)HQ?kwl1f}^DBfKBB^V~>X(a8N%O0u8`xAYLW6^gK)#@wzL7&+M`>^o3$zMq zSsSyd>)jjNUFxX?b;6Cs)D6K6p5l6X!71rRK6N9vVVz5ZM?pQ^TR`=$Z}1j3_!XRH zc{fov4L10?oDL~y6neK(H;*;=i%+u(&PlyRR3F0WK$kPTf@bd&kaHh%c}TZ%Ta`CP9S8YL|<=!rOE|rb#fRu}*xkwD2y= z&&wply|Lbds6>cpyIUIcUy10#A^j^A^Z*qit_O^@AqiBroFEELyKYL#+^eg(n z@^3OZI(YW6OM6JsC!v3<$+5As&&2JlqAyZ^kx3-s+)I}ZUeQ%e%iPW#SC)2y-P<^2QF;ziRt*{0cq^A4^O`4U~bT^!RK%6XT5iB^ez z^sa2vT=(X^XpuPJW42by9K5N_2<3&hx|%R zqIWl$77m{Gb-fx=V%EO9)wF2rynp{yR*A**Zjos*;X{%fTr zR?&NInU=a=2zI^RP-5M_=aFey@P+;T*M%j^ruU4PmUAzJyWSWov9;SfYFbf$A)^1r zaEV>?-U-vn!3$BYH^)nsx9^=ats1)!+kaD9vT}Ma&WumE81H&ZzSO}k2ya$RxtQF4 zORLl=I!MK=#{DAK^)|KCr9DW)tTy;!M*nSksoQiA$?OF8Vvg$_k5YHLV6s_V{l$X* zJAS3BqJwE>CkHQz$Lw@)OJo0iVd>`SkN~qY+?Hn72ScU4cA-ILjrA=p z{SSsqw?v1AnVlVMX>)xzUh3Z-8fkWJtfjO6p|o`SbST@biEydg^^ts8pxr)>Su^F5 zxc`w>+0N*F*=Fb6FAcgrrk3q)-&bsQA^6hu{>SvPz0>>nW*51aZo3Y9lm*-E7nrrw zU%KBv>{k{Vy}!xq(%_}Xu1`YB_P6hEHM=}^=~@31R@uSn{UWng!sVB)PkCkGb_e1l zW^I(qZ~LE?mK}~haLY{SetFFGSwmSw`+-Mh?ZKBn^*Fi>U_`4tF*;1eyvMzj;P#SQp4=X$Vcr|usyOhHUYqQhzC{e!KVZm&biGup##%m>C=bp~Fu%Cn}!8Ril~o37g%UU`n) zA*T5trOj~QO=)>v^dT?vA@??l+uMfng7!l`=2wH;%m&^H%ZsKD1(;vswpqHp8!9ic zI~-(wy}r$Q;N5U}S@hvB^BaS0wr=mo%PZOsN1ERpYg<0>URqu?eVA>2iy(Ax8(;X4~+X&oQ{rYGJh~A^mY3XQqkBR(Q5v1Oz1!GfmLyCIznXrh|nJB_K{c7 zY{j%=1;lp;clOY zD%$Lhjha8JZ;u%GJY3NpeQd(~`CxmL+n4c*&h}%I<}b$DV+X!SE4rqS;Vec79r11x z@|E3ok$8)jl#b+q39ZWB=tvcdSMD8Lx3APnaeJhO#p~dXjDfH8%7N)flEoWtM~>S! zkIF&2D6+-d`i_EuZ+?|mqoZgR?*=TtuYA@X9cl4pth00Ax3u!bbTr#yf^enV zZBoAKrCkii;w$Bfcwkbi>UDHXw#7I1D}!!R)T+1bF~t_&gRfj4n4(v`pN`>M{NP@> z?I!i88ncTPSp2NNa(_VTSM?z}w#nkx;FZU2(;-!#+GATSeve&wHZaYq`Z66GC$g9% zbiH)@!>jsgcU)pIMd^Aw@Tauud-U;J7E<@FF}J@BRX^L0KeCt(?)o(FS6KCX`uK>& zA8yxIxBrH!rtIQIE&kSb{T%pjxN15&Zo=Zf!LBK{f8$ku+v6rJ{*8709r!1$`Zpbi zqv7O5GEAHTU&fw|r^!%7auS?2Up9uVLYv_sA~0o4_;MX=4Vr9-NKqnV$DjR&O`^@@ ziBy?0R`Ch;@no7@gGfy>V+&s)CZ0x{H6+qx%7*e4JK}9_{r6%?;`Clo0HyjsGO^Y4dnJ>zMMZ zswwu#0-9PwkGDjAOSMT%auaR-P>(NDA+*}8Be|8PKHlRmQHZX#_>(N6X~_2mGKp!` zmi8$UnkKb(w}e<$Z55Mpi>Bq#8_ZNZU2WZw@`$D#(z{=x*j~NtPs#{w0k1clsdTm4 z);@KVrqj?HAyIl#Z5NX|L0dS~8^u)qP`$h(b&|Gdyf;>&JYBu=Pb$umB;Oa$R8gpL zu;<_{b*X*H5*6(lrx=ckrJhG0m#Jz}f2i^ANQ<bUWW=>vE*tNECeeWN9Nm@RGS8?f>XH z?|7>J_l+Nvc|=4?<7nAsQ$|NB9O=_u8mFN|gLa`#TcnJZkdwVfONBaZju9;@b=pNk zLkso2Usr$p{`&p(d~n`3PRQ$==ee#sCCKSex#eh+JFm(I#HXweIaF&oMsVky$H1JF zjirYgEFJytd?+7SlCrt$kj&C4{LW{OL6s?6oesaXbk4rhQ9h_SB{<}8yQPct&M%L_ zZ7HFphksg*ZN1Z7KDaYwN7rGF6<@Vn&XcE>y3;92*~-j zfobaA(kMMEH~(_=3IoT~{asN!tMTFGy*-C`r5CuZ;vs2&xWvTAHTcDm|fRz0|)dvcl3a{Z7{jp7pZus%TFuuk;G1I3sJ{?5d*`RzB&K zA#oPg%cWIup4I{B)unL`)+<`85-Y5O)9-f0@vZ$-t518{M5Na_o%FC?X;Ph5VH2Nz zKjh>TYkxs?mZxn_`oq$bbF2gWtMe*sOVaDRP71A8g;y7O+Eu1EI>q~0ugSeaX|lExn~Qev9?m*6Okf`_A;IUGXC8K-HRSo(^gm&zus%tbO9AIWxR7r%(UK^UGuPF zj88^eNaBt{>rK*{Mo-6pj1Q%W7p*t9*0fYO24{TgN|ab{QN8=j(#Zht zUsX88XM7DgRcpOXaQB_3b52G_>8S?mVE?-xDx6C)esrCZS%-w*{p{&dneodh>8*8W z_T7#Om*$Mlkfe6&?b5ryJjb?WbeAUmwBFHrx4U9&XU5;IB#w&&GSN;3O)Ckt&3hSymNCRS$lb58NI ziOjCEl}>EV)DKMwv^gZL8!d2e%N$sivc=|bYn`*yy)$!gcZ$d+O7)(rz(Xy|z&SO{ zCfelQ1gVF9*3i(@D4Q6;Jx_tBY1Z(v)Dt$b{`b74o{m|D-Kk=mBjNX^3ItwR#?EP( zHb=AX&6Em!vP?tM3T=)_@68o>1!S3*rCqc+-g<9=)GIh^RCk)h=7j3~#ezu@S(eV} zS z@zy3W`~F7hX_}`oyoIJ4Sx_VnC6x3 z>6~R`o0k3HsC1f7wpVDDg>Aa@L7ZTEK(=>TmV<3Z>w`q;^x*9Oy0iGUnW_&@3uZ)Q zPj$}ru+1`gm?oVOpFKS^dx~wg;9-_vW={6Zvg|pwIsOmxq%%vhXLn}{ZF9pP771om zX3urb@w3g#et2Fwt2x^zG$+tDU;6NpV0K&fg0h?~wgs&Z%cQeAvln&eh-?d0A6*m7 zQOjBEoEv6aWb){ibdG+`($L%}+cSbk6@s~@Ilg7NCv4C9KdP3_bj~+_r`Q!wI<`vprkUnY@_ypvvF3Y=UTip7nMd}lrv$i`=VtY}w z{+VEYL{5-%e!1->lloWE`SCgHL-T8GFAM752^Qq!Y%I%fur2Yg{~%pZlC!xxUuIhx zUjJFJurg<>bHQ8Nvh4Z}>B8om;Lw70+bh!gUxGz#IiY0*KW(qJ)^|%6b>{5oF5uWn zR2$^Hglf4voeP!iu9-CStQ6|!?hY-~w7V{7Q1M!9n!C5GP|xm$e}j7EV#nP5-Gw~6 zo8b+;y_R_89&j!)vb&Ytpi{ZTCpRLr$inWnw4tBZ(tzB^vLXk&JFN`^E0+f69_}vU z+m)*}8h9;>$c=VB<6&1}(m1?wS$uA6=$R>YQbD7!mv2t)(Xumh>?-{m%`1INa*ubP z5!zLSH(Givugr~eKI>;!o!w|#xx6_yKJ;v$U5&JHwAYHZ+{Ci8TkP((Hab_X=*&&( zJ}a`TRc&(h@>9z@?R+lGuFj-sLZzR6UP|b>D7$-tCQq-Grg>>)=T6w&_iys9T9sm1?|j*X2D@he=GB#} zOY(}lFUag3hc^d#t*OkrgV4Gy{KvbT=4jSSCDD`t+I=H_AmS& zM^*+o=HKbQ$g_VL{y5reomYN^^CctuSJ{t`R<85OuME9pVgFkCIL>Q*Kz?=EB?tRA zt&bBc*9Yg{?Y_jff2-PZ+G|5Zex37W5Bql}Eoqe-;`8r^UY=s#Drm{_+L)97uj7rY29jk5nDcv9iD)wJMM zS?LM;ul`S}E4Mlpyy-4A72AIce^TeQ&8y&@b6KW+NA{D4mD_v@+Cs|;?Y~Q(G-`#;jB zzr1#|6?B(f{b~QV^=WtIj?RL=-B&pd95tETB#~O7oQp)+LC#dxvr432sIXn4=^*bV zQ<=2Ww6NzDiJpT(fK0t=r(>bg9|_N)M}(~Rq+MQxDlXTI9D3%+bgFjw6sm2%X5pY% zDeE_BcR-=~m1_`EK%Evq48*H)^r&u}mf11exLp=?9$=nYo((-;4VCnbC{>dg#b==#_vB90t(= zMV&55&q;VmpcaQC*`v@t^Z#B4XgBc36M8+M_@lS;b0R-u`|ejn*`k7qw?vViOyfO~ zSEo7a6H#)&HQ@_UIBwnZ4kD*B+2|KheD?kt_G0=~J^r;A472T&-$dgrzOITy-mzo* zRfv+wcdEHWq0&!nO(I8PG*6o--ZEFLOXORIzwSqry}N8SkUm^;+d3Y>X8>9Zw7vhe z7*vH$Fk!=c^CQgJu=CS83pV^+L(hf{=kpiZ6Gbb6QpXT^yyRAAqNL%Tg)31Qt$-E- zfel&=h;H8kavVCfsSYVXvqc z>7&KKVG>#lK8Ii-W_F(UI=XGilh};}eGRl2JQ#}>gM)!+F_>@~3o*OvzOYx+Uyw(O z!CGsy7#J=?i-GJ27GlQTntF^bnfm=WdqusfAzBQs&qj+ua2OV1+FUA3r`z7Y`_5ib zpVl8O1`EB=VxSv>g_w79HCO~=f6Gkvih6!8v>5zyL5o4rTGnFF?xuX>__EFP4}0xv z%L<}+>#$(~M84Hb|20He>)yg3qA2CU_YFke{5MWph>|{fyMu|sY7dkr@7s#=Bn3~@wh#arK`~;#{!D4?BkzXpT zPA1AWL~FDAXnuVzyN|EGB(X-HQ$tarJbMO}D2uq7d#OZOMEhVbULx##{il>Lx-UwU zzqq4BIcEz>l$U1pWvPJyO$(|C4^>g39O;M><%z3MqWmWTTQQ5POIzrYz(4ZO2u7n& zqWo+TN|aAVp+tGwHT{oto7$Jfp9wdHphP)jI!csn_n<`i!?|sg8lWY8uOxC?=uR%u zRq9C;_jUJHCh}`H$Ep!!VVRA+h$7d_ zYWonv(e9h{+WK}k?~!W8Y*=y8R5Lbw_3-ggY(2?u5Y zpveJoL?b@*QY${m|fq@14-&t6`?yx|!u86K=cO$H)v z{b)29MERr1z%w3OG3BcGE9j$K?%K!lhwII;71MMfnhahZ!dA?Lt4W*bwwdj(wh?+6 zpvmC&R5Tgv*mZFaz0=|BpZ$dP7vm!cS$)uCuy_KR4Ek?ElfnJ;1t;jXi24+Yr*p?E zqsgG#0Zj(ySE9*a%}H#<98oFHerBF_dK3^&n4!sF$^tYQs2svp%*mOI9kO z$zX^Bnhcu!Sd&4!n`LQ6_*VL6w7&lkNfaj!w23D2eI{=^LX>ImkQ^t9stOe2iM+kC zNvDVs=iafWiNf!$=ua+KhyLUhDZV-Msdx`^^4SM(=<%h94M*ELoM*#=<=Zc^Vfo`X zOW1J99o4Hu&W4{;t`o&(#>a0F`LE`-loMr%;V4p`RkHFfeN>J21@{Pd2B1h;_Z zyw>;MW=z9l`#9U^3zprkQXul;erPEXB~y*&suG2&bCcAGoEryTX%fZ3mreT+`8I7q zeTlO7`e;&4n}jCig`sFt*3Az`4&d&ST0=G*rqS1w4fFX6MzY~wfhm@3xajmd8=}bn z-Y5qmZ>Yj1N1~+Jy3~azJhDul)i!t^^_W2DdHawD;mY?1UW84C`v1`#A2o*>l||gQ zVc3i*zVw0R0Oqz@vmAhyKI=B%O1;piygLMq%8q%HR?<7awZyO-KwdBO8u*Syufd=| z^cvKQbGFiLhwDDG9Ds*BdJQ<%=ry>s^yWT#=ei@uN&6xk))M7s~${lka60QwLt-;n= zs5P+KZ~Bzp*;X9%oRIRi_!X6E%XHXUncN?_2BPTlNRMVBZ~dY}PlyuJC{!xHl=P=N$xZw$d`p-)B!h($drhZGWfAxG zZmLukad({C+Cgx5b@eBqT?duQSrb{Ma=Y7-%`x&J^lAHNHuNO&?>7umA<80De7Qu? z_%S&eL|(W5XDy=Se7wCbQMjfmv>%aU*nMLFea5Hes_Z;aZhFAX6iK3bbC|BOU3FXRT zGfoQVbNsBAc@v71sa#paT{)V{l||ek{#33k;x?W9#r|%@R*q%osYzXX7ZZAzqg=Uc z0m_v(9YVSC$g8K;&~0x%y$m8G@k}=o=1fJoa_?OzSFSkIy`A2<>xCOjC_45*&B534 zs5!{ph?;|C>GKcMZ3F9*V+fCwP;+p|0W}BiD^YX6If*5i7o~D3bV*RBJ3DI_o1y05 z`FzwI#7ClBdHNM0ODJ+brC%W28vNlBA#@6A4(xVfN#@6*t2gMK8P9v(AqcfmbD%dK zH3xMYP;(HTh9#Nf9xbb!1RhHIo19kPD@6qmRh3Cy~>qXs0q!T=ndZ8j-(O ztCt2*=Ik~@izxcO{-h3(SCA^xBT8009Kn8Oc#5bwXtYDkLG%h%bI|T4I5F0k4a-;T zHDkjizbZ$u;SDBQ)@<0!XO10FxI6#OBO=G?sd^Jp{7qv<3z474M>gL#2-*BW$s^c( ztiQL4{x)(CD^SU@i0ffPCCehNoG+Cui@2AL?q%(P>uy*6rb|qI^u~RBVK_%_JAK-O zIj4FMB{L4ZR3Zx1FPo?lIk#H_dlALk^^1EG`Sz2#`w(THLfrI-qRhMl`t;!zKe;=I z&`$#;%lF2zlI3={h`?kcHtcr#ttlJssv9|q4WE^zmypPH6M-1%PAv@GK4 zgrI4;I@e1`?%Q!fc|SBQ?-ZbE zc}y^xmcQhvr_pUWkEdr6mZ_ulV1Nr+4<4>T>p^4^c4khjJ#&#R`O87;L9r!T4+58< z^}sk5J2Ri%=*`YkC%?^R=c#GK(R!dZ3#|t?_nADRcZL)PHWF;#UU*FSsE5{rbWgM% zEZTcd$BX~^aU&SGc)ha z76XE|9;-cQcdPX1X~c&2Z1pl_!_L{!BiV3AQ@te{&Q}{`OO!2lTIN6$4PKq?NaQsn zw!07|Q8jk#XU6ky$OMAC1^N#z3(^ibUBV|8^Ck$UWXpoyg-*c17}3F?kSnck-9E<{mXdYmhLxbXU?;|b%G(Z1a2fcE8cE2({1#9ejrC;PK8 zywZi;$Cl1L>^ybM4DHMREkOIS(jl}jORgmP(ns0)>4hJ`l85%?)+uOTPTq<3WuG%$ z8|k(_FUGO+RFyW`m-miG`?AYMv@d^8^I_+yf=8zh5LPImeVONg_T@&u4YBmj=s2v< z6i7KI=`DrMiR?U8Vj9WLQyb=^ec3FsUk1JPs%%j<;nc^pJi@HO?+XbUQ_#L#E<*dV zsOSoN7(V)Wk1K@FTC53yo9%}7<)s_YzO0{y6`BtoF0G;4A{Ddi2ovnlgYahsdJrz0 zzzWT^71y88ZAQP9pA%#z=s}3{K@Y;TgJ@q?Ei+)psT&_wu;WzlAoL*E{D&Td4?EDl zoK`5pQDfn=+wwcl*Cz7L^jxJ&l=$17 z=|>a}U4b5i$H%cl^GNxgq4XKOe@SuF=xNg1gbiPrH`|;IZ;nW`V8f$IUs$u@x9^SZ ziJa2|1IG}>b0=SLCi1m*bn%HYX~8)5GqYP3=1y?xjWUF9t|&vuTSsLG^w>0omT8K( zgC4wLM~(U(RE8kp9=1gpg2!@{A;=xaGR;eOCbFNIbw3XV5KN6xhVWu8$`BI5u}pJD zN!k{=P5pfvYgD>D0A&c9f(ntQLk2=9?GWCbAD6^3GDghHd+A@n*vx z1y`o9;q>4hGuZH=T)}K2N3SJn9#LGYUcZ3IKj1QOF;V8a7G=!8ld@LOSAMqklRqIq z4rR>4tf-8ceyx^J8MBCcG*)>FVe-w%Z0ERQ$C2%H<<;RRW8N~$U@yI8u^(m3?~3yx z=&j_pUk?%H^+Or6wg6?!mD^CpyeEg7K({$Ro|Z)D;G&E<-x+1hE7qWld2mtydl=qu zx2S*+#X%XfrzOgm<(KSX$EnLP*s8hyhNgtxGW#}*9j9InLm6}8Oq4Ot+=nveUd3x` z>9#v>&fh2O&_gYPgC}YcK5s=WLRNN-jBZ=f+~*~sKNqzK_nlCSaBwwh5yqdwR?Y63 z_21}{3x6;DAgr-KErQ|Vf3*ltqYujMrf-TyPc~igYDHA0s!xpI#h1X}K^&)bD z_qO#WimfkL>Ja(wUvJhU%2NBH7GZ%0Y7um|q86bhD{46VZtrh;WW z;H!%LngboNU-MA__G=zazK~zW3FnV9J8E1Uxn~a{Xc6{nnnY=`pPA>9S?p&f z{&NC5YRnjd{hHkA*spnOH}-3Wo;!bvZnJyUd7AJ^2m3WMCbIpS?QVbKet7Jmf1qNi z^CY5pZRhU)h7Ajln2l4w-@?W}BTT zV*XInNsqqU-O`@B2D0IW+F|S2aNqGYo7nK(4Slw;;jpxMp+t`BBe94m{-yY4H<5qF z-h4k%=I@6_=Am(yBIzrCT=ADZ3_tRF0y|E5n^Ge){aVeZMrINB%0X&m7I8P1Ej&#a z^)WS-E_pi$jm)S2v&^Bl=8DkBtW{K2L~m6-Q#eo9t%XKrCpR=Qe_M}6=Dbw4Xp_7A z;gVZ~L5gT(uD3%YbJPknGJBi|zDKvoRa|>Wxb#bj9jDfrppn_s2aU`xBGAa3P|ABv zx6S;poE@js2cePq_GC0NZ{LANX8Xb&>`{2T?A9+rX77I$4vWW4oc`-iC=kW> zQl2Oh`4JC>sS;&wJp)wiV`UFiI2hQX!lB6*6%H}Suwv8e&V)sDWsjc` zO9-V#sBqXg7Zna8!?9xX_2tyHbX!te>pH^h0jO}$^hSk4MJQHm?#x#Z(QRX%dhRBC z)j)+q4j(Hvmjw-AKQjZ8v10S#y-ar0IHZ6Tn-guYV)O4Ztk}GG6e~6ZZ!58%8RH+` z?5Od~5GyuM&cTY!=?Ac4Q|x6FR zfveHY{3y}vE!}pwW?dV>{qMz(1dau|nJ)>^%^VaR!5)R1T(4t~!k>TXi{n)M(1r5* z>3+_bk=m0e;qGl!CJJwzx8M>v+h1?eAd2n!mT3|BpWGF6iL#6>p8bd-Vb)>xD7;_O z!@-0)Rdh3lJF;%(b~m?GnZ|6mGvT8d8$MTUW5I@3{n=*ChKG;3W=G^bS)??EC^;78 z?MxK@cP*Arbfb?d8G?T1t<%xZY`F*h%&q6XutJ@bR}Snr<)cge%pz_dckJ4% z-i&_cy_r)N)3{6H@nwYXD(Gh}7=wOhzX0?z^AfOYv$5*?2D&7s+ld{g1S5B|sFW=@K@?y6kP=ViZyVHliYT-C&mx&9YTL0XjmS$a zEX^cJ=0B6qB?@)4P|I9H8|>*`>{}0+F*cRSjCQvl4;GiQ;liGoSK07NyN}n|u)zwO z+ibY$__hinC$?NtMHGAeQmiHNdzg6NC(26a!D?)bfYlgT3ajz@J*-C3K(ZS2Yc-jy z2AQ`VWHm(Gih}LznVX%mo9*<~jOmSj=C7{kXUvb&C^Ss z8PEq;dvA#2)Xf2FjM#9f_c>EGY`gvUNH+X2pKry6GoJ3VC5jhmRyz>+dal||L|JXn z+_6N_f#jrd^x?+ce>H*dTLDGQXKhf_yvmn~nnm1U$7tOqouO}!XUD0dKf>8@YO)cE znw93FsQKyv6g6+ToU)ugie=lom4tWtC~8iggreqop(twB&X?azw^cpy*hbi+fud$- zK8l*Z2coDs|1{QZuDF*GPL~W;Kv8ppHHw;}m!YWH^C;GB%HLLur%Ot{Ph!WZ^@dot zX*LJC=w%pmQQ{@&qFJp>7jb*(W8LN*FRa@Xg<##LL*C62y6tm|>QzEkFRa^KGWIw- zYUl@I-R6BUEYZO_SfUB?Shv}21xs{cDb{VSIYO3*&o#V7mWUpjc912ar=Lf_5>1^A zOQaeGOLYArEK%?~SR(8Gute_#utaIW|18mh+kVD@~%PsMfR(k>@YBt}jtC#0r*Z>t4tVs|%1Bt*@EPXm?BL`}-yv_VM7C zv*A8l_f@jt>a407HoUh<>mE_)qBi#-k@LebsevdiSoNxz$oETx)!@~@YBc^~R)ZTe z3T4d#AxRApS0S3D25msOMp8q>-SCB^hKOrE6jI~WbV!X;d;Up{+2=8jiMμFKt7<-~5?sLN!qHx-f!7fCO+H_x6 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 literal 0 HcmV?d00001 diff --git a/tests/test_data/quote_tick_eurusd_2019_sim_rust.parquet b/tests/test_data/quote_tick_eurusd_2019_sim_rust.parquet new file mode 100644 index 0000000000000000000000000000000000000000..1b363fdc0ec03f0bed5db8ead5840986600f0d4b GIT binary patch literal 482530 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 literal 0 HcmV?d00001 diff --git a/tests/test_data/quote_tick_usdjpy_2019_sim_rust.parquet b/tests/test_data/quote_tick_usdjpy_2019_sim_rust.parquet new file mode 100644 index 0000000000000000000000000000000000000000..d41c5104059b1d13a7d39b26167dc2ec919fe695 GIT binary patch literal 482530 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 Xisk1i None: + def _load_quote_ticks_into_catalog_rust(self) -> list[QuoteTick]: parquet_data_path = os.path.join(TEST_DATA_DIR, "quote_tick_data.parquet") assert os.path.exists(parquet_data_path) + reader = ParquetReader( parquet_data_path, 1000, ParquetType.QuoteTick, ParquetReaderType.File, ) - # data = map(QuoteTick.list_from_capsule, reader) - # ticks = list(itertools.chain(*data)) - # print(ticks) - # Use rust writer - metadata = { - "instrument_id": "USD/JPY.SIM", - "price_precision": "5", - "size_precision": "0", - } - writer = ParquetWriter( - ParquetType.QuoteTick, - metadata, + mapped_chunk = map(QuoteTick.list_from_capsule, reader) + quotes = list(itertools.chain(*mapped_chunk)) + + min_timestamp = str(quotes[0].ts_init).rjust(19, "0") + max_timestamp = str(quotes[-1].ts_init).rjust(19, "0") + + # Write EUR/USD and USD/JPY rust quotes + for instrument_id in ("EUR/USD.SIM", "USD/JPY.SIM"): + + # Reset reader + reader = ParquetReader( + parquet_data_path, + 1000, + ParquetType.QuoteTick, + ParquetReaderType.File, + ) + + metadata = { + "instrument_id": instrument_id, + "price_precision": "5", + "size_precision": "0", + } + writer = ParquetWriter( + ParquetType.QuoteTick, + metadata, + ) + + file_path = os.path.join( + self.catalog.path, + "data", + "quote_tick.parquet", + f"instrument_id={instrument_id.replace('/', '-')}", # EUR-USD.SIM, USD-JPY.SIM + f"{min_timestamp}-{max_timestamp}-0.parquet", + ) + + os.makedirs(os.path.dirname(file_path), exist_ok=True) + with open(file_path, "wb") as f: + for chunk in reader: + writer.write(chunk) + data: bytes = writer.flush_bytes() + f.write(data) + + return quotes + + def _load_trade_ticks_into_catalog_rust(self) -> list[TradeTick]: + parquet_data_path = os.path.join(TEST_DATA_DIR, "trade_tick_data.parquet") + assert os.path.exists(parquet_data_path) + reader = ParquetReader( + parquet_data_path, + 100, + ParquetType.TradeTick, + ParquetReaderType.File, ) - file_path = os.path.join( - self.catalog.path, - "data", - "quote_tick.parquet", - "instrument_id=USD-JPY.SIM", - "0-0-0.parquet", - ) + mapped_chunk = map(TradeTick.list_from_capsule, reader) + trades = list(itertools.chain(*mapped_chunk)) - os.makedirs(os.path.dirname(file_path), exist_ok=True) - with open(file_path, "wb") as f: - for chunk in reader: - writer.write(chunk) - data: bytes = writer.flush_bytes() - f.write(data) + min_timestamp = str(trades[0].ts_init).rjust(19, "0") + max_timestamp = str(trades[-1].ts_init).rjust(19, "0") - def _load_trade_ticks_into_catalog_rust(self) -> None: - parquet_data_path = os.path.join(TEST_DATA_DIR, "trade_tick_data.parquet") - assert os.path.exists(parquet_data_path) + # Reset reader reader = ParquetReader( parquet_data_path, 100, ParquetType.TradeTick, ParquetReaderType.File, ) - # data = map(TradeTick.list_from_capsule, reader) - # ticks = list(itertools.chain(*data)) - # print(ticks) - # Use rust writer metadata = { "instrument_id": "EUR/USD.SIM", "price_precision": "5", @@ -139,7 +171,7 @@ def _load_trade_ticks_into_catalog_rust(self) -> None: "data", "trade_tick.parquet", "instrument_id=EUR-USD.SIM", - "0-0-0.parquet", + f"{min_timestamp}-{max_timestamp}-0.parquet", ) os.makedirs(os.path.dirname(file_path), exist_ok=True) @@ -149,38 +181,198 @@ def _load_trade_ticks_into_catalog_rust(self) -> None: data: bytes = writer.flush_bytes() f.write(data) + return trades + + def test_get_files_for_expected_instrument_id(self): + # Arrange + self._load_quote_ticks_into_catalog_rust() + + # Act + files1 = self.catalog._get_files(cls=QuoteTick, instrument_id="USD/JPY.SIM") + files2 = self.catalog._get_files(cls=QuoteTick, instrument_id="EUR/USD.SIM") + files3 = self.catalog._get_files(cls=QuoteTick, instrument_id="USD/CHF.SIM") + + # Assert + assert files1 == [ + f"{self.catalog.path}/data/quote_tick.parquet/instrument_id=USD-JPY.SIM/1577898000000000065-1577919652000000125-0.parquet", + ] + assert files2 == [ + f"{self.catalog.path}/data/quote_tick.parquet/instrument_id=EUR-USD.SIM/1577898000000000065-1577919652000000125-0.parquet", + ] + assert files3 == [] + + def test_get_files_for_no_instrument_id(self): + # Arrange + self._load_quote_ticks_into_catalog_rust() + + # Act + files = self.catalog._get_files(cls=QuoteTick) + + # Assert + assert files == [ + f"{self.catalog.path}/data/quote_tick.parquet/instrument_id=EUR-USD.SIM/1577898000000000065-1577919652000000125-0.parquet", + f"{self.catalog.path}/data/quote_tick.parquet/instrument_id=USD-JPY.SIM/1577898000000000065-1577919652000000125-0.parquet", + ] + + def test_get_files_for_timestamp_range(self): + # Arrange + self._load_quote_ticks_into_catalog_rust() + start = 1577898000000000065 + end = 1577919652000000125 + + # Act + files1 = self.catalog._get_files( + cls=QuoteTick, + instrument_id="EUR/USD.SIM", + start_nanos=start, + end_nanos=start, + ) + + files2 = self.catalog._get_files( + cls=QuoteTick, + instrument_id="EUR/USD.SIM", + start_nanos=0, + end_nanos=start - 1, + ) + + files3 = self.catalog._get_files( + cls=QuoteTick, + instrument_id="EUR/USD.SIM", + start_nanos=end + 1, + end_nanos=sys.maxsize, + ) + + # Assert + assert files1 == [ + f"{self.catalog.path}/data/quote_tick.parquet/instrument_id=EUR-USD.SIM/1577898000000000065-1577919652000000125-0.parquet", + ] + assert files2 == [] + assert files3 == [] + def test_data_catalog_quote_ticks_as_nautilus_use_rust(self): # Arrange self._load_quote_ticks_into_catalog_rust() # Act - quote_ticks = self.catalog.quote_ticks(as_nautilus=True, use_rust=True) + quote_ticks = self.catalog.quote_ticks( + as_nautilus=True, + use_rust=True, + instrument_ids=["EUR/USD.SIM"], + ) # Assert assert all(isinstance(tick, QuoteTick) for tick in quote_ticks) assert len(quote_ticks) == 9500 - def test_data_catalog_quote_ticks_use_rust(self): + def test_data_catalog_quote_ticks_as_nautilus_use_rust_with_date_range(self): + # Arrange + self._load_quote_ticks_into_catalog_rust() + + start_timestamp = 1577898181000000440 # index 44 + end_timestamp = 1577898572000000953 # index 99 + + # Act + quote_ticks = self.catalog.quote_ticks( + as_nautilus=True, + use_rust=True, + instrument_ids=["EUR/USD.SIM"], + start=start_timestamp, + end=end_timestamp, + ) + + # Assert + assert all(isinstance(tick, QuoteTick) for tick in quote_ticks) + assert len(quote_ticks) == 54 + assert quote_ticks[0].ts_init == start_timestamp + assert quote_ticks[-1].ts_init == end_timestamp + + def test_data_catalog_quote_ticks_as_nautilus_use_rust_with_date_range_with_multiple_instrument_ids( + self, + ): # Arrange self._load_quote_ticks_into_catalog_rust() + start_timestamp = 1577898181000000440 # EUR/USD.SIM index 44 + end_timestamp = 1577898572000000953 # EUR/USD.SIM index 99 + + # Act + quote_ticks = self.catalog.quote_ticks( + as_nautilus=True, + use_rust=True, + instrument_ids=["EUR/USD.SIM", "USD/JPY.SIM"], + start=start_timestamp, + end=end_timestamp, + ) + + # Assert + assert all(isinstance(tick, QuoteTick) for tick in quote_ticks) + + instrument1_quote_ticks = [t for t in quote_ticks if str(t.instrument_id) == "EUR/USD.SIM"] + assert len(instrument1_quote_ticks) == 54 + + instrument2_quote_ticks = [t for t in quote_ticks if str(t.instrument_id) == "USD/JPY.SIM"] + assert len(instrument2_quote_ticks) == 54 + + assert quote_ticks[0].ts_init == start_timestamp + assert quote_ticks[-1].ts_init == end_timestamp + + def test_data_catalog_use_rust_quote_ticks_round_trip(self): + # Arrange + instrument = TestInstrumentProvider.default_fx_ccy("EUR/USD") + + parquet_data_glob_path = TEST_DATA_DIR + "/quote_tick_data.parquet" + assert os.path.exists(parquet_data_glob_path) + + def block_parser(df): + df = df.set_index("ts_event") + df.index = df.ts_init.apply(unix_nanos_to_dt) + objs = QuoteTickDataWrangler(instrument=instrument).process(df) + yield from objs + + # Act + process_files( + glob_path=parquet_data_glob_path, + reader=ParquetByteReader(parser=block_parser), + use_rust=True, + catalog=self.catalog, + instrument=instrument, + ) + + quote_ticks = self.catalog.quote_ticks( + as_nautilus=True, + use_rust=True, + instrument_ids=["EUR/USD.SIM"], + ) + + assert all(isinstance(tick, QuoteTick) for tick in quote_ticks) + assert len(quote_ticks) == 9500 + + def test_data_catalog_quote_ticks_use_rust(self): + # Arrange + quotes = self._load_quote_ticks_into_catalog_rust() + # Act - qdf = self.catalog.quote_ticks(use_rust=True) + qdf = self.catalog.quote_ticks(use_rust=True, instrument_ids=["EUR/USD.SIM"]) # Assert assert isinstance(qdf, pd.DataFrame) assert len(qdf) == 9500 - # assert qdf.bid.equals(pd.Series([float(q.bid) for q in quotes])) - # assert qdf.ask.equals(pd.Series([float(q.ask) for q in quotes])) - # assert qdf.bid_size.equals(pd.Series([float(q.bid_size) for q in quotes])) - # assert qdf.ask_size.equals(pd.Series([float(q.ask_size) for q in quotes])) + assert qdf.bid.equals(pd.Series([float(q.bid) for q in quotes])) + assert qdf.ask.equals(pd.Series([float(q.ask) for q in quotes])) + assert qdf.bid_size.equals(pd.Series([float(q.bid_size) for q in quotes])) + assert qdf.ask_size.equals(pd.Series([float(q.ask_size) for q in quotes])) + assert (qdf.instrument_id == "EUR/USD.SIM").all def test_data_catalog_trade_ticks_as_nautilus_use_rust(self): # Arrange self._load_trade_ticks_into_catalog_rust() # Act - trade_ticks = self.catalog.trade_ticks(as_nautilus=True, use_rust=True) + trade_ticks = self.catalog.trade_ticks( + as_nautilus=True, + use_rust=True, + instrument_ids=["EUR/USD.SIM"], + ) # Assert assert all(isinstance(tick, TradeTick) for tick in trade_ticks) @@ -442,7 +634,6 @@ def test_catalog_bar_query_instrument_id(self): assert "instrument_id" in data.columns def test_catalog_projections(self): - projections = {"tid": ds.field("trade_id")} trades = self.catalog.trade_ticks(projections=projections) assert "tid" in trades.columns diff --git a/tests/unit_tests/persistence/test_catalog_rust.py b/tests/unit_tests/persistence/test_catalog_rust.py index fa207b710c0d..d43e558d1306 100644 --- a/tests/unit_tests/persistence/test_catalog_rust.py +++ b/tests/unit_tests/persistence/test_catalog_rust.py @@ -15,6 +15,7 @@ import itertools import os +import tempfile import pandas as pd @@ -22,7 +23,9 @@ from nautilus_trader.core.nautilus_pyo3.persistence import ParquetReader from nautilus_trader.core.nautilus_pyo3.persistence import ParquetReaderType from nautilus_trader.core.nautilus_pyo3.persistence import ParquetType +from nautilus_trader.core.nautilus_pyo3.persistence import ParquetWriter from nautilus_trader.model.data.tick import QuoteTick +from nautilus_trader.model.data.tick import TradeTick from tests import TEST_DATA_DIR @@ -35,8 +38,8 @@ def test_file_parquet_reader_quote_ticks(): ParquetReaderType.File, ) - data = map(QuoteTick.list_from_capsule, reader) - ticks = list(itertools.chain(*data)) + mapped_chunk = map(QuoteTick.list_from_capsule, reader) + ticks = list(itertools.chain(*mapped_chunk)) csv_data_path = os.path.join(TEST_DATA_DIR, "quote_tick_data.csv") df = pd.read_csv(csv_data_path, header=None, names="dates bid ask bid_size".split()) @@ -88,6 +91,100 @@ def test_buffer_parquet_reader_quote_ticks(): # ) +def test_file_parquet_writer_quote_ticks(): + parquet_data_path = os.path.join(PACKAGE_ROOT, "tests/test_data/quote_tick_data.parquet") + + # Write quotes + reader = ParquetReader( + parquet_data_path, + 1000, + ParquetType.QuoteTick, + ParquetReaderType.File, + ) + + metadata = { + "instrument_id": "EUR/USD.SIM", + "price_precision": "5", + "size_precision": "0", + } + writer = ParquetWriter( + ParquetType.QuoteTick, + metadata, + ) + + file_path = tempfile.mktemp() + + for chunk in reader: + writer.write(chunk) + + with open(file_path, "wb") as f: + data: bytes = writer.flush_bytes() + f.write(data) + + # Read quotes again + reader = ParquetReader( + file_path, + 1000, + ParquetType.QuoteTick, + ParquetReaderType.File, + ) + + # Cleanup + os.remove(file_path) + + mapped_chunk = map(QuoteTick.list_from_capsule, reader) + quotes = list(itertools.chain(*mapped_chunk)) + + assert len(quotes) == 9500 + + +def test_file_parquet_writer_trade_ticks(): + # Read quotes + parquet_data_path = os.path.join(TEST_DATA_DIR, "trade_tick_data.parquet") + assert os.path.exists(parquet_data_path) + + reader = ParquetReader( + parquet_data_path, + 100, + ParquetType.TradeTick, + ParquetReaderType.File, + ) + + # Write trades + metadata = { + "instrument_id": "EUR/USD.SIM", + "price_precision": "5", + "size_precision": "0", + } + writer = ParquetWriter( + ParquetType.QuoteTick, + metadata, + ) + + file_path = tempfile.mktemp() + with open(file_path, "wb") as f: + for chunk in reader: + writer.write(chunk) + data: bytes = writer.flush_bytes() + f.write(data) + + # Read quotes again + reader = ParquetReader( + parquet_data_path, + 100, + ParquetType.TradeTick, + ParquetReaderType.File, + ) + + # Cleanup + os.remove(file_path) + + mapped_chunk = map(TradeTick.list_from_capsule, reader) + trades = list(itertools.chain(*mapped_chunk)) + + assert len(trades) == 100 + + def get_peak_memory_usage_gb(): import platform From 2fa16ed679ac2a6cd9af88be4a91fac9b1c97965 Mon Sep 17 00:00:00 2001 From: Chris Sellers Date: Sat, 4 Feb 2023 11:13:28 +1100 Subject: [PATCH 10/81] Update dependencies --- nautilus_core/Cargo.lock | 109 ++++++++++--------- poetry.lock | 226 +++++++++++++++++++-------------------- pyproject.toml | 2 +- 3 files changed, 173 insertions(+), 164 deletions(-) diff --git a/nautilus_core/Cargo.lock b/nautilus_core/Cargo.lock index 9bcd37d218e4..7ab8d39896de 100644 --- a/nautilus_core/Cargo.lock +++ b/nautilus_core/Cargo.lock @@ -97,9 +97,9 @@ dependencies = [ [[package]] name = "async-trait" -version = "0.1.63" +version = "0.1.64" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eff18d764974428cf3a9328e23fc5c986f5fbed46e6cd4cdf42544df5d297ec1" +checksum = "1cd7fce9ba8c3c042128ce72d8b2ddbf3a05747efb67ea0313c635e10bda47a2" dependencies = [ "proc-macro2", "quote", @@ -423,9 +423,9 @@ dependencies = [ [[package]] name = "cxx" -version = "1.0.88" +version = "1.0.89" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "322296e2f2e5af4270b54df9e85a02ff037e271af20ba3e7fe1575515dc840b8" +checksum = "bc831ee6a32dd495436e317595e639a587aa9907bef96fe6e6abc290ab6204e9" dependencies = [ "cc", "cxxbridge-flags", @@ -435,9 +435,9 @@ dependencies = [ [[package]] name = "cxx-build" -version = "1.0.88" +version = "1.0.89" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "017a1385b05d631e7875b1f151c9f012d37b53491e2a87f65bff5c262b2111d8" +checksum = "94331d54f1b1a8895cd81049f7eaaaef9d05a7dcb4d1fd08bf3ff0806246789d" dependencies = [ "cc", "codespan-reporting", @@ -450,15 +450,15 @@ dependencies = [ [[package]] name = "cxxbridge-flags" -version = "1.0.88" +version = "1.0.89" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c26bbb078acf09bc1ecda02d4223f03bdd28bd4874edcb0379138efc499ce971" +checksum = "48dcd35ba14ca9b40d6e4b4b39961f23d835dbb8eed74565ded361d93e1feb8a" [[package]] name = "cxxbridge-macro" -version = "1.0.88" +version = "1.0.89" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "357f40d1f06a24b60ae1fe122542c1fb05d28d32acb2aed064e84bc2ad1e252e" +checksum = "81bbeb29798b407ccd82a3324ade1a7286e0d29851475990b612670f6f5124d2" dependencies = [ "proc-macro2", "quote", @@ -578,9 +578,9 @@ checksum = "ee1b05cbd864bcaecbd3455d6d967862d446e4ebfc3c2e5e5b9841e53cba6673" [[package]] name = "futures" -version = "0.3.25" +version = "0.3.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38390104763dc37a5145a53c29c63c1290b5d316d6086ec32c293f6736051bb0" +checksum = "13e2792b0ff0340399d58445b88fd9770e3489eff258a4cbc1523418f12abf84" dependencies = [ "futures-channel", "futures-core", @@ -593,9 +593,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.25" +version = "0.3.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52ba265a92256105f45b719605a571ffe2d1f0fea3807304b522c1d778f79eed" +checksum = "2e5317663a9089767a1ec00a487df42e0ca174b61b4483213ac24448e4664df5" dependencies = [ "futures-core", "futures-sink", @@ -603,15 +603,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.25" +version = "0.3.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04909a7a7e4633ae6c4a9ab280aeb86da1236243a77b694a49eacd659a4bd3ac" +checksum = "ec90ff4d0fe1f57d600049061dc6bb68ed03c7d2fbd697274c41805dcb3f8608" [[package]] name = "futures-executor" -version = "0.3.25" +version = "0.3.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7acc85df6714c176ab5edf386123fafe217be88c0840ec11f199441134a074e2" +checksum = "e8de0a35a6ab97ec8869e32a2473f4b1324459e14c29275d14b10cb1fd19b50e" dependencies = [ "futures-core", "futures-task", @@ -620,15 +620,15 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.25" +version = "0.3.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00f5fb52a06bdcadeb54e8d3671f8888a39697dcb0b81b23b55174030427f4eb" +checksum = "bfb8371b6fb2aeb2d280374607aeabfc99d95c72edfe51692e42d3d7f0d08531" [[package]] name = "futures-macro" -version = "0.3.25" +version = "0.3.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bdfb8ce053d86b91919aad980c220b1fb8401a9394410e1c289ed7e66b61835d" +checksum = "95a73af87da33b5acf53acfebdc339fe592ecf5357ac7c0a7734ab9d8c876a70" dependencies = [ "proc-macro2", "quote", @@ -637,15 +637,15 @@ dependencies = [ [[package]] name = "futures-sink" -version = "0.3.25" +version = "0.3.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39c15cf1a4aa79df40f1bb462fb39676d0ad9e366c2a33b590d7c66f4f81fcf9" +checksum = "f310820bb3e8cfd46c80db4d7fb8353e15dfff853a127158425f31e0be6c8364" [[package]] name = "futures-task" -version = "0.3.25" +version = "0.3.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ffb393ac5d9a6eaa9d3fdf37ae2776656b706e200c8e16b1bdb227f5198e6ea" +checksum = "dcf79a1bf610b10f42aea489289c5a2c478a786509693b80cd39c44ccd936366" [[package]] name = "futures-timer" @@ -655,9 +655,9 @@ checksum = "e64b03909df88034c26dc1547e8970b91f98bdb65165d6a4e9110d94263dbb2c" [[package]] name = "futures-util" -version = "0.3.25" +version = "0.3.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "197676987abd2f9cadff84926f410af1c183608d36641465df73ae8211dc65d6" +checksum = "9c1d6de3acfef38d2be4b1f543f553131788603495be83da675e180c8d6b7bd1" dependencies = [ "futures-channel", "futures-core", @@ -704,9 +704,9 @@ checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" [[package]] name = "heck" -version = "0.4.0" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2540771e65fc8cb83cd6e8a237f70c319bd5c29f78ed1084ba5d50eeac86f7f9" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" [[package]] name = "hermit-abi" @@ -810,9 +810,9 @@ checksum = "fad582f4b9e86b6caa621cabeb0963332d92eea04729ab12892c2533951e6440" [[package]] name = "js-sys" -version = "0.3.60" +version = "0.3.61" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49409df3e3bf0856b916e2ceaca09ee28e6871cf7d9ce97a692cacfdb2a25a47" +checksum = "445dde2150c55e483f3d8416706b97ec8e8237c307e5b7b4b8dd15e6af2a0730" dependencies = [ "wasm-bindgen", ] @@ -1067,9 +1067,9 @@ dependencies = [ [[package]] name = "parking_lot_core" -version = "0.9.6" +version = "0.9.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba1ef8814b5c993410bb3adfad7a5ed269563e4a2f90c41f5d85be7fb47133bf" +checksum = "9069cbb9f99e3a5083476ccb29ceb1de18b9118cafa53e90c9551235de2b9521" dependencies = [ "cfg-if", "libc", @@ -1656,9 +1656,9 @@ checksum = "e1766d682d402817b5ac4490b3c3002d91dfa0d22812f341609f97b08757359c" [[package]] name = "uuid" -version = "1.2.2" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "422ee0de9031b5b948b97a8fc04e3aa35230001a722ddd27943e0be31564ce4c" +checksum = "1674845326ee10d37ca60470760d4288a6f80f304007d92e5c53bab78c9cfd79" dependencies = [ "getrandom", ] @@ -1694,9 +1694,9 @@ checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" [[package]] name = "wasm-bindgen" -version = "0.2.83" +version = "0.2.84" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eaf9f5aceeec8be17c128b2e93e031fb8a4d469bb9c4ae2d7dc1888b26887268" +checksum = "31f8dcbc21f30d9b8f2ea926ecb58f6b91192c17e9d33594b3df58b2007ca53b" dependencies = [ "cfg-if", "wasm-bindgen-macro", @@ -1704,9 +1704,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-backend" -version = "0.2.83" +version = "0.2.84" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c8ffb332579b0557b52d268b91feab8df3615f265d5270fec2a8c95b17c1142" +checksum = "95ce90fd5bcc06af55a641a86428ee4229e44e07033963a2290a8e241607ccb9" dependencies = [ "bumpalo", "log", @@ -1719,9 +1719,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.83" +version = "0.2.84" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "052be0f94026e6cbc75cdefc9bae13fd6052cdcaf532fa6c45e7ae33a1e6c810" +checksum = "4c21f77c0bedc37fd5dc21f897894a5ca01e7bb159884559461862ae90c0b4c5" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -1729,9 +1729,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.83" +version = "0.2.84" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bc0c051dc5f23e307b13285f9d75df86bfdf816c5721e573dec1f9b8aa193c" +checksum = "2aff81306fcac3c7515ad4e177f521b5c9a15f2b08f4e32d823066102f35a5f6" dependencies = [ "proc-macro2", "quote", @@ -1742,15 +1742,15 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.83" +version = "0.2.84" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c38c045535d93ec4f0b4defec448e4291638ee608530863b1e2ba115d4fff7f" +checksum = "0046fef7e28c3804e5e38bfa31ea2a0f73905319b677e57ebe37e49358989b5d" [[package]] name = "web-sys" -version = "0.3.60" +version = "0.3.61" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bcda906d8be16e728fd5adc5b729afad4e444e106ab28cd1c7256e54fa61510f" +checksum = "e33b99f4b23ba3eec1a53ac264e35a755f00e966e0065077d6027c0f575b0b97" dependencies = [ "js-sys", "wasm-bindgen", @@ -1789,9 +1789,18 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windows-sys" -version = "0.42.0" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-targets" +version = "0.42.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a3e1820f08b8513f676f7ab6c1f99ff312fb97b553d30ff4dd86f9f15728aa7" +checksum = "8e2522491fbfcd58cc84d47aeb2958948c4b8982e9a2d8a2a35bbaed431390e7" dependencies = [ "windows_aarch64_gnullvm", "windows_aarch64_msvc", diff --git a/poetry.lock b/poetry.lock index f8220b891618..607c1257b317 100644 --- a/poetry.lock +++ b/poetry.lock @@ -199,14 +199,14 @@ pytz = ">=2015.7" [[package]] name = "beautifulsoup4" -version = "4.11.1" +version = "4.11.2" description = "Screen-scraping library" category = "dev" optional = false python-versions = ">=3.6.0" files = [ - {file = "beautifulsoup4-4.11.1-py3-none-any.whl", hash = "sha256:58d5c3d29f5a36ffeb94f02f0d786cd53014cf9b3b3951d42e0080d8a9498d30"}, - {file = "beautifulsoup4-4.11.1.tar.gz", hash = "sha256:ad9aa55b65ef2808eb405f46cf74df7fcb7044d5cbc26487f96eb2ef2e436693"}, + {file = "beautifulsoup4-4.11.2-py3-none-any.whl", hash = "sha256:0e79446b10b3ecb499c1556f7e228a53e64a2bfcebd455f370d8927cb5b59e39"}, + {file = "beautifulsoup4-4.11.2.tar.gz", hash = "sha256:bc4bdda6717de5a2987436fb8d72f45dc90dd856bdfd512a1314ce90349a0106"}, ] [package.dependencies] @@ -853,101 +853,101 @@ tqdm = ["tqdm"] [[package]] name = "hiredis" -version = "2.1.1" +version = "2.2.1" description = "Python wrapper for hiredis" category = "main" optional = true python-versions = ">=3.7" files = [ - {file = "hiredis-2.1.1-cp310-cp310-macosx_10_12_universal2.whl", hash = "sha256:f15e48545dadf3760220821d2f3c850e0c67bbc66aad2776c9d716e6216b5103"}, - {file = "hiredis-2.1.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:b3a437e3af246dd06d116f1615cdf4e620e639dfcc923fe3045e00f6a967fc27"}, - {file = "hiredis-2.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:b61732d75e2222a3b0060b97395df78693d5c3487fe4a5d0b75f6ac1affc68b9"}, - {file = "hiredis-2.1.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:170c2080966721b42c5a8726e91c5fc271300a4ac9ddf8a5b79856cfd47553e1"}, - {file = "hiredis-2.1.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d2d6e4caaffaf42faf14cfdf20b1d6fff6b557137b44e9569ea6f1877e6f375d"}, - {file = "hiredis-2.1.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d64b2d90302f0dd9e9ba43e89f8640f35b6d5968668da82ba2d2652b2cc3c3d2"}, - {file = "hiredis-2.1.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:61fd1c55efb48ba734628f096e7a50baf0df3f18e91183face5c07fba3b4beb7"}, - {file = "hiredis-2.1.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cfc5e923828714f314737e7f856b3dccf8805e5679fe23f07241b397cd785f6c"}, - {file = "hiredis-2.1.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:ef2aa0485735c8608a92964e52ab9025ceb6003776184a1eb5d1701742cc910b"}, - {file = "hiredis-2.1.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:2d39193900a03b900a25d474b9f787434f05a282b402f063d4ca02c62d61bdb9"}, - {file = "hiredis-2.1.1-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:4b51f5eb47e61c6b82cb044a1815903a77a4f840fa050fd2ff40d617c102d16c"}, - {file = "hiredis-2.1.1-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:d9145d011b74bef972b485a09f391babaa101626dbb54afc2313d5682a746593"}, - {file = "hiredis-2.1.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6f45509b43d720d64837c1211fcdea42acd48e71539b7152d74c16413ceea080"}, - {file = "hiredis-2.1.1-cp310-cp310-win32.whl", hash = "sha256:3a284bbf6503cd6ac1183b3542fe853a8be47fb52a631224f6dda46ba229d572"}, - {file = "hiredis-2.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:f60fad285db733b2badba43f7036a1241cb3e19c17260348f3ff702e6eaa4980"}, - {file = "hiredis-2.1.1-cp311-cp311-macosx_10_12_universal2.whl", hash = "sha256:69c20816ac2af11701caf10e5b027fd33c6e8dfe7806ab71bc5191aa2a6d50f9"}, - {file = "hiredis-2.1.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:cd43dbaa73322a0c125122114cbc2c37141353b971751d05798f3b9780091e90"}, - {file = "hiredis-2.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c9632cd480fbc09c14622038a9a5f2f21ef6ce35892e9fa4df8d3308d3f2cedf"}, - {file = "hiredis-2.1.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:252d4a254f1566012b94e35cba577a001d3a732fa91e824d2076233222232cf9"}, - {file = "hiredis-2.1.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b901e68f3a6da279388e5dbe8d3bc562dd6dd3ff8a4b90e4f62e94de36461777"}, - {file = "hiredis-2.1.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f45f296998043345ecfc4f69a51fa4f3e80ca3659864df80b459095580968a6"}, - {file = "hiredis-2.1.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:79f2acf237428dd61faa5b49247999ff68f45b3552c57303fcfabd2002eab249"}, - {file = "hiredis-2.1.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:82bc6f5b92c9fcd5b5d6506000dd433006b126b193932c52a9bcc10dcc10e4fc"}, - {file = "hiredis-2.1.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:19843e4505069085301c3126c91b4e48970070fb242d7c617fb6777e83b55541"}, - {file = "hiredis-2.1.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:c7336fddae533cbe786360d7a0316c71fe96313872c06cde20a969765202ab04"}, - {file = "hiredis-2.1.1-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:90b4355779970e121c219def3e35533ec2b24773a26fc4aa0f8271dd262fa2f2"}, - {file = "hiredis-2.1.1-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:4beaac5047317a73b27cf15b4f4e0d2abaafa8378e1a6ed4cf9ff420d8f88aba"}, - {file = "hiredis-2.1.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:7e25dc06e02689a45a49fa5e2f48bdfdbc11c5b52bef792a8cb37e0b82a7b0ae"}, - {file = "hiredis-2.1.1-cp311-cp311-win32.whl", hash = "sha256:f8b3233c1de155743ef34b0cae494e33befed5e0adba77762f5d8a8e417c5015"}, - {file = "hiredis-2.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:4ced076af04e28761d486501c58259247c1882fd19c7f94c18a257d143248eee"}, - {file = "hiredis-2.1.1-cp37-cp37m-macosx_10_12_x86_64.whl", hash = "sha256:f4300e063045e11ee79b79a7c9426813ab8d97e340b15843374093225dde407d"}, - {file = "hiredis-2.1.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b04b6c04fe13e1e30ba6f9340d3d0fb776a7e52611d11809fb59341871e050e5"}, - {file = "hiredis-2.1.1-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:436dcbbe3104737e8b4e2d63a019a764d107d72d6b6ee3cd107097c1c263fd1e"}, - {file = "hiredis-2.1.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:11801d9e96f39286ab558c6db940c39fc00150450ae1007d18b35437d2f79ad7"}, - {file = "hiredis-2.1.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c7d8d0ca7b4f6136f8a29845d31cfbc3f562cbe71f26da6fca55aa4977e45a18"}, - {file = "hiredis-2.1.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1c040af9eb9b12602b4b714b90a1c2ac1109e939498d47b0748ec33e7a948747"}, - {file = "hiredis-2.1.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:f448146b86a8693dda5f02bb4cb2ef65c894db2cf743e7bf351978354ce685e3"}, - {file = "hiredis-2.1.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:649c5a1f0952af50f008f0bbec5f0b1e519150220c0a71ef80541a0c128d0c13"}, - {file = "hiredis-2.1.1-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:b8e7415b0952b0dd6df3aa2d37b5191c85e54d6a0ac1449ddb1e9039bbb39fa5"}, - {file = "hiredis-2.1.1-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:38c1a56a30b953e3543662f950f498cfb17afed214b27f4fc497728fb623e0c9"}, - {file = "hiredis-2.1.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:6050b519fb3b62d68a28a1941ae9dc5122e8820fef2b8e20a65cb3c1577332a0"}, - {file = "hiredis-2.1.1-cp37-cp37m-win32.whl", hash = "sha256:96add2a205efffe5e19a256a50be0ed78fcb5e9503242c65f57928e95cf4c901"}, - {file = "hiredis-2.1.1-cp37-cp37m-win_amd64.whl", hash = "sha256:8ceb101095f8cce9ac672ed7244b002d83ea97af7f27bb73f2fbe7fe8e8f03c7"}, - {file = "hiredis-2.1.1-cp38-cp38-macosx_10_12_universal2.whl", hash = "sha256:9f068136e5119f2ba939ecd45c47b4e3cf6dd7ca9a65b6078c838029c5c1f564"}, - {file = "hiredis-2.1.1-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:8a42e246a03086ae1430f789e37d7192113db347417932745c4700d8999f853a"}, - {file = "hiredis-2.1.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:5359811bfdb10fca234cba4629e555a1cde6c8136025395421f486ce43129ae3"}, - {file = "hiredis-2.1.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d304746e2163d3d2cbc4c08925539e00d2bb3edc9e79fce531b5468d4e264d15"}, - {file = "hiredis-2.1.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4fe297a52a8fc1204eef646bebf616263509d089d472e25742913924b1449099"}, - {file = "hiredis-2.1.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:637e563d5cbf79d8b04224f99cfce8001146647e7ce198f0b032e32e62079e3c"}, - {file = "hiredis-2.1.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39b61340ff2dcd99d5ded0ca5fc33c878d89a1426e2f7b6dbc7c7381e330bc8a"}, - {file = "hiredis-2.1.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:66eaf6d5ea5207177ba8ffb9ee479eea743292267caf1d6b89b51cf9d5885d23"}, - {file = "hiredis-2.1.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:4d2d0e458c32cdafd9a0f0b0aaeb61b169583d074287721eee740b730b7654bd"}, - {file = "hiredis-2.1.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:8a92781e466f2f1f9d38720d8920cb094bc0d59f88219591bc12b1c12c9d471c"}, - {file = "hiredis-2.1.1-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:5560b09304ebaac5323a7402f5090f2a8559843200014f5adf1ff7517dd3805b"}, - {file = "hiredis-2.1.1-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:4732a0bf877bbd69d4d1b38a3db2160252acb31894a48f324fd54f742f6b2123"}, - {file = "hiredis-2.1.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:b5bd33ac8a572e2aa94b489dec35b0c00ca554b27e56ad19953e0bf2cbcf3ad8"}, - {file = "hiredis-2.1.1-cp38-cp38-win32.whl", hash = "sha256:07e86649773e486a21e170d1396217e15833776d9e8f4a7121c28a1d37e032c9"}, - {file = "hiredis-2.1.1-cp38-cp38-win_amd64.whl", hash = "sha256:b964d81db8f11a99552621acd24c97381a0fd401a57187ce9f8cb9a53f4b6f4e"}, - {file = "hiredis-2.1.1-cp39-cp39-macosx_10_12_universal2.whl", hash = "sha256:27e89e7befc785a273cccb105840db54b7f93005adf4e68c516d57b19ea2aac2"}, - {file = "hiredis-2.1.1-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:ea6f0f98e1721741b5bc3167a495a9f16459fe67648054be05365a67e67c29ba"}, - {file = "hiredis-2.1.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:40c34aeecccb9474999839299c9d2d5ff46a62ed47c58645b7965f48944abd74"}, - {file = "hiredis-2.1.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:65927e75da4265ec88d06cbdab20113a9e69bbac3aea1ec053d4d940f1c88fc8"}, - {file = "hiredis-2.1.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:72cab67bcceb2e998da2f28aad9ec7b1a5ece5888f7ac3d3723cccba62338703"}, - {file = "hiredis-2.1.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d67429ff99231137491d8c3daa097c767a9c273bb03ac412ed8f6acb89e2e52f"}, - {file = "hiredis-2.1.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10c596bce5e9dd379c68c17208716da2767bb6f6f2a71d748f9e4c247ced31e6"}, - {file = "hiredis-2.1.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7e0aab2d6e60aa9f9e14c83396b4a58fb4aded712806486c79189bcae4a175ac"}, - {file = "hiredis-2.1.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:17deb7d218a5ae9f05d2b19d51936231546973303747924fc17a2869aef0029a"}, - {file = "hiredis-2.1.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:d3d60e2af4ce93d6e45a50a9b5795156a8725495e411c7987a2f81ab14e99665"}, - {file = "hiredis-2.1.1-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:fbc960cd91e55e2281e1a330e7d1c4970b6a05567dd973c96e412b4d012e17c6"}, - {file = "hiredis-2.1.1-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:0ae718e9db4b622072ff73d38bc9cd7711edfedc8a1e08efe25a6c8170446da4"}, - {file = "hiredis-2.1.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:e51e3fa176fecd19660f898c4238232e8ca0f5709e6451a664c996f9aec1b8e1"}, - {file = "hiredis-2.1.1-cp39-cp39-win32.whl", hash = "sha256:0258bb84b4a1e015f14f891d91957042fa88f6f4e86cc0808d735ebbc1e3fc88"}, - {file = "hiredis-2.1.1-cp39-cp39-win_amd64.whl", hash = "sha256:c5a47c964c58c044a323336a798d8729722e09865d7e087eb3512df6146b39a8"}, - {file = "hiredis-2.1.1-pp37-pypy37_pp73-macosx_10_12_x86_64.whl", hash = "sha256:8de0334c212e069d49952e476e16c6b42ba9677cc1e2d2f4588bd9a39489a3ab"}, - {file = "hiredis-2.1.1-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:653e33f69202c00eca35416ee23091447ad1e9f9a556cc2b715b2befcfc31b3c"}, - {file = "hiredis-2.1.1-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f14cccf931c859ba3169d766e892a3673a79649ec2ceca7ba95ea376b23fd222"}, - {file = "hiredis-2.1.1-pp37-pypy37_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:86c56359fd7aca6a9ca41af91636aef15d5ad6d19e631ebd662f233c79f7e100"}, - {file = "hiredis-2.1.1-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:c2b197e3613c3aef3933b2c6eb095bd4be9c84022aea52057697b709b400c4bc"}, - {file = "hiredis-2.1.1-pp38-pypy38_pp73-macosx_10_12_x86_64.whl", hash = "sha256:ec060d6db9576f6723b5290448aea67160608556b5506eb947997d9d1ca6f7b7"}, - {file = "hiredis-2.1.1-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8781f5b91d75abef529a33cf3509ba5fe540d2814de0c4602f0f5ba6f1669739"}, - {file = "hiredis-2.1.1-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9bd6b934794bea92a15b10ac35889df63b28d2abf9d020a7c87c05dd9c6e1edd"}, - {file = "hiredis-2.1.1-pp38-pypy38_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cf6d85c1ffb4ec4a859b2f31cd8845e633f91ed971a3cce6f59a722dcc361b8c"}, - {file = "hiredis-2.1.1-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:bbf80c686e3f63d40b0ab42d3605d3b6d415c368a5d8a9764a314ebda6138650"}, - {file = "hiredis-2.1.1-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c1d85dfdf37a8df0e0174fc0c762b485b80a2fc7ce9592ae109aaf4a5d45ba9a"}, - {file = "hiredis-2.1.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:816b9ea96e7cc2496a1ac9c4a76db670827c1e31045cc377c66e64a20bb4b3ff"}, - {file = "hiredis-2.1.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db59afa0edf194bea782e4686bfc496fc1cea2e24f310d769641e343d14cc929"}, - {file = "hiredis-2.1.1-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c7a7e4ccec7164cdf2a9bbedc0e7430492eb56d9355a41377f40058c481bccc"}, - {file = "hiredis-2.1.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:646f150fa73f9cbc69419e34a1aae318c9f39bd9640760aa46624b2815da0c2d"}, - {file = "hiredis-2.1.1.tar.gz", hash = "sha256:21751e4b7737aaf7261a068758b22f7670155099592b28d8dde340bf6874313d"}, + {file = "hiredis-2.2.1-cp310-cp310-macosx_10_12_universal2.whl", hash = "sha256:998ab35070dc81806a23be5de837466a51b25e739fb1a0d5313474d5bb29c829"}, + {file = "hiredis-2.2.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:70db8f514ebcb6f884497c4eee21d0350bbc4102e63502411f8e100cf3b7921e"}, + {file = "hiredis-2.2.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a57a4a33a78e94618d026fc68e853d3f71fa4a1d4da7a6e828e927819b001f2d"}, + {file = "hiredis-2.2.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:209b94fa473b39e174b665186cad73206ca849cf6e822900b761e83080f67b06"}, + {file = "hiredis-2.2.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:58e51d83b42fdcc29780897641b1dcb30c0e4d3c4f6d9d71d79b2cfec99b8eb7"}, + {file = "hiredis-2.2.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:706995fb1173fab7f12110fbad00bb95dd0453336f7f0b341b4ca7b1b9ff0bc7"}, + {file = "hiredis-2.2.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:812e27a9b20db967f942306267bcd8b1369d7c171831b6f45d22d75576cd01cd"}, + {file = "hiredis-2.2.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:69c32d54ac1f6708145c77d79af12f7448ca1025a0bf912700ad1f0be511026a"}, + {file = "hiredis-2.2.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:96745c4cdca261a50bd70c01f14c6c352a48c4d6a78e2d422040fba7919eadef"}, + {file = "hiredis-2.2.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:943631a49d7746cd413acaf0b712d030a15f02671af94c54759ba3144351f97a"}, + {file = "hiredis-2.2.1-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:796b616478a5c1cac83e9e10fcd803e746e5a02461bfa7767aebae8b304e2124"}, + {file = "hiredis-2.2.1-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:341952a311654c39433c1e0d8d31c2a0c5864b2675ed159ed264ecaa5cfb225b"}, + {file = "hiredis-2.2.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6fbb1a56d455602bd6c276d5c316ae245111b2dc8158355112f2d905e7471c85"}, + {file = "hiredis-2.2.1-cp310-cp310-win32.whl", hash = "sha256:14f67987e1d55b197e46729d1497019228ad8c94427bb63500e6f217aa586ca5"}, + {file = "hiredis-2.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:ea011b3bfa37f2746737860c1e5ba198b63c9b4764e40b042aac7bd2c258938f"}, + {file = "hiredis-2.2.1-cp311-cp311-macosx_10_12_universal2.whl", hash = "sha256:103bde304d558061c4ba1d7ff94351e761da753c28883fd68964f25080152dac"}, + {file = "hiredis-2.2.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:6ba9f425739a55e1409fda5dafad7fdda91c6dcd2b111ba93bb7b53d90737506"}, + {file = "hiredis-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cb59a7535e0b8373f694ce87576c573f533438c5fbee450193333a22118f4a98"}, + {file = "hiredis-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6afbddc82bbb2c4c405d9a49a056ffe6541f8ad3160df49a80573b399f94ba3a"}, + {file = "hiredis-2.2.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a386f00800b1b043b091b93850e02814a8b398952438a9d4895bd70f5c80a821"}, + {file = "hiredis-2.2.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fec7465caac7b0a36551abb37066221cabf59f776d78fdd58ff17669942b4b41"}, + {file = "hiredis-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5cd590dd7858d0107c37b438aa27bbcaa0ba77c5b8eda6ebab7acff0aa89f7d7"}, + {file = "hiredis-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1523ec56d711bee863aaaf4325cef4430da3143ec388e60465f47e28818016cd"}, + {file = "hiredis-2.2.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:d4f6bbe599d255a504ef789c19e23118c654d256343c1ecdf7042fb4b4d0f7fa"}, + {file = "hiredis-2.2.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:d77dbc13d55c1d45d6a203da910002fffd13fa310af5e9c5994959587a192789"}, + {file = "hiredis-2.2.1-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:b2b847ea3f9af99e02c4c58b7cc6714e105c8d73705e5ff1132e9a249391f688"}, + {file = "hiredis-2.2.1-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:18135ecf28fc6577e71c0f8d8eb2f31e4783020a7d455571e7e5d2793374ce20"}, + {file = "hiredis-2.2.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:724aed63871bc386d6f28b5f4d15490d84934709f093e021c4abb785e72db5db"}, + {file = "hiredis-2.2.1-cp311-cp311-win32.whl", hash = "sha256:497a8837984ddfbf6f5a4c034c0107f2c5aaaebeebf34e2c6ab591acffce5f12"}, + {file = "hiredis-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:1776db8af168b22588ec10c3df674897b20cc6d25f093cd2724b8b26d7dac057"}, + {file = "hiredis-2.2.1-cp37-cp37m-macosx_10_12_x86_64.whl", hash = "sha256:49a518b456403602775218062a4dd06bed42b26854ff1ff6784cfee2ef6fa347"}, + {file = "hiredis-2.2.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:02118dc8545e2371448b9983a0041f12124eea907eb61858f2be8e7c1dfa1e43"}, + {file = "hiredis-2.2.1-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:78f2a53149b116e0088f6eda720574f723fbc75189195aab8a7a2a591ca89cab"}, + {file = "hiredis-2.2.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e3b8f0eba6d88c2aec63e6d1e38960f8a25c01f9796d32993ffa1cfcf48744c"}, + {file = "hiredis-2.2.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:38270042f40ed9e576966c603d06c984c80364b0d9ec86962a31551dae27b0cd"}, + {file = "hiredis-2.2.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a11250dd0521e9f729325b19ce9121df4cbb80ad3468cc21e56803e8380bc4b"}, + {file = "hiredis-2.2.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:595474e6c25f1c3c8ec67d587188e7dd47c492829b2c7c5ba1b17ee9e7e9a9ea"}, + {file = "hiredis-2.2.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:8ad00a7621de8ef9ae1616cf24a53d48ad1a699b96668637559a8982d109a800"}, + {file = "hiredis-2.2.1-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:a5e5e51faa7cd02444d4ee1eb59e316c08e974bcfa3a959cb790bc4e9bb616c5"}, + {file = "hiredis-2.2.1-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:0a9493bbc477436a3725e99cfcba768f416ab70ab92956e373d1a3b480b1e204"}, + {file = "hiredis-2.2.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:231e5836579fc75b25c6f9bb6213950ea3d39aadcfeb7f880211ca55df968342"}, + {file = "hiredis-2.2.1-cp37-cp37m-win32.whl", hash = "sha256:2ed6c948648798b440a9da74db65cdd2ad22f38cf4687f5212df369031394591"}, + {file = "hiredis-2.2.1-cp37-cp37m-win_amd64.whl", hash = "sha256:c65f38418e35970d44f9b5a59533f0f60f14b9f91b712dba51092d2c74d4dcd1"}, + {file = "hiredis-2.2.1-cp38-cp38-macosx_10_12_universal2.whl", hash = "sha256:2f6e80fb7cd4cc61af95ab2875801e4c36941a956c183297c3273cbfbbefa9d3"}, + {file = "hiredis-2.2.1-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:a54d2b3328a2305e0dfb257a4545053fdc64df0c64e0635982e191c846cc0456"}, + {file = "hiredis-2.2.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:33624903dfb629d6f7c17ed353b4b415211c29fd447f31e6bf03361865b97e68"}, + {file = "hiredis-2.2.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0f4b92df1e69dc48411045d2117d1d27ec6b5f0dd2b6501759cea2f6c68d5618"}, + {file = "hiredis-2.2.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:03c6a1f6bf2f64f40d076c997cdfcb8b3d1c9557dda6cb7bbad2c5c839921726"}, + {file = "hiredis-2.2.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3af3071d33432960cba88ce4e4932b508ab3e13ce41431c2a1b2dc9a988f7627"}, + {file = "hiredis-2.2.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cbb3f56d371b560bf39fe45d29c24e3d819ae2399733e2c86394a34e76adab38"}, + {file = "hiredis-2.2.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5da26970c41084a2ac337a4f075301a78cffb0e0f3df5e98c3049fc95e10725c"}, + {file = "hiredis-2.2.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:d87f90064106dfd7d2cc7baeb007a8ca289ee985f4bf64bb627c50cdc34208ed"}, + {file = "hiredis-2.2.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:c233199b9f4dd43e2297577e32ba5fcd0378871a47207bc424d5e5344d030a3e"}, + {file = "hiredis-2.2.1-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:99b5bcadd5e029234f89d244b86bc8d21093be7ac26111068bebd92a4a95dc73"}, + {file = "hiredis-2.2.1-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:ed79f65098c4643cb6ec4530b337535f00b58ea02e25180e3df15e9cc9da58dc"}, + {file = "hiredis-2.2.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:c7fd6394779c9a3b324b65394deadb949311662f3770bd34f904b8c04328082c"}, + {file = "hiredis-2.2.1-cp38-cp38-win32.whl", hash = "sha256:bde0178e7e6c49e408b8d3a8c0ec8e69a23e8dc2ae29f87af2d74b21025385dc"}, + {file = "hiredis-2.2.1-cp38-cp38-win_amd64.whl", hash = "sha256:6f5f469ba5ae613e4c652cdedfc723aa802329fcc2d65df1e9ab0ac0de34ad9e"}, + {file = "hiredis-2.2.1-cp39-cp39-macosx_10_12_universal2.whl", hash = "sha256:e5945ef29a76ab792973bef1ffa2970d81dd22edb94dfa5d6cba48beb9f51962"}, + {file = "hiredis-2.2.1-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:bad6e9a0e31678ee15ac3ef72e77c08177c86df05c37d2423ff3cded95131e51"}, + {file = "hiredis-2.2.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e57dfcd72f036cce9eab77bc533a932444459f7e54d96a555d25acf2501048be"}, + {file = "hiredis-2.2.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3afc76a012b907895e679d1e6bcc6394845d0cc91b75264711f8caf53d7b0f37"}, + {file = "hiredis-2.2.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a99c0d50d1a31be285c83301eff4b911dca16aac1c3fe1875c7d6f517a1e9fc4"}, + {file = "hiredis-2.2.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d8849bc74473778c10377f82cf9a534e240734e2f9a92c181ef6d51b4e3d3eb2"}, + {file = "hiredis-2.2.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e199868fe78c2d175bbb7b88f5daf2eae4a643a62f03f8d6736f9832f04f88b"}, + {file = "hiredis-2.2.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a0e98106a28fabb672bb014f6c4506cc67491e4cf9ac56d189cbb1e81a9a3e68"}, + {file = "hiredis-2.2.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0f2607e08dcb1c5d1e925c451facbfc357927acaa336a004552c32a6dd68e050"}, + {file = "hiredis-2.2.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:954abb363ed1d18dfb7510dbd89402cb7c21106307e04e2ee7bccf35a134f4dd"}, + {file = "hiredis-2.2.1-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:0474ab858f5dd15be6b467d89ec14b4c287f53b55ca5455369c3a1a787ef3a24"}, + {file = "hiredis-2.2.1-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:b90dd0adb1d659f8c94b32556198af1e61e38edd27fc7434d4b3b68ad4e51d37"}, + {file = "hiredis-2.2.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:7a5dac3ae05bc64b233f950edf37dce9c904aedbc7e18cfc2adfb98edb85da46"}, + {file = "hiredis-2.2.1-cp39-cp39-win32.whl", hash = "sha256:19666eb154b7155d043bf941e50d1640125f92d3294e2746df87639cc44a10e6"}, + {file = "hiredis-2.2.1-cp39-cp39-win_amd64.whl", hash = "sha256:c702dd28d52656bb86f7a2a76ea9341ac434810871b51fcd6cd28c6d7490fbdf"}, + {file = "hiredis-2.2.1-pp37-pypy37_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c604919bba041e4c4708ecb0fe6c7c8a92a7f1e886b0ae8d2c13c3e4abfc5eda"}, + {file = "hiredis-2.2.1-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:04c972593f26f4769e2be7058b7928179337593bcfc6a8b6bda87eea807b7cbf"}, + {file = "hiredis-2.2.1-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:42504e4058246536a9f477f450ab21275126fc5f094be5d5e5290c6de9d855f9"}, + {file = "hiredis-2.2.1-pp37-pypy37_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:220b6ac9d3fce60d14ccc34f9790e20a50dc56b6fb747fc357600963c0cf6aca"}, + {file = "hiredis-2.2.1-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:a16d81115128e6a9fc6904de051475be195f6c460c9515583dccfd407b16ff78"}, + {file = "hiredis-2.2.1-pp38-pypy38_pp73-macosx_10_12_x86_64.whl", hash = "sha256:df6325aade17b1f86c8b87f6a1d9549a4184fda00e27e2fca0e5d2a987130365"}, + {file = "hiredis-2.2.1-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dcad9c9239845b29f149a895e7e99b8307889cecbfc37b69924c2dad1f4ae4e8"}, + {file = "hiredis-2.2.1-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f0ccf6fc116795d76bca72aa301a33874c507f9e77402e857d298c73419b5ea3"}, + {file = "hiredis-2.2.1-pp38-pypy38_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:63f941e77c024be2a1451089e2fdbd5ff450ff0965f49948bbeb383aef1799ea"}, + {file = "hiredis-2.2.1-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:2bb682785a37145b209f44f5d5290b0f9f4b56205542fc592d0f1b3d5ffdfcf0"}, + {file = "hiredis-2.2.1-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:8fe289556264cb1a2efbcd3d6b3c55e059394ad01b6afa88151264137f85c352"}, + {file = "hiredis-2.2.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:96b079c53b6acd355edb6fe615270613f3f7ddc4159d69837ce15ec518925c40"}, + {file = "hiredis-2.2.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:82ad46d1140c5779cd9dfdafc35f47dd09dadff7654d8001c50bb283da82e7c9"}, + {file = "hiredis-2.2.1-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:17e9f363db56a8edb4eff936354cfa273197465bcd970922f3d292032eca87b0"}, + {file = "hiredis-2.2.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:ae6b356ed166a0ec663a46b547c988815d2b0e5f2d0af31ef34a16cf3ce705d0"}, + {file = "hiredis-2.2.1.tar.gz", hash = "sha256:d9fbef7f9070055a7cc012ac965e3dbabbf2400b395649ea8d6016dc82a7d13a"}, ] [[package]] @@ -968,14 +968,14 @@ nest-asyncio = "*" [[package]] name = "identify" -version = "2.5.16" +version = "2.5.17" description = "File identification library for Python" category = "dev" optional = false python-versions = ">=3.7" files = [ - {file = "identify-2.5.16-py2.py3-none-any.whl", hash = "sha256:832832a58ecc1b8f33d5e8cb4f7d3db2f5c7fbe922dfee5f958b48fed691501a"}, - {file = "identify-2.5.16.tar.gz", hash = "sha256:c47acedfe6495b1c603ed7e93469b26e839cab38db4155113f36f718f8b3dc47"}, + {file = "identify-2.5.17-py2.py3-none-any.whl", hash = "sha256:7d526dd1283555aafcc91539acc061d8f6f59adb0a7bba462735b0a318bff7ed"}, + {file = "identify-2.5.17.tar.gz", hash = "sha256:93cc61a861052de9d4c541a7acb7e3dcc9c11b398a2144f6e52ae5285f5f4f06"}, ] [package.extras] @@ -1720,14 +1720,14 @@ testing = ["pytest", "pytest-benchmark"] [[package]] name = "pre-commit" -version = "3.0.2" +version = "3.0.4" description = "A framework for managing and maintaining multi-language pre-commit hooks." category = "dev" optional = false python-versions = ">=3.8" files = [ - {file = "pre_commit-3.0.2-py2.py3-none-any.whl", hash = "sha256:f448d5224c70e196a6c6f87961d2333dfdc49988ebbf660477f9efe991c03597"}, - {file = "pre_commit-3.0.2.tar.gz", hash = "sha256:aa97fa71e7ab48225538e1e91a6b26e483029e6de64824f04760c32557bc91d7"}, + {file = "pre_commit-3.0.4-py2.py3-none-any.whl", hash = "sha256:9e3255edb0c9e7fe9b4f328cb3dc86069f8fdc38026f1bf521018a05eaf4d67b"}, + {file = "pre_commit-3.0.4.tar.gz", hash = "sha256:bc4687478d55578c4ac37272fe96df66f73d9b5cf81be6f28627d4e712e752d5"}, ] [package.dependencies] @@ -2235,14 +2235,14 @@ use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] [[package]] name = "setuptools" -version = "67.0.0" +version = "67.1.0" description = "Easily download, build, install, upgrade, and uninstall Python packages" category = "dev" optional = false python-versions = ">=3.7" files = [ - {file = "setuptools-67.0.0-py3-none-any.whl", hash = "sha256:9d790961ba6219e9ff7d9557622d2fe136816a264dd01d5997cfc057d804853d"}, - {file = "setuptools-67.0.0.tar.gz", hash = "sha256:883131c5b6efa70b9101c7ef30b2b7b780a4283d5fc1616383cdf22c83cbefe6"}, + {file = "setuptools-67.1.0-py3-none-any.whl", hash = "sha256:a7687c12b444eaac951ea87a9627c4f904ac757e7abdc5aac32833234af90378"}, + {file = "setuptools-67.1.0.tar.gz", hash = "sha256:e261cdf010c11a41cb5cb5f1bf3338a7433832029f559a6a7614bd42a967c300"}, ] [package.extras] @@ -2460,14 +2460,14 @@ test = ["pytest"] [[package]] name = "sphinxcontrib-htmlhelp" -version = "2.0.0" +version = "2.0.1" description = "sphinxcontrib-htmlhelp is a sphinx extension which renders HTML help files" category = "dev" optional = false -python-versions = ">=3.6" +python-versions = ">=3.8" files = [ - {file = "sphinxcontrib-htmlhelp-2.0.0.tar.gz", hash = "sha256:f5f8bb2d0d629f398bf47d0d69c07bc13b65f75a81ad9e2f71a63d4b7a2f6db2"}, - {file = "sphinxcontrib_htmlhelp-2.0.0-py2.py3-none-any.whl", hash = "sha256:d412243dfb797ae3ec2b59eca0e52dac12e75a241bf0e4eb861e450d06c6ed07"}, + {file = "sphinxcontrib-htmlhelp-2.0.1.tar.gz", hash = "sha256:0cbdd302815330058422b98a113195c9249825d681e18f11e8b1f78a2f11efff"}, + {file = "sphinxcontrib_htmlhelp-2.0.1-py3-none-any.whl", hash = "sha256:c38cb46dccf316c79de6e5515e1770414b797162b23cd3d06e67020e1d2a6903"}, ] [package.extras] @@ -2622,14 +2622,14 @@ files = [ [[package]] name = "types-redis" -version = "4.4.0.4" +version = "4.4.0.6" description = "Typing stubs for redis" category = "dev" optional = false python-versions = "*" files = [ - {file = "types-redis-4.4.0.4.tar.gz", hash = "sha256:b70829ca3401d3153d628e28d860070eff1b36b2fa3e5af3e583c1d167383cab"}, - {file = "types_redis-4.4.0.4-py3-none-any.whl", hash = "sha256:802e893ad3f88e03d3a2feb0d23a715d60b0bb330bc598a52f1de237fc2547a5"}, + {file = "types-redis-4.4.0.6.tar.gz", hash = "sha256:57f8b3706afe47ef36496d70a97a3783560e6cb19e157be12985dbb31de1d853"}, + {file = "types_redis-4.4.0.6-py3-none-any.whl", hash = "sha256:8b40d6bf3a54352d4cb2aa7d01294c572a39d40a9d289b96bdf490b51d3a42d2"}, ] [package.dependencies] @@ -2653,14 +2653,14 @@ types-urllib3 = "<1.27" [[package]] name = "types-toml" -version = "0.10.8.1" +version = "0.10.8.2" description = "Typing stubs for toml" category = "dev" optional = false python-versions = "*" files = [ - {file = "types-toml-0.10.8.1.tar.gz", hash = "sha256:171bdb3163d79a520560f24ba916a9fc9bff81659c5448a9fea89240923722be"}, - {file = "types_toml-0.10.8.1-py3-none-any.whl", hash = "sha256:b7b5c4977f96ab7b5ac06d8a6590d17c0bf252a96efc03b109c2711fb3e0eafd"}, + {file = "types-toml-0.10.8.2.tar.gz", hash = "sha256:51d428666b30e9cc047791f440d0f11a82205e789c40debbb86f3add7472cf3e"}, + {file = "types_toml-0.10.8.2-py3-none-any.whl", hash = "sha256:3cf6a09449527b087b6c800a9d6d2dd22faf15fd47006542da7c9c3d067a6ced"}, ] [[package]] @@ -2942,4 +2942,4 @@ redis = ["hiredis", "redis"] [metadata] lock-version = "2.0" python-versions = ">=3.9,<3.12" -content-hash = "c1f3e24ee56f7f86b4b3375bf61152800f3def784300e96a185f24237829014f" +content-hash = "20bf3ac19091ddba37cb5e770729f5273198ae948c31ef4476ebe1c4d7b501e0" diff --git a/pyproject.toml b/pyproject.toml index ebe1526f4441..47c1b1e88ba2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -63,7 +63,7 @@ tabulate = "^0.9.0" toml = "^0.10.2" tqdm = "^4.64.1" uvloop = {version = "^0.17.0", markers = "sys_platform != 'win32'"} -hiredis = {version = "^2.1.1", optional = true} +hiredis = {version = "^2.2.1", optional = true} ib_insync = {version = "^0.9.81", optional = true} redis = {version = "^4.4.2", optional = true} docker = {version = "^6.0.1", optional = true} From 1e10feeba908a709fa2b5272e0d013365ea6511d Mon Sep 17 00:00:00 2001 From: Chris Sellers Date: Sat, 4 Feb 2023 11:54:18 +1100 Subject: [PATCH 11/81] Cleanup pass --- .../adapters/binance/common/data.py | 9 +-- .../adapters/binance/common/enums.py | 12 +-- .../adapters/binance/common/execution.py | 10 +-- .../adapters/binance/common/schemas/market.py | 27 ++++--- .../adapters/binance/common/schemas/symbol.py | 6 +- nautilus_trader/adapters/binance/config.py | 4 +- nautilus_trader/adapters/binance/factories.py | 10 +-- .../adapters/binance/futures/__init__.py | 14 ++++ .../adapters/binance/futures/execution.py | 4 +- .../adapters/binance/futures/http/__init__.py | 14 ++++ .../adapters/binance/futures/http/account.py | 43 ++++------ .../adapters/binance/futures/http/market.py | 6 +- .../adapters/binance/futures/http/user.py | 2 +- .../adapters/binance/futures/http/wallet.py | 14 ++-- .../binance/futures/schemas/__init__.py | 14 ++++ .../binance/futures/schemas/account.py | 2 +- .../binance/futures/schemas/wallet.py | 2 +- .../adapters/binance/http/market.py | 55 ++++++------- nautilus_trader/adapters/binance/http/user.py | 10 +-- .../adapters/binance/spot/execution.py | 4 +- .../adapters/binance/spot/http/account.py | 79 ++++++++----------- .../adapters/binance/spot/http/market.py | 29 +++---- .../adapters/binance/spot/http/user.py | 2 +- .../adapters/binance/spot/http/wallet.py | 8 +- 24 files changed, 189 insertions(+), 191 deletions(-) diff --git a/nautilus_trader/adapters/binance/common/data.py b/nautilus_trader/adapters/binance/common/data.py index 0e6a376f45aa..81dcda29bace 100644 --- a/nautilus_trader/adapters/binance/common/data.py +++ b/nautilus_trader/adapters/binance/common/data.py @@ -361,11 +361,6 @@ async def _subscribe_quote_ticks(self, instrument_id: InstrumentId) -> None: self._ws_client.subscribe_book_ticker(instrument_id.symbol.value) async def _subscribe_trade_ticks(self, instrument_id: InstrumentId) -> None: - if self._binance_account_type.is_futures: - self._log.warning( - "Trade ticks have been requested from a `Binance Futures` exchange. " - "This functionality is not officially documented or supported.", - ) self._ws_client.subscribe_trades(instrument_id.symbol.value) async def _subscribe_bars(self, bar_type: BarType) -> None: @@ -378,9 +373,9 @@ async def _subscribe_bars(self, bar_type: BarType) -> None: return resolution = self._enum_parser.parse_internal_bar_agg(bar_type.spec.aggregation) - if not self._binance_account_type.is_spot_or_margin and resolution == "s": + if self._binance_account_type.is_futures and resolution == "s": self._log.error( - f"Cannot request {bar_type}.", + f"Cannot subscribe to {bar_type}. ", "Second interval bars are not aggregated by Binance Futures.", ) try: diff --git a/nautilus_trader/adapters/binance/common/enums.py b/nautilus_trader/adapters/binance/common/enums.py index efc22caacdf2..5aa338bff8b2 100644 --- a/nautilus_trader/adapters/binance/common/enums.py +++ b/nautilus_trader/adapters/binance/common/enums.py @@ -292,7 +292,7 @@ def parse_binance_order_side(self, order_side: BinanceOrderSide) -> OrderSide: return self.ext_to_int_order_side[order_side] except KeyError: raise RuntimeError( # pragma: no cover (design-time error) - f"unrecognized binance order side, was {order_side}", # pragma: no cover + f"unrecognized Binance order side, was {order_side}", # pragma: no cover ) def parse_internal_order_side(self, order_side: OrderSide) -> BinanceOrderSide: @@ -300,7 +300,7 @@ def parse_internal_order_side(self, order_side: OrderSide) -> BinanceOrderSide: return self.int_to_ext_order_side[order_side] except KeyError: raise RuntimeError( # pragma: no cover (design-time error) - f"unrecognized internal order side, was {order_side}", # pragma: no cover + f"unrecognized Nautilus order side, was {order_side}", # pragma: no cover ) def parse_binance_time_in_force(self, time_in_force: BinanceTimeInForce) -> TimeInForce: @@ -308,7 +308,7 @@ def parse_binance_time_in_force(self, time_in_force: BinanceTimeInForce) -> Time return self.ext_to_int_time_in_force[time_in_force] except KeyError: raise RuntimeError( # pragma: no cover (design-time error) - f"unrecognized binance time in force, was {time_in_force}", # pragma: no cover + f"unrecognized Binance time in force, was {time_in_force}", # pragma: no cover ) def parse_internal_time_in_force(self, time_in_force: TimeInForce) -> BinanceTimeInForce: @@ -316,7 +316,7 @@ def parse_internal_time_in_force(self, time_in_force: TimeInForce) -> BinanceTim return self.int_to_ext_time_in_force[time_in_force] except KeyError: raise RuntimeError( # pragma: no cover (design-time error) - f"unrecognized internal time in force, was {time_in_force}", # pragma: no cover + f"unrecognized Nautilus time in force, was {time_in_force}", # pragma: no cover ) def parse_binance_order_status(self, order_status: BinanceOrderStatus) -> OrderStatus: @@ -340,7 +340,7 @@ def parse_binance_bar_agg(self, bar_agg: str) -> BarAggregation: return self.ext_to_int_bar_agg[bar_agg] except KeyError: raise RuntimeError( # pragma: no cover (design-time error) - f"unrecognized binance kline resolution, was {bar_agg}", + f"unrecognized Binance kline resolution, was {bar_agg}", ) def parse_internal_bar_agg(self, bar_agg: BarAggregation) -> str: @@ -348,7 +348,7 @@ def parse_internal_bar_agg(self, bar_agg: BarAggregation) -> str: return self.int_to_ext_bar_agg[bar_agg] except KeyError: raise RuntimeError( # pragma: no cover (design-time error) - "unrecognized or non-supported BarAggregation,", + "unrecognized or non-supported Nautilus BarAggregation,", f"was {bar_aggregation_to_str(bar_agg)}", # pragma: no cover ) diff --git a/nautilus_trader/adapters/binance/common/execution.py b/nautilus_trader/adapters/binance/common/execution.py index 72755495d4e9..4c70302b4990 100644 --- a/nautilus_trader/adapters/binance/common/execution.py +++ b/nautilus_trader/adapters/binance/common/execution.py @@ -107,7 +107,7 @@ class BinanceCommonExecutionClient(LiveExecutionClient): The account type for the client. base_url_ws : str, optional The base URL for the WebSocket client. - clock_sync_interval_secs : int, default 900 + clock_sync_interval_secs : int, default 0 The interval (seconds) between syncing the Nautilus clock with the Binance server(s) clock. If zero, then will *not* perform syncing. warn_gtd_to_gtc : bool, default True @@ -133,7 +133,7 @@ def __init__( instrument_provider: InstrumentProvider, account_type: BinanceAccountType = BinanceAccountType.FUTURES_USDT, base_url_ws: Optional[str] = None, - clock_sync_interval_secs: int = 900, + clock_sync_interval_secs: int = 0, warn_gtd_to_gtc: bool = True, ): super().__init__( @@ -389,7 +389,7 @@ async def generate_order_status_reports( ) binance_orders.extend(response) except BinanceError as e: - self._log.exception(f"Cannot generate order status report: {e.message}", e) + self._log.exception(f"Cannot generate OrderStatusReport: {e.message}", e) return [] reports: list[OrderStatusReport] = [] @@ -441,7 +441,7 @@ async def generate_trade_reports( ) binance_trades.extend(response) except BinanceError as e: - self._log.exception(f"Cannot generate trade report: {e.message}", e) + self._log.exception(f"Cannot generate TradeReport: {e.message}", e) return [] # Parse all Binance trades @@ -484,7 +484,7 @@ async def generate_position_status_reports( symbol = instrument_id.symbol.value if instrument_id is not None else None reports = await self._get_binance_position_status_reports(symbol) except BinanceError as e: - self._log.exception(f"Cannot generate position status report: {e.message}", e) + self._log.exception(f"Cannot generate PositionStatusReport: {e.message}", e) return [] len_reports = len(reports) diff --git a/nautilus_trader/adapters/binance/common/schemas/market.py b/nautilus_trader/adapters/binance/common/schemas/market.py index 0f695f0b03d7..775ba62f0401 100644 --- a/nautilus_trader/adapters/binance/common/schemas/market.py +++ b/nautilus_trader/adapters/binance/common/schemas/market.py @@ -61,7 +61,7 @@ class BinanceTime(msgspec.Struct, frozen=True): class BinanceExchangeFilter(msgspec.Struct): """ - Schema of an exchange filter, within response of GET `exchangeInfo` + Schema of an exchange filter, within response of GET `exchangeInfo.` """ filterType: BinanceExchangeFilterType @@ -71,7 +71,7 @@ class BinanceExchangeFilter(msgspec.Struct): class BinanceRateLimit(msgspec.Struct): """ - Schema of rate limit info, within response of GET `exchangeInfo` + Schema of rate limit info, within response of GET `exchangeInfo.` """ rateLimitType: BinanceRateLimitType @@ -83,7 +83,7 @@ class BinanceRateLimit(msgspec.Struct): class BinanceSymbolFilter(msgspec.Struct): """ - Schema of a symbol filter, within response of GET `exchangeInfo` + Schema of a symbol filter, within response of GET `exchangeInfo.` """ filterType: BinanceSymbolFilterType @@ -116,13 +116,14 @@ class BinanceSymbolFilter(msgspec.Struct): minTrailingAboveDelta: Optional[int] = None # SPOT/MARGIN only maxTrailingAboveDelta: Optional[int] = None # SPOT/MARGIN only minTrailingBelowDelta: Optional[int] = None # SPOT/MARGIN only - maxTrailingBelowDetla: Optional[int] = None # SPOT/MARGIN only + maxTrailingBelowDelta: Optional[int] = None # SPOT/MARGIN only class BinanceDepth(msgspec.Struct, frozen=True): """ Schema of a binance orderbook depth. - GET response of `depth` + + GET response of `depth`. """ lastUpdateId: int @@ -167,7 +168,7 @@ def parse_to_trade_tick( instrument_id: InstrumentId, ts_init: int, ) -> TradeTick: - """Parse Binance trade to internal TradeTick""" + """Parse Binance trade to internal TradeTick.""" return TradeTick( instrument_id=instrument_id, price=Price.from_str(self.price), @@ -180,7 +181,7 @@ def parse_to_trade_tick( class BinanceAggTrade(msgspec.Struct, frozen=True): - """Schema of a single compressed aggregate trade""" + """Schema of a single compressed aggregate trade.""" a: int # Aggregate tradeId p: str # Price @@ -193,7 +194,7 @@ class BinanceAggTrade(msgspec.Struct, frozen=True): class BinanceKline(msgspec.Struct, array_like=True): - """Array-like schema of single Binance kline""" + """Array-like schema of single Binance kline.""" open_time: int open: str @@ -213,7 +214,7 @@ def parse_to_binance_bar( bar_type: BarType, ts_init: int, ) -> BinanceBar: - """Parse kline to BinanceBar""" + """Parse kline to BinanceBar.""" return BinanceBar( bar_type=bar_type, open=Price.from_str(self.open), @@ -231,7 +232,7 @@ def parse_to_binance_bar( class BinanceTicker24hr(msgspec.Struct, frozen=True): - """Schema of single Binance 24hr ticker (FULL/MINI)""" + """Schema of single Binance 24hr ticker (FULL/MINI).""" symbol: Optional[str] lastPrice: Optional[str] @@ -263,7 +264,7 @@ class BinanceTicker24hr(msgspec.Struct, frozen=True): class BinanceTickerPrice(msgspec.Struct, frozen=True): - """Schema of single Binance Price Ticker""" + """Schema of single Binance Price Ticker.""" symbol: Optional[str] price: Optional[str] @@ -272,7 +273,7 @@ class BinanceTickerPrice(msgspec.Struct, frozen=True): class BinanceTickerBook(msgspec.Struct, frozen=True): - """Schema of a single Binance Order Book Ticker""" + """Schema of a single Binance Order Book Ticker.""" symbol: Optional[str] bidPrice: Optional[str] @@ -297,7 +298,7 @@ class BinanceDataMsgWrapper(msgspec.Struct): class BinanceOrderBookDelta(msgspec.Struct, array_like=True): - """Schema of single ask/bid delta""" + """Schema of single ask/bid delta.""" price: str size: str diff --git a/nautilus_trader/adapters/binance/common/schemas/symbol.py b/nautilus_trader/adapters/binance/common/schemas/symbol.py index c1b97254de13..8b6dceabc95f 100644 --- a/nautilus_trader/adapters/binance/common/schemas/symbol.py +++ b/nautilus_trader/adapters/binance/common/schemas/symbol.py @@ -24,11 +24,11 @@ class BinanceSymbol(str): - """Binance compatible symbol""" + """Binance compatible symbol.""" def __new__(cls, symbol: str): if symbol is not None: - # Format the string on construction to be binance compatible + # Format the string on construction to be Binance compatible return super().__new__( cls, symbol.upper().replace(" ", "").replace("/", "").replace("-PERP", ""), @@ -48,7 +48,7 @@ def parse_binance_to_internal(self, account_type: BinanceAccountType) -> str: class BinanceSymbols(str): - """Binance compatible list of symbols""" + """Binance compatible list of symbols.""" def __new__(cls, symbols: list[str]): if symbols is not None: diff --git a/nautilus_trader/adapters/binance/config.py b/nautilus_trader/adapters/binance/config.py index 21ce25803673..1a8fd0e1f891 100644 --- a/nautilus_trader/adapters/binance/config.py +++ b/nautilus_trader/adapters/binance/config.py @@ -79,7 +79,7 @@ class BinanceExecClientConfig(LiveExecClientConfig): If client is connecting to Binance US. testnet : bool, default False If the client is connecting to a Binance testnet. - clock_sync_interval_secs : int, default 900 (15 mins) + clock_sync_interval_secs : int, default 0 The interval (seconds) between syncing the Nautilus clock with the Binance server(s) clock. If zero, then will *not* perform syncing. warn_gtd_to_gtc : bool, default True @@ -93,5 +93,5 @@ class BinanceExecClientConfig(LiveExecClientConfig): base_url_ws: Optional[str] = None us: bool = False testnet: bool = False - clock_sync_interval_secs: int = 900 + clock_sync_interval_secs: int = 0 warn_gtd_to_gtc: bool = True diff --git a/nautilus_trader/adapters/binance/factories.py b/nautilus_trader/adapters/binance/factories.py index 592b4478714b..d886db4c9926 100644 --- a/nautilus_trader/adapters/binance/factories.py +++ b/nautilus_trader/adapters/binance/factories.py @@ -38,7 +38,7 @@ from nautilus_trader.msgbus.bus import MessageBus -HTTP_CLIENTS: dict[str, BinanceHttpClient] = {} +BINANCE_HTTP_CLIENTS: dict[str, BinanceHttpClient] = {} def get_cached_binance_http_client( @@ -84,14 +84,14 @@ def get_cached_binance_http_client( BinanceHttpClient """ - global HTTP_CLIENTS + global BINANCE_HTTP_CLIENTS key = key or _get_api_key(account_type, is_testnet) secret = secret or _get_api_secret(account_type, is_testnet) default_http_base_url = _get_http_base_url(account_type, is_testnet, is_us) client_key: str = "|".join((key, secret)) - if client_key not in HTTP_CLIENTS: + if client_key not in BINANCE_HTTP_CLIENTS: client = BinanceHttpClient( loop=loop, clock=clock, @@ -100,8 +100,8 @@ def get_cached_binance_http_client( secret=secret, base_url=base_url or default_http_base_url, ) - HTTP_CLIENTS[client_key] = client - return HTTP_CLIENTS[client_key] + BINANCE_HTTP_CLIENTS[client_key] = client + return BINANCE_HTTP_CLIENTS[client_key] @lru_cache(1) diff --git a/nautilus_trader/adapters/binance/futures/__init__.py b/nautilus_trader/adapters/binance/futures/__init__.py index e69de29bb2d1..ca16b56e4794 100644 --- a/nautilus_trader/adapters/binance/futures/__init__.py +++ b/nautilus_trader/adapters/binance/futures/__init__.py @@ -0,0 +1,14 @@ +# ------------------------------------------------------------------------------------------------- +# Copyright (C) 2015-2023 Nautech Systems Pty Ltd. All rights reserved. +# https://nautechsystems.io +# +# Licensed under the GNU Lesser General Public License Version 3.0 (the "License"); +# You may not use this file except in compliance with the License. +# You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ------------------------------------------------------------------------------------------------- diff --git a/nautilus_trader/adapters/binance/futures/execution.py b/nautilus_trader/adapters/binance/futures/execution.py index f53ad40a3406..1a9b65211a84 100644 --- a/nautilus_trader/adapters/binance/futures/execution.py +++ b/nautilus_trader/adapters/binance/futures/execution.py @@ -73,7 +73,7 @@ class BinanceFuturesExecutionClient(BinanceCommonExecutionClient): The account type for the client. base_url_ws : str, optional The base URL for the WebSocket client. - clock_sync_interval_secs : int, default 900 + clock_sync_interval_secs : int, default 0 The interval (seconds) between syncing the Nautilus clock with the Binance server(s) clock. If zero, then will *not* perform syncing. warn_gtd_to_gtc : bool, default True @@ -91,7 +91,7 @@ def __init__( instrument_provider: BinanceFuturesInstrumentProvider, account_type: BinanceAccountType = BinanceAccountType.FUTURES_USDT, base_url_ws: Optional[str] = None, - clock_sync_interval_secs: int = 900, + clock_sync_interval_secs: int = 0, warn_gtd_to_gtc: bool = True, ): if not account_type.is_futures: diff --git a/nautilus_trader/adapters/binance/futures/http/__init__.py b/nautilus_trader/adapters/binance/futures/http/__init__.py index e69de29bb2d1..ca16b56e4794 100644 --- a/nautilus_trader/adapters/binance/futures/http/__init__.py +++ b/nautilus_trader/adapters/binance/futures/http/__init__.py @@ -0,0 +1,14 @@ +# ------------------------------------------------------------------------------------------------- +# Copyright (C) 2015-2023 Nautech Systems Pty Ltd. All rights reserved. +# https://nautechsystems.io +# +# Licensed under the GNU Lesser General Public License Version 3.0 (the "License"); +# You may not use this file except in compliance with the License. +# You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ------------------------------------------------------------------------------------------------- diff --git a/nautilus_trader/adapters/binance/futures/http/account.py b/nautilus_trader/adapters/binance/futures/http/account.py index 93b547db5550..47fd0f1cfa3d 100644 --- a/nautilus_trader/adapters/binance/futures/http/account.py +++ b/nautilus_trader/adapters/binance/futures/http/account.py @@ -33,7 +33,7 @@ class BinanceFuturesPositionModeHttp(BinanceHttpEndpoint): """ - Endpoint of user's position mode for every FUTURES symbol + Endpoint of user's position mode for every FUTURES symbol. `GET /fapi/v1/positionSide/dual` `GET /dapi/v1/positionSide/dual` @@ -45,7 +45,6 @@ class BinanceFuturesPositionModeHttp(BinanceHttpEndpoint): ---------- https://binance-docs.github.io/apidocs/futures/en/#change-position-mode-trade https://binance-docs.github.io/apidocs/delivery/en/#change-position-mode-trade - """ def __init__( @@ -68,15 +67,14 @@ def __init__( class GetParameters(msgspec.Struct, omit_defaults=True, frozen=True): """ - Parameters of positionSide/dual GET request + Parameters of positionSide/dual GET request. Parameters ---------- timestamp : str - The millisecond timestamp of the request + The millisecond timestamp of the request. recvWindow : str, optional The response receive window for the request (cannot be greater than 60000). - """ timestamp: str @@ -84,18 +82,17 @@ class GetParameters(msgspec.Struct, omit_defaults=True, frozen=True): class PostParameters(msgspec.Struct, omit_defaults=True, frozen=True): """ - Parameters of positionSide/dual POST request + Parameters of positionSide/dual POST request. Parameters ---------- timestamp : str - The millisecond timestamp of the request + The millisecond timestamp of the request. dualSidePosition : str ('true', 'false') The dual side position mode to set... - `true`: Hedge Mode, `false`: One-way mode + `true`: Hedge Mode, `false`: One-way mode. recvWindow : str, optional The response receive window for the request (cannot be greater than 60000). - """ timestamp: str @@ -124,7 +121,6 @@ class BinanceFuturesAllOpenOrdersHttp(BinanceHttpEndpoint): ---------- https://binance-docs.github.io/apidocs/futures/en/#cancel-all-open-orders-trade https://binance-docs.github.io/apidocs/delivery/en/#cancel-all-open-orders-trade - """ def __init__( @@ -145,17 +141,16 @@ def __init__( class DeleteParameters(msgspec.Struct, omit_defaults=True, frozen=True): """ - Parameters of allOpenOrders DELETE request + Parameters of allOpenOrders DELETE request. Parameters ---------- timestamp : str - The millisecond timestamp of the request + The millisecond timestamp of the request. symbol : BinanceSymbol The symbol of the request recvWindow : str, optional The response receive window for the request (cannot be greater than 60000). - """ timestamp: str @@ -170,7 +165,7 @@ async def _delete(self, parameters: DeleteParameters) -> BinanceStatusCode: class BinanceFuturesAccountHttp(BinanceHttpEndpoint): """ - Endpoint of current FUTURES account information + Endpoint of current FUTURES account information. `GET /fapi/v2/account` `GET /dapi/v1/account` @@ -179,7 +174,6 @@ class BinanceFuturesAccountHttp(BinanceHttpEndpoint): ---------- https://binance-docs.github.io/apidocs/futures/en/#account-information-v2-user_data https://binance-docs.github.io/apidocs/delivery/en/#account-information-user_data - """ def __init__( @@ -200,15 +194,14 @@ def __init__( class GetParameters(msgspec.Struct, omit_defaults=True, frozen=True): """ - Parameters of account GET request + Parameters of account GET request. Parameters ---------- timestamp : str - The millisecond timestamp of the request + The millisecond timestamp of the request. recvWindow : str, optional The response receive window for the request (cannot be greater than 60000). - """ timestamp: str @@ -231,7 +224,6 @@ class BinanceFuturesPositionRiskHttp(BinanceHttpEndpoint): ---------- https://binance-docs.github.io/apidocs/futures/en/#position-information-v2-user_data https://binance-docs.github.io/apidocs/delivery/en/#position-information-user_data - """ def __init__( @@ -252,17 +244,16 @@ def __init__( class GetParameters(msgspec.Struct, omit_defaults=True, frozen=True): """ - Parameters of positionRisk GET request + Parameters of positionRisk GET request. Parameters ---------- timestamp : str - The millisecond timestamp of the request - symbol : BinanceSymbol - The symbol of the request + The millisecond timestamp of the request. + symbol : BinanceSymbol, optional + The symbol of the request. recvWindow : str, optional The response receive window for the request (cannot be greater than 60000). - """ timestamp: str @@ -325,7 +316,7 @@ async def query_futures_hedge_mode( self, recv_window: Optional[str] = None, ) -> BinanceFuturesDualSidePosition: - """Check Binance Futures hedge mode (dualSidePosition)""" + """Check Binance Futures hedge mode (dualSidePosition).""" return await self._endpoint_futures_position_mode._get( parameters=self._endpoint_futures_position_mode.GetParameters( timestamp=self._timestamp(), @@ -338,7 +329,7 @@ async def set_futures_hedge_mode( dual_side_position: bool, recv_window: Optional[str] = None, ) -> BinanceStatusCode: - """Set Binance Futures hedge mode (dualSidePosition)""" + """Set Binance Futures hedge mode (dualSidePosition).""" return await self._endpoint_futures_position_mode._post( parameters=self._endpoint_futures_position_mode.PostParameters( timestamp=self._timestamp(), diff --git a/nautilus_trader/adapters/binance/futures/http/market.py b/nautilus_trader/adapters/binance/futures/http/market.py index 4aca25517a83..40b508b3d7ff 100644 --- a/nautilus_trader/adapters/binance/futures/http/market.py +++ b/nautilus_trader/adapters/binance/futures/http/market.py @@ -26,7 +26,7 @@ class BinanceFuturesExchangeInfoHttp(BinanceHttpEndpoint): """ - Endpoint of FUTURES exchange trading rules and symbol information + Endpoint of FUTURES exchange trading rules and symbol information. `GET /fapi/v1/exchangeInfo` `GET /dapi/v1/exchangeInfo` @@ -35,7 +35,6 @@ class BinanceFuturesExchangeInfoHttp(BinanceHttpEndpoint): ---------- https://binance-docs.github.io/apidocs/futures/en/#exchange-information https://binance-docs.github.io/apidocs/delivery/en/#exchange-information - """ def __init__( @@ -69,8 +68,7 @@ class BinanceFuturesMarketHttpAPI(BinanceMarketHttpAPI): client : BinanceHttpClient The Binance REST API client. account_type : BinanceAccountType - The Binance account type, used to select the endpoint - + The Binance account type, used to select the endpoint. """ def __init__( diff --git a/nautilus_trader/adapters/binance/futures/http/user.py b/nautilus_trader/adapters/binance/futures/http/user.py index 6d5946c83dd5..9b82301613b0 100644 --- a/nautilus_trader/adapters/binance/futures/http/user.py +++ b/nautilus_trader/adapters/binance/futures/http/user.py @@ -28,7 +28,7 @@ class BinanceFuturesUserDataHttpAPI(BinanceUserDataHttpAPI): client : BinanceHttpClient The Binance REST API client. account_type : BinanceAccountType - The Binance account type, used to select the endpoint + The Binance account type, used to select the endpoint. """ def __init__( diff --git a/nautilus_trader/adapters/binance/futures/http/wallet.py b/nautilus_trader/adapters/binance/futures/http/wallet.py index cbe18ed00ba1..38d8914118bc 100644 --- a/nautilus_trader/adapters/binance/futures/http/wallet.py +++ b/nautilus_trader/adapters/binance/futures/http/wallet.py @@ -29,7 +29,7 @@ class BinanceFuturesCommissionRateHttp(BinanceHttpEndpoint): """ - Endpoint of maker/taker commission rate information + Endpoint of maker/taker commission rate information. `GET /fapi/v1/commissionRate` `GET /dapi/v1/commissionRate` @@ -38,7 +38,6 @@ class BinanceFuturesCommissionRateHttp(BinanceHttpEndpoint): ---------- https://binance-docs.github.io/apidocs/futures/en/#user-commission-rate-user_data https://binance-docs.github.io/apidocs/delivery/en/#user-commission-rate-user_data - """ def __init__( @@ -63,12 +62,11 @@ class GetParameters(msgspec.Struct, omit_defaults=True, frozen=True): Parameters ---------- symbol : BinanceSymbol - Receive commission rate of the provided symbol - recvWindow : str - Optional number of milliseconds after timestamp the request is valid + Receive commission rate of the provided symbol. timestamp : str - Millisecond timestamp of the request - + Millisecond timestamp of the request. + recvWindow : str, optional + The number of milliseconds after timestamp the request is valid. """ timestamp: str @@ -116,7 +114,7 @@ def __init__( ) def _timestamp(self) -> str: - """Create Binance timestamp from internal clock""" + """Create Binance timestamp from internal clock.""" return str(self._clock.timestamp_ms()) async def query_futures_commission_rate( diff --git a/nautilus_trader/adapters/binance/futures/schemas/__init__.py b/nautilus_trader/adapters/binance/futures/schemas/__init__.py index e69de29bb2d1..ca16b56e4794 100644 --- a/nautilus_trader/adapters/binance/futures/schemas/__init__.py +++ b/nautilus_trader/adapters/binance/futures/schemas/__init__.py @@ -0,0 +1,14 @@ +# ------------------------------------------------------------------------------------------------- +# Copyright (C) 2015-2023 Nautech Systems Pty Ltd. All rights reserved. +# https://nautechsystems.io +# +# Licensed under the GNU Lesser General Public License Version 3.0 (the "License"); +# You may not use this file except in compliance with the License. +# You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ------------------------------------------------------------------------------------------------- diff --git a/nautilus_trader/adapters/binance/futures/schemas/account.py b/nautilus_trader/adapters/binance/futures/schemas/account.py index 95c013019ffb..3d9dbdf2534a 100644 --- a/nautilus_trader/adapters/binance/futures/schemas/account.py +++ b/nautilus_trader/adapters/binance/futures/schemas/account.py @@ -155,7 +155,7 @@ def parse_to_position_status_report( class BinanceFuturesDualSidePosition(msgspec.Struct, frozen=True): """ - HTTP response from `Binance Futures` GET /fapi/v1/positionSide/dual (HMAC SHA256) + HTTP response from `Binance Futures` GET /fapi/v1/positionSide/dual (HMAC SHA256). """ dualSidePosition: bool diff --git a/nautilus_trader/adapters/binance/futures/schemas/wallet.py b/nautilus_trader/adapters/binance/futures/schemas/wallet.py index 0ae36c32926a..d1931fb82d94 100644 --- a/nautilus_trader/adapters/binance/futures/schemas/wallet.py +++ b/nautilus_trader/adapters/binance/futures/schemas/wallet.py @@ -22,7 +22,7 @@ class BinanceFuturesCommissionRate(msgspec.Struct, frozen=True): - """Schema of a single `Binance Futures` commissionRate""" + """Schema of a single `Binance Futures` commissionRate.""" symbol: str makerCommissionRate: str diff --git a/nautilus_trader/adapters/binance/http/market.py b/nautilus_trader/adapters/binance/http/market.py index ccf7303158a9..7b3ac1da47d8 100644 --- a/nautilus_trader/adapters/binance/http/market.py +++ b/nautilus_trader/adapters/binance/http/market.py @@ -54,7 +54,6 @@ class BinancePingHttp(BinanceHttpEndpoint): https://binance-docs.github.io/apidocs/spot/en/#test-connectivity https://binance-docs.github.io/apidocs/futures/en/#test-connectivity https://binance-docs.github.io/apidocs/delivery/en/#test-connectivity - """ def __init__( @@ -92,7 +91,6 @@ class BinanceTimeHttp(BinanceHttpEndpoint): https://binance-docs.github.io/apidocs/spot/en/#check-server-time https://binance-docs.github.io/apidocs/futures/en/#check-server-time https://binance-docs.github.io/apidocs/delivery/en/#check-server-time - """ def __init__( @@ -115,7 +113,7 @@ async def _get(self) -> BinanceTime: class BinanceDepthHttp(BinanceHttpEndpoint): """ - Endpoint of orderbook depth + Endpoint of orderbook depth. `GET /api/v3/depth` `GET /fapi/v1/depth` @@ -126,7 +124,6 @@ class BinanceDepthHttp(BinanceHttpEndpoint): https://binance-docs.github.io/apidocs/spot/en/#order-book https://binance-docs.github.io/apidocs/futures/en/#order-book https://binance-docs.github.io/apidocs/delivery/en/#order-book - """ def __init__( @@ -147,7 +144,7 @@ def __init__( class GetParameters(msgspec.Struct, omit_defaults=True, frozen=True): """ - Orderbook depth GET endpoint parameters + Orderbook depth GET endpoint parameters. Parameters ---------- @@ -191,7 +188,6 @@ class BinanceTradesHttp(BinanceHttpEndpoint): https://binance-docs.github.io/apidocs/spot/en/#recent-trades-list https://binance-docs.github.io/apidocs/futures/en/#recent-trades-list https://binance-docs.github.io/apidocs/delivery/en/#recent-trades-list - """ def __init__( @@ -244,7 +240,6 @@ class BinanceHistoricalTradesHttp(BinanceHttpEndpoint): https://binance-docs.github.io/apidocs/spot/en/#old-trade-lookup-market_data https://binance-docs.github.io/apidocs/futures/en/#old-trades-lookup-market_data https://binance-docs.github.io/apidocs/delivery/en/#old-trades-lookup-market_data - """ def __init__( @@ -322,7 +317,7 @@ def __init__( class GetParameters(msgspec.Struct, omit_defaults=True, frozen=True): """ - GET parameters for aggregate trades + GET parameters for aggregate trades. Parameters ---------- @@ -331,11 +326,11 @@ class GetParameters(msgspec.Struct, omit_defaults=True, frozen=True): limit : int, optional The limit for the response. Default 500; max 1000. fromId : str, optional - Trade id to fetch from INCLUSIVE + Trade id to fetch from INCLUSIVE. startTime : str, optional - Timestamp in ms to get aggregate trades from INCLUSIVE + Timestamp in ms to get aggregate trades from INCLUSIVE. endTime : str, optional - Timestamp in ms to get aggregate trades until INCLUSIVE + Timestamp in ms to get aggregate trades until INCLUSIVE. """ symbol: BinanceSymbol @@ -385,7 +380,7 @@ def __init__( class GetParameters(msgspec.Struct, omit_defaults=True, frozen=True): """ - GET parameters for klines + GET parameters for klines. Parameters ---------- @@ -396,9 +391,9 @@ class GetParameters(msgspec.Struct, omit_defaults=True, frozen=True): limit : int, optional The limit for the response. Default 500; max 1000. startTime : str, optional - Timestamp in ms to get klines from INCLUSIVE + Timestamp in ms to get klines from INCLUSIVE. endTime : str, optional - Timestamp in ms to get klines until INCLUSIVE + Timestamp in ms to get klines until INCLUSIVE. """ symbol: BinanceSymbol @@ -415,7 +410,7 @@ async def _get(self, parameters: GetParameters) -> list[BinanceKline]: class BinanceTicker24hrHttp(BinanceHttpEndpoint): """ - Endpoint of 24 hour rolling window price change statistics + Endpoint of 24 hour rolling window price change statistics. `GET /api/v3/ticker/24hr` `GET /fapi/v1/ticker/24hr` @@ -461,7 +456,7 @@ class GetParameters(msgspec.Struct, omit_defaults=True, frozen=True): When omitted, endpoint will return a list of BinanceTicker24hr for all trading pairs. symbols : BinanceSymbols SPOT/MARGIN only! - List of trading pairs. When given, endpoint will return a list of BinanceTicker24hr + List of trading pairs. When given, endpoint will return a list of BinanceTicker24hr. type : str SPOT/MARGIN only! Select between FULL and MINI 24hr ticker responses to save bandwidth. @@ -482,7 +477,7 @@ async def _get(self, parameters: GetParameters) -> list[BinanceTicker24hr]: class BinanceTickerPriceHttp(BinanceHttpEndpoint): """ - Endpoint of latest price for a symbol or symbols + Endpoint of latest price for a symbol or symbols. `GET /api/v3/ticker/price` `GET /fapi/v1/ticker/price` @@ -514,7 +509,7 @@ def __init__( class GetParameters(msgspec.Struct, omit_defaults=True, frozen=True): """ - GET parameters for price ticker + GET parameters for price ticker. Parameters ---------- @@ -540,7 +535,7 @@ async def _get(self, parameters: GetParameters) -> list[BinanceTickerPrice]: class BinanceTickerBookHttp(BinanceHttpEndpoint): """ - Endpoint of best price/qty on the order book for a symbol or symbols + Endpoint of best price/qty on the order book for a symbol or symbols. `GET /api/v3/ticker/bookTicker` `GET /fapi/v1/ticker/bookTicker` @@ -572,7 +567,7 @@ def __init__( class GetParameters(msgspec.Struct, omit_defaults=True, frozen=True): """ - GET parameters for order book ticker + GET parameters for order book ticker. Parameters ---------- @@ -581,7 +576,7 @@ class GetParameters(msgspec.Struct, omit_defaults=True, frozen=True): When omitted, endpoint will return a list of BinanceTickerBook for all trading pairs. symbols : str SPOT/MARGIN only! - List of trading pairs. When given, endpoint will return a list of BinanceTickerBook + List of trading pairs. When given, endpoint will return a list of BinanceTickerBook. """ symbol: Optional[BinanceSymbol] = None @@ -605,7 +600,7 @@ class BinanceMarketHttpAPI: client : BinanceHttpClient The Binance REST API client. account_type : BinanceAccountType - The Binance account type, used to select the endpoint prefix + The Binance account type, used to select the endpoint prefix. Warnings -------- @@ -644,11 +639,11 @@ def __init__( self._endpoint_ticker_book = BinanceTickerBookHttp(client, self.base_endpoint) async def ping(self) -> dict: - """Ping Binance REST API""" + """Ping Binance REST API.""" return await self._endpoint_ping._get() async def request_server_time(self) -> int: - """Request server time from Binance""" + """Request server time from Binance.""" response = await self._endpoint_time._get() return response.serverTime @@ -683,7 +678,7 @@ async def query_trades( symbol: str, limit: Optional[int] = None, ) -> list[BinanceTrade]: - """Query trades for symbol""" + """Query trades for symbol.""" return await self._endpoint_trades._get( parameters=self._endpoint_trades.GetParameters( symbol=BinanceSymbol(symbol), @@ -697,7 +692,7 @@ async def request_trade_ticks( ts_init: int, limit: Optional[int] = None, ) -> list[TradeTick]: - """Request TradeTicks from Binance""" + """Request TradeTicks from Binance.""" trades = await self.query_trades(instrument_id.symbol.value, limit) return [ trade.parse_to_trade_tick( @@ -715,7 +710,7 @@ async def query_agg_trades( end_time: Optional[str] = None, from_id: Optional[str] = None, ) -> list[BinanceAggTrade]: - """Query trades for symbol""" + """Query trades for symbol.""" return await self._endpoint_agg_trades._get( parameters=self._endpoint_agg_trades.GetParameters( symbol=BinanceSymbol(symbol), @@ -732,7 +727,7 @@ async def query_historical_trades( limit: Optional[int] = None, from_id: Optional[str] = None, ) -> list[BinanceTrade]: - """Query historical trades for symbol""" + """Query historical trades for symbol.""" return await self._endpoint_historical_trades._get( parameters=self._endpoint_historical_trades.GetParameters( symbol=BinanceSymbol(symbol), @@ -748,7 +743,7 @@ async def request_historical_trade_ticks( limit: Optional[int] = None, from_id: Optional[str] = None, ) -> list[TradeTick]: - """Request historical TradeTicks from Binance""" + """Request historical TradeTicks from Binance.""" historical_trades = await self.query_historical_trades( symbol=instrument_id.symbol.value, limit=limit, @@ -790,7 +785,7 @@ async def request_binance_bars( start_time: Optional[str] = None, end_time: Optional[str] = None, ) -> list[BinanceBar]: - """Request Binance Bars from Klines""" + """Request Binance Bars from Klines.""" klines = await self.query_klines( symbol=bar_type.instrument_id.symbol.value, interval=interval, diff --git a/nautilus_trader/adapters/binance/http/user.py b/nautilus_trader/adapters/binance/http/user.py index a2cdd599aeca..8b3d7ac1029b 100644 --- a/nautilus_trader/adapters/binance/http/user.py +++ b/nautilus_trader/adapters/binance/http/user.py @@ -29,7 +29,7 @@ class BinanceListenKeyHttp(BinanceHttpEndpoint): """ - Endpoint for managing user data streams (listenKey) + Endpoint for managing user data streams (listenKey). `POST /api/v3/userDataStream` `POST /sapi/v3/userDataStream` @@ -79,7 +79,7 @@ def __init__( class PostParameters(msgspec.Struct, omit_defaults=True, frozen=True): """ - POST parameters for creating listenkeys + POST parameters for creating listen keys. Parameters ---------- @@ -91,14 +91,14 @@ class PostParameters(msgspec.Struct, omit_defaults=True, frozen=True): class PutDeleteParameters(msgspec.Struct, omit_defaults=True, frozen=True): """ - PUT & DELETE parameters for managing listenkeys + PUT & DELETE parameters for managing listen keys. Parameters ---------- symbol : BinanceSymbol The trading pair. Only required for ISOLATED MARGIN accounts! listenKey : str - The listenkey to manage. Only required for SPOT/MARGIN accounts! + The listen key to manage. Only required for SPOT/MARGIN accounts! """ symbol: Optional[BinanceSymbol] = None # MARGIN_ISOLATED only, mandatory @@ -129,7 +129,7 @@ class BinanceUserDataHttpAPI: client : BinanceHttpClient The Binance REST API client. account_type : BinanceAccountType - The Binance account type, used to select the endpoint + The Binance account type, used to select the endpoint. Warnings -------- diff --git a/nautilus_trader/adapters/binance/spot/execution.py b/nautilus_trader/adapters/binance/spot/execution.py index cc2ef4f743c8..c3ee7a5454b0 100644 --- a/nautilus_trader/adapters/binance/spot/execution.py +++ b/nautilus_trader/adapters/binance/spot/execution.py @@ -68,7 +68,7 @@ class BinanceSpotExecutionClient(BinanceCommonExecutionClient): The account type for the client. base_url_ws : str, optional The base URL for the WebSocket client. - clock_sync_interval_secs : int, default 900 + clock_sync_interval_secs : int, default 0 The interval (seconds) between syncing the Nautilus clock with the Binance server(s) clock. If zero, then will *not* perform syncing. warn_gtd_to_gtc : bool, default True @@ -86,7 +86,7 @@ def __init__( instrument_provider: BinanceSpotInstrumentProvider, account_type: BinanceAccountType = BinanceAccountType.SPOT, base_url_ws: Optional[str] = None, - clock_sync_interval_secs: int = 900, + clock_sync_interval_secs: int = 0, warn_gtd_to_gtc: bool = True, ): if not account_type.is_spot_or_margin: diff --git a/nautilus_trader/adapters/binance/spot/http/account.py b/nautilus_trader/adapters/binance/spot/http/account.py index b07507f80f99..c220321ebe6a 100644 --- a/nautilus_trader/adapters/binance/spot/http/account.py +++ b/nautilus_trader/adapters/binance/spot/http/account.py @@ -51,7 +51,6 @@ class BinanceSpotOpenOrdersHttp(BinanceOpenOrdersHttp): ---------- https://binance-docs.github.io/apidocs/spot/en/#current-open-orders-user_data https://binance-docs.github.io/apidocs/spot/en/#cancel-all-open-orders-on-a-symbol-trade - """ def __init__( @@ -83,7 +82,6 @@ class DeleteParameters(msgspec.Struct, omit_defaults=True, frozen=True): The symbol of the orders recvWindow : str, optional The response receive window for the request (cannot be greater than 60000). - """ timestamp: str @@ -98,14 +96,13 @@ async def _delete(self, parameters: DeleteParameters) -> list[dict[str, Any]]: class BinanceSpotOrderOcoHttp(BinanceHttpEndpoint): """ - Endpoint for creating SPOT/MARGIN OCO orders + Endpoint for creating SPOT/MARGIN OCO orders. `POST /api/v3/order/oco` References ---------- https://binance-docs.github.io/apidocs/spot/en/#new-oco-trade - """ def __init__( @@ -126,18 +123,18 @@ def __init__( class PostParameters(msgspec.Struct, omit_defaults=True, frozen=True): """ - OCO order creation POST endpoint parameters + OCO order creation POST endpoint parameters. Parameters ---------- symbol : BinanceSymbol - The symbol of the order + The symbol of the order. timestamp : str - The millisecond timestamp of the request + The millisecond timestamp of the request. side : BinanceOrderSide - The market side of the order (BUY, SELL) + The market side of the order (BUY, SELL). quantity : str - The order quantity in base asset units for the request + The order quantity in base asset units for the request. price : str The order price for the request. stopPrice : str @@ -154,7 +151,7 @@ class PostParameters(msgspec.Struct, omit_defaults=True, frozen=True): limitIcebergQty : str, optional Create a limit iceberg order. trailingDelta : str, optional - Can be used in addition to stopPrice + Can be used in addition to stopPrice. The order trailing delta of the request. stopClientOrderId : str, optional The client order ID for the stop request. A unique ID among open orders. @@ -162,7 +159,7 @@ class PostParameters(msgspec.Struct, omit_defaults=True, frozen=True): stopStrategyId : int, optional The client strategy ID for the stop request. stopStrategyType : int, optional - The client strategy type for the stop request. Cannot be less than 1000000 + The client strategy type for the stop request. Cannot be less than 1000000. stopLimitPrice : str, optional Limit price for the stop order request. If provided, stopLimitTimeInForce is required. @@ -170,13 +167,12 @@ class PostParameters(msgspec.Struct, omit_defaults=True, frozen=True): Create a stop iceberg order. stopLimitTimeInForce : BinanceTimeInForce, optional The time in force of the stop limit order. - Valid values: (GTC, FOK, IOC) + Valid values: (GTC, FOK, IOC). newOrderRespType : BinanceNewOrderRespType, optional The response type for the order request. recvWindow : str, optional The response receive window in milliseconds for the request. Cannot exceed 60000. - """ symbol: BinanceSymbol @@ -208,7 +204,7 @@ async def _post(self, parameters: PostParameters) -> BinanceSpotOrderOco: class BinanceSpotOrderListHttp(BinanceHttpEndpoint): """ - Endpoint for querying and deleting SPOT/MARGIN OCO orders + Endpoint for querying and deleting SPOT/MARGIN OCO orders. `GET /api/v3/orderList` `DELETE /api/v3/orderList` @@ -217,7 +213,6 @@ class BinanceSpotOrderListHttp(BinanceHttpEndpoint): ---------- https://binance-docs.github.io/apidocs/spot/en/#query-oco-user_data https://binance-docs.github.io/apidocs/spot/en/#cancel-oco-trade - """ def __init__( @@ -244,17 +239,16 @@ class GetParameters(msgspec.Struct, omit_defaults=True, frozen=True): Parameters ---------- timestamp : str - The millisecond timestamp of the request + The millisecond timestamp of the request. orderListId : str, optional - The unique identifier of the order list to retrieve + The unique identifier of the order list to retrieve. origClientOrderId : str, optional - The client specified identifier of the order list to retrieve + The client specified identifier of the order list to retrieve. recvWindow : str, optional The response receive window in milliseconds for the request. Cannot exceed 60000. - NOTE: Either orderListId or origClientOrderId must be provided - + NOTE: Either orderListId or origClientOrderId must be provided. """ timestamp: str @@ -269,13 +263,13 @@ class DeleteParameters(msgspec.Struct, omit_defaults=True, frozen=True): Parameters ---------- timestamp : str - The millisecond timestamp of the request + The millisecond timestamp of the request. symbol : BinanceSymbol - The symbol of the order + The symbol of the order. orderListId : str, optional - The unique identifier of the order list to retrieve + The unique identifier of the order list to retrieve. listClientOrderId : str, optional - The client specified identifier of the order list to retrieve + The client specified identifier of the order list to retrieve. newClientOrderId : str, optional Used to uniquely identify this cancel. Automatically generated by default. @@ -283,8 +277,7 @@ class DeleteParameters(msgspec.Struct, omit_defaults=True, frozen=True): The response receive window in milliseconds for the request. Cannot exceed 60000. - NOTE: Either orderListId or listClientOrderId must be provided - + NOTE: Either orderListId or listClientOrderId must be provided. """ timestamp: str @@ -307,14 +300,13 @@ async def _delete(self, parameters: DeleteParameters) -> BinanceSpotOrderOco: class BinanceSpotAllOrderListHttp(BinanceHttpEndpoint): """ - Endpoint for querying all SPOT/MARGIN OCO orders + Endpoint for querying all SPOT/MARGIN OCO orders. `GET /api/v3/allOrderList` References ---------- https://binance-docs.github.io/apidocs/spot/en/#query-all-oco-user_data - """ def __init__( @@ -335,15 +327,15 @@ def __init__( class GetParameters(msgspec.Struct, omit_defaults=True, frozen=True): """ - Parameters of allOrderList GET request + Parameters of allOrderList GET request. Parameters ---------- timestamp : str - The millisecond timestamp of the request + The millisecond timestamp of the request. fromId : str, optional The order ID for the request. - If included, request will return orders from this orderId INCLUSIVE + If included, request will return orders from this orderId INCLUSIVE. startTime : str, optional The start time (UNIX milliseconds) filter for the request. endTime : str, optional @@ -355,7 +347,6 @@ class GetParameters(msgspec.Struct, omit_defaults=True, frozen=True): The response receive window for the request (cannot be greater than 60000). NOTE: If fromId is specified, neither startTime endTime can be provided. - """ timestamp: str @@ -373,14 +364,13 @@ async def _get(self, parameters: GetParameters) -> list[BinanceSpotOrderOco]: class BinanceSpotOpenOrderListHttp(BinanceHttpEndpoint): """ - Endpoint for querying all SPOT/MARGIN OPEN OCO orders + Endpoint for querying all SPOT/MARGIN OPEN OCO orders. `GET /api/v3/openOrderList` References ---------- https://binance-docs.github.io/apidocs/spot/en/#query-open-oco-user_data - """ def __init__( @@ -401,15 +391,14 @@ def __init__( class GetParameters(msgspec.Struct, omit_defaults=True, frozen=True): """ - Parameters of allOrderList GET request + Parameters of allOrderList GET request. Parameters ---------- timestamp : str - The millisecond timestamp of the request + The millisecond timestamp of the request. recvWindow : str, optional The response receive window for the request (cannot be greater than 60000). - """ timestamp: str @@ -423,14 +412,13 @@ async def _get(self, parameters: GetParameters) -> list[BinanceSpotOrderOco]: class BinanceSpotAccountHttp(BinanceHttpEndpoint): """ - Endpoint of current SPOT/MARGIN account information + Endpoint of current SPOT/MARGIN account information. `GET /api/v3/account` References ---------- https://binance-docs.github.io/apidocs/spot/en/#account-information-user_data - """ def __init__( @@ -451,15 +439,14 @@ def __init__( class GetParameters(msgspec.Struct, omit_defaults=True, frozen=True): """ - Parameters of account GET request + Parameters of account GET request. Parameters ---------- timestamp : str - The millisecond timestamp of the request + The millisecond timestamp of the request. recvWindow : str, optional The response receive window for the request (cannot be greater than 60000). - """ timestamp: str @@ -480,7 +467,6 @@ class BinanceSpotOrderRateLimitHttp(BinanceHttpEndpoint): References ---------- https://binance-docs.github.io/apidocs/spot/en/#query-current-order-count-usage-trade - """ def __init__( @@ -501,12 +487,12 @@ def __init__( class GetParameters(msgspec.Struct, omit_defaults=True, frozen=True): """ - Parameters of rateLimit/order GET request + Parameters of rateLimit/order GET request. Parameters ---------- timestamp : str - The millisecond timestamp of the request + The millisecond timestamp of the request. recvWindow : str, optional The response receive window for the request (cannot be greater than 60000). @@ -533,7 +519,6 @@ class BinanceSpotAccountHttpAPI(BinanceAccountHttpAPI): The clock for the API client. account_type : BinanceAccountType The Binance account type, used to select the endpoint prefix - """ def __init__( @@ -590,7 +575,7 @@ async def new_spot_oco( new_order_resp_type: Optional[BinanceNewOrderRespType] = None, recv_window: Optional[str] = None, ) -> BinanceSpotOrderOco: - """Send in a new spot OCO order to Binance""" + """Send in a new spot OCO order to Binance.""" if stop_limit_price is not None and stop_limit_time_in_force is None: raise RuntimeError( "stopLimitPrice cannot be provided without stopLimitTimeInForce.", diff --git a/nautilus_trader/adapters/binance/spot/http/market.py b/nautilus_trader/adapters/binance/spot/http/market.py index dc6d5a3f9c72..d20366ad4943 100644 --- a/nautilus_trader/adapters/binance/spot/http/market.py +++ b/nautilus_trader/adapters/binance/spot/http/market.py @@ -32,14 +32,13 @@ class BinanceSpotExchangeInfoHttp(BinanceHttpEndpoint): """ - Endpoint of SPOT/MARGIN exchange trading rules and symbol information + Endpoint of SPOT/MARGIN exchange trading rules and symbol information. `GET /api/v3/exchangeInfo` References ---------- https://binance-docs.github.io/apidocs/spot/en/#exchange-information - """ def __init__( @@ -60,17 +59,16 @@ def __init__( class GetParameters(msgspec.Struct, omit_defaults=True, frozen=True): """ - GET exchangeInfo parameters + GET exchangeInfo parameters. Parameters ---------- - symbol : BinanceSymbol - Optional, specify trading pair to get exchange info for - symbols : BinanceSymbols - Optional, specify list of trading pairs to get exchange info for - permissions : BinanceSpotPermissions - Optional, filter symbols list by supported permissions - + symbol : BinanceSymbol, optional + The specify trading pair to get exchange info for. + symbols : BinanceSymbols, optional + The specify list of trading pairs to get exchange info for. + permissions : BinanceSpotPermissions, optional + The filter symbols list by supported permissions. """ symbol: Optional[BinanceSymbol] = None @@ -85,14 +83,13 @@ async def _get(self, parameters: Optional[GetParameters] = None) -> BinanceSpotE class BinanceSpotAvgPriceHttp(BinanceHttpEndpoint): """ - Endpoint of current average price of a symbol + Endpoint of current average price of a symbol. `GET /api/v3/avgPrice` References ---------- https://binance-docs.github.io/apidocs/spot/en/#current-average-price - """ def __init__( @@ -113,13 +110,12 @@ def __init__( class GetParameters(msgspec.Struct, omit_defaults=True, frozen=True): """ - GET avgPrice parameters + GET avgPrice parameters. Parameters ---------- symbol : BinanceSymbol - Specify trading pair to get average price for - + Specify trading pair to get average price for. """ symbol: BinanceSymbol = None @@ -139,8 +135,7 @@ class BinanceSpotMarketHttpAPI(BinanceMarketHttpAPI): client : BinanceHttpClient The Binance REST API client. account_type : BinanceAccountType - The Binance account type, used to select the endpoint - + The Binance account type, used to select the endpoint. """ def __init__( diff --git a/nautilus_trader/adapters/binance/spot/http/user.py b/nautilus_trader/adapters/binance/spot/http/user.py index 8c5a6034fdbc..4f37c6abae1a 100644 --- a/nautilus_trader/adapters/binance/spot/http/user.py +++ b/nautilus_trader/adapters/binance/spot/http/user.py @@ -28,7 +28,7 @@ class BinanceSpotUserDataHttpAPI(BinanceUserDataHttpAPI): client : BinanceHttpClient The Binance REST API client. account_type : BinanceAccountType - The Binance account type, used to select the endpoint + The Binance account type, used to select the endpoint. """ def __init__( diff --git a/nautilus_trader/adapters/binance/spot/http/wallet.py b/nautilus_trader/adapters/binance/spot/http/wallet.py index 931bf516d9b7..cc568aaeaa8b 100644 --- a/nautilus_trader/adapters/binance/spot/http/wallet.py +++ b/nautilus_trader/adapters/binance/spot/http/wallet.py @@ -29,14 +29,13 @@ class BinanceSpotTradeFeeHttp(BinanceHttpEndpoint): """ - Endpoint of maker/taker trade fee information + Endpoint of maker/taker trade fee information. `GET /sapi/v1/asset/tradeFee` References ---------- https://binance-docs.github.io/apidocs/spot/en/#trade-fee-user_data - """ def __init__( @@ -57,7 +56,7 @@ def __init__( class GetParameters(msgspec.Struct, omit_defaults=True, frozen=True): """ - GET parameters for fetching trade fees + GET parameters for fetching trade fees. Parameters ---------- @@ -67,7 +66,6 @@ class GetParameters(msgspec.Struct, omit_defaults=True, frozen=True): Optional number of milliseconds after timestamp the request is valid timestamp : str Millisecond timestamp of the request - """ timestamp: str @@ -111,7 +109,7 @@ def __init__( self._endpoint_spot_trade_fee = BinanceSpotTradeFeeHttp(client, self.base_endpoint) def _timestamp(self) -> str: - """Create Binance timestamp from internal clock""" + """Create Binance timestamp from internal clock.""" return str(self._clock.timestamp_ms()) async def query_spot_trade_fees( From 6d31a2a50ecdcdcef1996fe81b3631f1971896c8 Mon Sep 17 00:00:00 2001 From: Chris Sellers Date: Sat, 4 Feb 2023 12:21:12 +1100 Subject: [PATCH 12/81] Update pre-commit --- poetry.lock | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/poetry.lock b/poetry.lock index 607c1257b317..04e497080f37 100644 --- a/poetry.lock +++ b/poetry.lock @@ -2942,4 +2942,4 @@ redis = ["hiredis", "redis"] [metadata] lock-version = "2.0" python-versions = ">=3.9,<3.12" -content-hash = "20bf3ac19091ddba37cb5e770729f5273198ae948c31ef4476ebe1c4d7b501e0" +content-hash = "23b831064255a4e8ddfa7c9e41a3d0a1da8c73e186cd2624370fa2ad91bc1960" diff --git a/pyproject.toml b/pyproject.toml index 47c1b1e88ba2..01f3a3363aaa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -83,7 +83,7 @@ black = "^22.12.0" flake8 = "^6.0.0" isort = "^5.12.0" mypy = "^0.991" -pre-commit = "^3.0.2" +pre-commit = "^3.0.4" pyproject-flake8 = "^6.0.0" types-pytz = "^2022.6.0" types-redis = "^4.3.21" From b3ce16032c7e086bdad7dc0e657a412d342c4d79 Mon Sep 17 00:00:00 2001 From: Chris Sellers Date: Sat, 4 Feb 2023 12:26:58 +1100 Subject: [PATCH 13/81] Consolidate BacktestEngine.run API --- docs/user_guide/advanced/data.md | 2 +- nautilus_trader/backtest/engine.pxd | 3 - nautilus_trader/backtest/engine.pyx | 132 ++++++------------ nautilus_trader/backtest/node.py | 16 ++- nautilus_trader/persistence/batching.py | 7 +- .../unit_tests/backtest/test_backtest_node.py | 2 +- 6 files changed, 52 insertions(+), 110 deletions(-) diff --git a/docs/user_guide/advanced/data.md b/docs/user_guide/advanced/data.md index e1619286a15d..70cb220c8f9a 100644 --- a/docs/user_guide/advanced/data.md +++ b/docs/user_guide/advanced/data.md @@ -21,7 +21,7 @@ class MyDataPoint(Data): z: int, ts_event: int, ts_init: int, - ): + ) -> None: super().__init__(ts_event, ts_init) self.label = label diff --git a/nautilus_trader/backtest/engine.pxd b/nautilus_trader/backtest/engine.pxd index 7a86434c184d..60e364996b07 100644 --- a/nautilus_trader/backtest/engine.pxd +++ b/nautilus_trader/backtest/engine.pxd @@ -46,8 +46,5 @@ cdef class BacktestEngine: cdef uint64_t _index cdef uint64_t _iteration - cpdef list list_actors(self) - cpdef list list_strategies(self) - cdef Data _next(self) cdef list _advance_time(self, uint64_t now_ns, list clocks) diff --git a/nautilus_trader/backtest/engine.pyx b/nautilus_trader/backtest/engine.pyx index c81f23be33c9..ace2fb2668c5 100644 --- a/nautilus_trader/backtest/engine.pyx +++ b/nautilus_trader/backtest/engine.pyx @@ -93,7 +93,7 @@ cdef class BacktestEngine: If `config` is not of type `BacktestEngineConfig`. """ - def __init__(self, config: Optional[BacktestEngineConfig] = None): + def __init__(self, config: Optional[BacktestEngineConfig] = None) -> None: if config is None: config = BacktestEngineConfig() Condition.type(config, BacktestEngineConfig, "config") @@ -339,7 +339,7 @@ cdef class BacktestEngine: """ return self._kernel.portfolio - def list_venues(self): + def list_venues(self) -> list[Venue]: """ Return the venues contained within the engine. @@ -686,28 +686,6 @@ cdef class BacktestEngine: # Checked inside trader self.kernel.trader.add_strategies(strategies) - cpdef list list_actors(self): - """ - Return the actors for the backtest. - - Returns - ---------- - list[Actors] - - """ - return self.trader.actors() - - cpdef list list_strategies(self): - """ - Return the strategies for the backtest. - - Returns - ---------- - list[Strategy] - - """ - return self.trader.strategies() - def reset(self) -> None: """ Reset the backtest engine. @@ -718,7 +696,7 @@ cdef class BacktestEngine: if self.kernel.trader.is_running: # End current backtest run - self._end() + self.end() # Change logger clock back to live clock for consistent time stamping self.kernel.logger.change_clock(self._clock) @@ -759,7 +737,7 @@ cdef class BacktestEngine: self._log.info("Reset.") - def clear_data(self): + def clear_data(self) -> None: """ Clear the engines internal data stream. @@ -786,6 +764,7 @@ cdef class BacktestEngine: start: Optional[Union[datetime, str, int]] = None, end: Optional[Union[datetime, str, int]] = None, run_config_id: Optional[str] = None, + streaming: bool = False, ) -> None: """ Run a backtest. @@ -793,6 +772,15 @@ cdef class BacktestEngine: At the end of the run the trader and strategies will be stopped, then post-run analysis performed. + If more data than can fit in memory is to be run through the backtest + engine, then `streaming` mode can be utilized. The expected sequence is as + follows: + - Add initial data batch and strategies. + - Call `run(streaming=True)`. + - Call `clear_data()`. + - Add next batch of data stream. + - Call either `run(streaming=False)` or `end()`. When there is no more data to run on. + Parameters ---------- start : Union[datetime, str, int], optional @@ -803,6 +791,9 @@ cdef class BacktestEngine: to the end of the data. run_config_id : str, optional The tokenized `BacktestRunConfig` ID. + streaming : bool, default False + If running in streaming mode. If False then will end the backtest + following the run iterations. Raises ------ @@ -813,59 +804,37 @@ cdef class BacktestEngine: """ self._run(start, end, run_config_id) - self._end() + if not streaming: + self.end() - def run_streaming( - self, - start: Optional[Union[datetime, str, int]] = None, - end: Optional[Union[datetime, str, int]] = None, - run_config_id: Optional[str] = None, - ): + def end(self): """ - Run a backtest in streaming mode. + Manually end the backtest. - If more data than can fit in memory is to be run through the backtest - engine, then streaming mode can be utilized. The expected sequence is as - follows: - - Add initial data batch and strategies. - - Call `run_streaming()`. - - Call `clear_data()`. - - Add next batch of data stream. - - Call `run_streaming()`. - - Call `end_streaming()` when there is no more data to run on. - - Parameters - ---------- - start : Union[datetime, str, int], optional - The start datetime (UTC) for the current batch of data. If ``None`` - engine runs from the start of the data. - end : Union[datetime, str, int], optional - The end datetime (UTC) for the current batch of data. If ``None`` engine runs - to the end of the data. - run_config_id : str, optional - The tokenized backtest run configuration ID. - - Raises - ------ - ValueError - If no data has been added to the engine. - ValueError - If the `start` is >= the `end` datetime. + Notes + ----- + Only required if you have previously been running with streaming. """ - self._run(start, end, run_config_id) + if self.kernel.trader.is_running: + self.kernel.trader.stop() + if self.kernel.data_engine.is_running: + self.kernel.data_engine.stop() + if self.kernel.risk_engine.is_running: + self.kernel.risk_engine.stop() + if self.kernel.exec_engine.is_running: + self.kernel.exec_engine.stop() + if self.kernel.emulator.is_running: + self.kernel.emulator.stop() - def end_streaming(self): - """ - End the backtest streaming run. + # Process remaining messages + for exchange in self._venues.values(): + exchange.process(self.kernel.clock.timestamp_ns()) - The following sequence of events will occur: - - The trader will be stopped which in turn stops the strategies. - - The exchanges will process all pending messages. - - Post-run analysis is performed. + self._run_finished = self._clock.utc_now() + self._backtest_end = self.kernel.clock.utc_now() - """ - self._end() + self._log_post_run() def get_result(self): """ @@ -1020,27 +989,6 @@ cdef class BacktestEngine: for event_handler in now_events: event_handler.handle() - def _end(self): - if self.kernel.trader.is_running: - self.kernel.trader.stop() - if self.kernel.data_engine.is_running: - self.kernel.data_engine.stop() - if self.kernel.risk_engine.is_running: - self.kernel.risk_engine.stop() - if self.kernel.exec_engine.is_running: - self.kernel.exec_engine.stop() - if self.kernel.emulator.is_running: - self.kernel.emulator.stop() - - # Process remaining messages - for exchange in self._venues.values(): - exchange.process(self.kernel.clock.timestamp_ns()) - - self._run_finished = self._clock.utc_now() - self._backtest_end = self.kernel.clock.utc_now() - - self._log_post_run() - cdef Data _next(self): cdef uint64_t cursor = self._index self._index += 1 diff --git a/nautilus_trader/backtest/node.py b/nautilus_trader/backtest/node.py index 8dd529b27934..33a87ea43803 100644 --- a/nautilus_trader/backtest/node.py +++ b/nautilus_trader/backtest/node.py @@ -167,6 +167,12 @@ def _create_engine( # Add venues (must be added prior to instruments) for config in venue_configs: base_currency: Optional[str] = config.base_currency + if config.leverages: + leverages = { + InstrumentId.from_str(i): Decimal(v) for i, v in config.leverages.items() + } + else: + leverages = {} engine.add_venue( venue=Venue(config.name), oms_type=OmsType[config.oms_type], @@ -174,11 +180,7 @@ def _create_engine( base_currency=Currency.from_str(base_currency) if base_currency else None, starting_balances=[Money.from_str(m) for m in config.starting_balances], default_leverage=Decimal(config.default_leverage), - leverages={ - InstrumentId.from_str(i): Decimal(v) for i, v in config.leverages.items() - } - if config.leverages - else {}, + leverages=leverages, book_type=book_type_from_str(config.book_type), routing=config.routing, modules=[ActorFactory.create(module) for module in (config.modules or [])], @@ -271,9 +273,9 @@ def _run_streaming( GenericData(data_type=DataType(data["type"]), data=d) for d in data["data"] ] self._load_engine_data(engine=engine, data=data) - engine.run_streaming(run_config_id=run_config_id) + engine.run(run_config_id=run_config_id, streaming=True) - engine.end_streaming() + engine.end() engine.dispose() def _run_oneshot( diff --git a/nautilus_trader/persistence/batching.py b/nautilus_trader/persistence/batching.py index efdc63c2f766..c92dd9fd2f5d 100644 --- a/nautilus_trader/persistence/batching.py +++ b/nautilus_trader/persistence/batching.py @@ -49,11 +49,9 @@ def _generate_batches( use_rust: bool = False, n_rows: int = 10_000, ): - use_rust = use_rust and cls in (QuoteTick, TradeTick) files = sorted(files, key=lambda x: Path(x).stem) for file in files: - if use_rust: reader = ParquetReader( file, @@ -63,7 +61,6 @@ def _generate_batches( ) for capsule in reader: - # PyCapsule > List if cls == QuoteTick: objs = QuoteTick.list_from_capsule(capsule) @@ -71,7 +68,6 @@ def _generate_batches( objs = TradeTick.list_from_capsule(capsule) yield objs - else: for batch in pq.ParquetFile(fs.open(file)).iter_batches(batch_size=n_rows): if batch.num_rows == 0: @@ -101,7 +97,6 @@ def generate_batches( end = end_time started = False for batch in batches: - min = batch[0].ts_init max = batch[-1].ts_init if min < start and max < start: @@ -180,7 +175,7 @@ def frame_to_nautilus(df: pd.DataFrame, cls: type): def batch_files( # noqa: C901 catalog: ParquetDataCatalog, data_configs: list[BacktestDataConfig], - read_num_rows: int = 10000, + read_num_rows: int = 10_000, target_batch_size_bytes: int = parse_bytes("100mb"), # noqa: B008, ): files = build_filenames(catalog=catalog, data_configs=data_configs) diff --git a/tests/unit_tests/backtest/test_backtest_node.py b/tests/unit_tests/backtest/test_backtest_node.py index 3741a7c4ff85..76eac97987d0 100644 --- a/tests/unit_tests/backtest/test_backtest_node.py +++ b/tests/unit_tests/backtest/test_backtest_node.py @@ -85,7 +85,7 @@ def test_run(self): # Assert assert len(results) == 1 - def test_backtest_run_streaming_sync(self): + def test_backtest_run_batch_sync(self): # Arrange config = BacktestRunConfig( engine=BacktestEngineConfig(strategies=self.strategies), From 362bab5b24fd9b9d149b4ee51562a538abc9b7a0 Mon Sep 17 00:00:00 2001 From: Chris Sellers Date: Sat, 4 Feb 2023 13:40:47 +1100 Subject: [PATCH 14/81] Cleanup docs --- .pre-commit-config.yaml | 3 +- .../adapters/binance/common/enums.py | 2 +- .../adapters/binance/http/endpoint.py | 2 +- .../adapters/interactive_brokers/config.py | 2 +- .../adapters/interactive_brokers/data.py | 42 ++++++++-------- .../adapters/interactive_brokers/providers.py | 48 +++++++++---------- nautilus_trader/common/generators.pyx | 18 +++---- nautilus_trader/core/data.pyx | 2 +- .../indicators/linear_regression.pyx | 24 ++++------ nautilus_trader/indicators/macd.pyx | 44 ++++++++--------- nautilus_trader/indicators/obv.pyx | 24 ++++------ nautilus_trader/indicators/pressure.pyx | 36 +++++++------- nautilus_trader/indicators/roc.pyx | 28 +++++------ nautilus_trader/indicators/rsi.pyx | 28 +++++------ .../indicators/spread_analyzer.pyx | 30 +++++------- nautilus_trader/indicators/stochastics.pyx | 32 ++++++------- nautilus_trader/indicators/swings.pyx | 14 ++---- nautilus_trader/live/risk_engine.pyx | 26 ---------- nautilus_trader/persistence/external/core.py | 24 ++++------ 19 files changed, 176 insertions(+), 253 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 1bcc60370a5e..924ed463d04e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -140,7 +140,7 @@ repos: files: ^nautilus_trader/ exclude: "nautilus_trader/test_kit" args: - - "--ignore=D100,D102,D103,D104,D107,D105,D200,D203,D205,D212,D400,D413,D415" + - "--ignore=D100,D102,D103,D104,D107,D105,D200,D203,D205,D212,D400,D413,D415,D416" additional_dependencies: - toml @@ -162,3 +162,4 @@ repos: # D400: First line should end with a period (not always a first line) # D413: Missing blank line after last section ('Parameters') # D415: First line should end with a period, question mark, or exclamation point (not always a first line) +# D416: Section name should end with a colon ('Warnings:', not 'Warnings') (incorrect?) diff --git a/nautilus_trader/adapters/binance/common/enums.py b/nautilus_trader/adapters/binance/common/enums.py index 5aa338bff8b2..0440e6268f01 100644 --- a/nautilus_trader/adapters/binance/common/enums.py +++ b/nautilus_trader/adapters/binance/common/enums.py @@ -241,7 +241,7 @@ class BinanceEnumParser: """ Provides common parsing methods for enums used by the 'Binance' exchange. - Warnings: + Warnings -------- This class should not be used directly, but through a concrete subclass. """ diff --git a/nautilus_trader/adapters/binance/http/endpoint.py b/nautilus_trader/adapters/binance/http/endpoint.py index aca31fee7472..10bd625a9eeb 100644 --- a/nautilus_trader/adapters/binance/http/endpoint.py +++ b/nautilus_trader/adapters/binance/http/endpoint.py @@ -37,7 +37,7 @@ class BinanceHttpEndpoint: """ Base functionality of endpoints connecting to the Binance REST API. - Warnings: + Warnings -------- This class should not be used directly, but through a concrete subclass. """ diff --git a/nautilus_trader/adapters/interactive_brokers/config.py b/nautilus_trader/adapters/interactive_brokers/config.py index 057220d13a4f..bdb55a668860 100644 --- a/nautilus_trader/adapters/interactive_brokers/config.py +++ b/nautilus_trader/adapters/interactive_brokers/config.py @@ -35,7 +35,7 @@ class InteractiveBrokersDataClientConfig(LiveDataClientConfig): The Interactive Brokers account id. If ``None`` then will source the `TWS_ACCOUNT`. trading_mode: str - paper or live + paper or live. account_id : str, optional The account_id to use for Nautilus. gateway_host : str, optional diff --git a/nautilus_trader/adapters/interactive_brokers/data.py b/nautilus_trader/adapters/interactive_brokers/data.py index 6d952d5d151f..34140e431e25 100644 --- a/nautilus_trader/adapters/interactive_brokers/data.py +++ b/nautilus_trader/adapters/interactive_brokers/data.py @@ -60,6 +60,25 @@ class InteractiveBrokersDataClient(LiveMarketDataClient): """ Provides a data client for the InteractiveBrokers exchange. + + Parameters + ---------- + loop : asyncio.AbstractEventLoop + The event loop for the client. + client : IB + The ib_insync IB client. + msgbus : MessageBus + The message bus for the client. + cache : Cache + The cache for the client. + clock : LiveClock + The clock for the client. + logger : Logger + The logger for the client. + instrument_provider : InteractiveBrokersInstrumentProvider + The instrument provider. + handle_revised_bars : bool + If DataClient will emit bar updates as soon new bar opens. """ def __init__( @@ -73,29 +92,6 @@ def __init__( instrument_provider: InteractiveBrokersInstrumentProvider, handle_revised_bars: bool, ): - """ - Initialize a new instance of the ``InteractiveBrokersDataClient`` class. - - Parameters - ---------- - loop : asyncio.AbstractEventLoop - The event loop for the client. - client : IB - The ib_insync IB client. - msgbus : MessageBus - The message bus for the client. - cache : Cache - The cache for the client. - clock : LiveClock - The clock for the client. - logger : Logger - The logger for the client. - instrument_provider : InteractiveBrokersInstrumentProvider - The instrument provider. - handle_revised_bars : bool - If DataClient will emit bar updates as soon new bar opens. - - """ super().__init__( loop=loop, client_id=ClientId(IB_VENUE.value), diff --git a/nautilus_trader/adapters/interactive_brokers/providers.py b/nautilus_trader/adapters/interactive_brokers/providers.py index ece0c0c17ca4..4b4db0b47f47 100644 --- a/nautilus_trader/adapters/interactive_brokers/providers.py +++ b/nautilus_trader/adapters/interactive_brokers/providers.py @@ -40,6 +40,21 @@ class InteractiveBrokersInstrumentProvider(InstrumentProvider): """ Provides a means of loading `Instrument` objects through Interactive Brokers. + + Parameters + ---------- + client : ib_insync.IB + The Interactive Brokers client. + config : InstrumentProviderConfig + The instrument provider config + logger : Logger + The logger for the instrument provider. + host : str + The client host name or IP address. + port : str + The client port number. + client_id : int + The unique client ID number for the connection. """ def __init__( @@ -50,26 +65,7 @@ def __init__( host: str = "127.0.0.1", port: int = 7497, client_id: int = 1, - ): - """ - Initialize a new instance of the ``InteractiveBrokersInstrumentProvider`` class. - - Parameters - ---------- - client : ib_insync.IB - The Interactive Brokers client. - config : InstrumentProviderConfig - The instrument provider config - logger : Logger - The logger for the instrument provider. - host : str - The client host name or IP address. - port : str - The client port number. - client_id : int - The unique client ID number for the connection. - - """ + ) -> None: super().__init__( venue=IB_VENUE, logger=logger, @@ -212,15 +208,15 @@ async def load( Parameters ---------- - build_options_chain: bool (default: False) - Search for full option chain - option_kwargs: str (default: False) - JSON string for options filtering, available fields: min_expiry, max_expiry, min_strike, max_strike, kind - kwargs: **kwargs + build_options_chain : bool, default False + Search for full option chain. + option_kwargs : str, default False + JSON string for options filtering, available fields: min_expiry, max_expiry, min_strike, max_strike, kind. + kwargs : **kwargs Optional extra kwargs to search for, examples: secType, conId, symbol, lastTradeDateOrContractMonth, strike, right, multiplier, exchange, primaryExchange, currency, localSymbol, tradingClass, includeExpired, secIdType, secId, - comboLegsDescrip, comboLegs, deltaNeutralContract + comboLegsDescrip, comboLegs, deltaNeutralContract. """ self._log.debug(f"Attempting to find instrument for {kwargs=}") contract = self._parse_contract(**kwargs) diff --git a/nautilus_trader/common/generators.pyx b/nautilus_trader/common/generators.pyx index d6483775756d..34b43d5d7007 100644 --- a/nautilus_trader/common/generators.pyx +++ b/nautilus_trader/common/generators.pyx @@ -26,20 +26,16 @@ from nautilus_trader.model.identifiers cimport TraderId cdef class IdentifierGenerator: """ Provides a generator for unique ID strings. + + Parameters + ---------- + trader_id : TraderId + The ID tag for the trader. + clock : Clock + The internal clock. """ def __init__(self, TraderId trader_id not None, Clock clock not None): - """ - Initialize a new instance of the ``IdentifierGenerator`` class. - - Parameters - ---------- - trader_id : TraderId - The ID tag for the trader. - clock : Clock - The internal clock. - - """ self._clock = clock self._id_tag_trader = trader_id.get_tag() diff --git a/nautilus_trader/core/data.pyx b/nautilus_trader/core/data.pyx index d7783f7da6d3..77ae5e01ecd3 100644 --- a/nautilus_trader/core/data.pyx +++ b/nautilus_trader/core/data.pyx @@ -37,7 +37,7 @@ cdef class Data: This class should not be used directly, but through a concrete subclass. """ - def __init__(self, uint64_t ts_event, uint64_t ts_init): + def __init__(self, uint64_t ts_event, uint64_t ts_init) -> None: # Design-time invariant: correct ordering of timestamps. # This was originally an `assert` to aid initial development of the core # system. It can be used to assist development by uncommenting below. diff --git a/nautilus_trader/indicators/linear_regression.pyx b/nautilus_trader/indicators/linear_regression.pyx index f73f97521e39..d957f898c757 100644 --- a/nautilus_trader/indicators/linear_regression.pyx +++ b/nautilus_trader/indicators/linear_regression.pyx @@ -28,23 +28,19 @@ from nautilus_trader.model.data.bar cimport Bar cdef class LinearRegression(Indicator): """ An indicator that calculates a simple linear regression. - """ - def __init__(self, int period=0): - """ - Initialize a new instance of the ``LinearRegression`` class. + Parameters + ---------- + period : int + The period for the indicator. - Parameters - ---------- - period : int - The period for the indicator. - - Raises - ------ - ValueError - If `period` is not greater than zero. + Raises + ------ + ValueError + If `period` is not greater than zero. + """ - """ + def __init__(self, int period=0): Condition.positive_int(period, "period") super().__init__(params=[period]) diff --git a/nautilus_trader/indicators/macd.pyx b/nautilus_trader/indicators/macd.pyx index 0f873a2be9ae..1ae1e049766b 100644 --- a/nautilus_trader/indicators/macd.pyx +++ b/nautilus_trader/indicators/macd.pyx @@ -29,6 +29,26 @@ cdef class MovingAverageConvergenceDivergence(Indicator): """ An indicator which calculates the difference between two moving averages. Different moving average types can be selected for the inner calculation. + + Parameters + ---------- + fast_period : int + The period for the fast moving average (> 0). + slow_period : int + The period for the slow moving average (> 0 & > fast_sma). + ma_type : MovingAverageType + The moving average type for the calculations. + price_type : PriceType + The specified price type for extracting values from quote ticks. + + Raises + ------ + ValueError + If `fast_period` is not positive (> 0). + ValueError + If `slow_period` is not positive (> 0). + ValueError + If `fast_period` is not < `slow_period`. """ def __init__( @@ -38,30 +58,6 @@ cdef class MovingAverageConvergenceDivergence(Indicator): ma_type not None: MovingAverageType=MovingAverageType.EXPONENTIAL, PriceType price_type=PriceType.LAST, ): - """ - Initialize a new instance of the ``MovingAverageConvergenceDivergence`` class. - - Parameters - ---------- - fast_period : int - The period for the fast moving average (> 0). - slow_period : int - The period for the slow moving average (> 0 & > fast_sma). - ma_type : MovingAverageType - The moving average type for the calculations. - price_type : PriceType - The specified price type for extracting values from quote ticks. - - Raises - ------ - ValueError - If `fast_period` is not positive (> 0). - ValueError - If `slow_period` is not positive (> 0). - ValueError - If `fast_period` is not < `slow_period`. - - """ Condition.positive_int(fast_period, "fast_period") Condition.positive_int(slow_period, "slow_period") Condition.true(slow_period > fast_period, "slow_period was <= fast_period") diff --git a/nautilus_trader/indicators/obv.pyx b/nautilus_trader/indicators/obv.pyx index dc5e71a056dc..7b0276dcc9c6 100644 --- a/nautilus_trader/indicators/obv.pyx +++ b/nautilus_trader/indicators/obv.pyx @@ -24,23 +24,19 @@ cdef class OnBalanceVolume(Indicator): """ An indicator which calculates the momentum of relative positive or negative volume. - """ - def __init__(self, int period=0): - """ - Initialize a new instance of the ``OnBalanceVolume`` class. + Parameters + ---------- + period : int + The period for the indicator, zero indicates no window (>= 0). - Parameters - ---------- - period : int - The period for the indicator, zero indicates no window (>= 0). - - Raises - ------ - ValueError - If `period` is negative (< 0). + Raises + ------ + ValueError + If `period` is negative (< 0). + """ - """ + def __init__(self, int period=0): Condition.not_negative(period, "period") super().__init__(params=[period]) diff --git a/nautilus_trader/indicators/pressure.pyx b/nautilus_trader/indicators/pressure.pyx index 90150d735b30..2cb9017bf493 100644 --- a/nautilus_trader/indicators/pressure.pyx +++ b/nautilus_trader/indicators/pressure.pyx @@ -26,6 +26,22 @@ cdef class Pressure(Indicator): """ An indicator which calculates the relative volume (multiple of average volume) to move the market across a relative range (multiple of ATR). + + Parameters + ---------- + period : int + The period for the indicator (> 0). + ma_type : MovingAverageType + The moving average type for the calculations. + atr_floor : double + The ATR floor (minimum) output value for the indicator (>= 0.). + + Raises + ------ + ValueError + If `period` is not positive (> 0). + ValueError + If `atr_floor` is negative (< 0). """ def __init__( @@ -34,26 +50,6 @@ cdef class Pressure(Indicator): ma_type not None: MovingAverageType=MovingAverageType.EXPONENTIAL, double atr_floor=0, ): - """ - Initialize a new instance of the ``Pressure`` class. - - Parameters - ---------- - period : int - The period for the indicator (> 0). - ma_type : MovingAverageType - The moving average type for the calculations. - atr_floor : double - The ATR floor (minimum) output value for the indicator (>= 0.). - - Raises - ------ - ValueError - If `period` is not positive (> 0). - ValueError - If `atr_floor` is negative (< 0). - - """ Condition.positive_int(period, "period") Condition.not_negative(atr_floor, "atr_floor") diff --git a/nautilus_trader/indicators/roc.pyx b/nautilus_trader/indicators/roc.pyx index 3153ea12c9b7..7fed366d2c0a 100644 --- a/nautilus_trader/indicators/roc.pyx +++ b/nautilus_trader/indicators/roc.pyx @@ -25,25 +25,21 @@ cdef class RateOfChange(Indicator): """ An indicator which calculates the rate of change of price over a defined period. The return output can be simple or log. + + Parameters + ---------- + period : int + The period for the indicator. + use_log : bool + Use log returns for value calculation. + + Raises + ------ + ValueError + If `period` is not > 1. """ def __init__(self, int period, bint use_log=False): - """ - Initialize a new instance of the ``RateOfChange`` class. - - Parameters - ---------- - period : int - The period for the indicator. - use_log : bool - Use log returns for value calculation. - - Raises - ------ - ValueError - If `period` is not > 1. - - """ Condition.true(period > 1, "period was <= 1") super().__init__(params=[period]) diff --git a/nautilus_trader/indicators/rsi.pyx b/nautilus_trader/indicators/rsi.pyx index 8411b9833831..6e16a180142c 100644 --- a/nautilus_trader/indicators/rsi.pyx +++ b/nautilus_trader/indicators/rsi.pyx @@ -24,6 +24,18 @@ from nautilus_trader.model.data.bar cimport Bar cdef class RelativeStrengthIndex(Indicator): """ An indicator which calculates a relative strength index (RSI) across a rolling window. + + Parameters + ---------- + ma_type : int + The moving average type for average gain/loss. + period : MovingAverageType + The rolling window period for the indicator. + + Raises + ------ + ValueError + If `period` is not positive (> 0). """ def __init__( @@ -31,22 +43,6 @@ cdef class RelativeStrengthIndex(Indicator): int period, ma_type not None: MovingAverageType=MovingAverageType.EXPONENTIAL, ): - """ - Initialize a new instance of the ``RelativeStrengthIndex`` class. - - Parameters - ---------- - ma_type : int - The moving average type for average gain/loss. - period : MovingAverageType - The rolling window period for the indicator. - - Raises - ------ - ValueError - If `period` is not positive (> 0). - - """ Condition.positive_int(period, "period") super().__init__(params=[period, ma_type.name]) diff --git a/nautilus_trader/indicators/spread_analyzer.pyx b/nautilus_trader/indicators/spread_analyzer.pyx index 4afa3928b5c8..f7021038799d 100644 --- a/nautilus_trader/indicators/spread_analyzer.pyx +++ b/nautilus_trader/indicators/spread_analyzer.pyx @@ -28,25 +28,21 @@ from nautilus_trader.model.objects cimport Price cdef class SpreadAnalyzer(Indicator): """ Provides various spread analysis metrics. - """ - - def __init__(self, InstrumentId instrument_id not None, int capacity): - """ - Initialize a new instance of the ``SpreadAnalyzer`` class. - - Parameters - ---------- - instrument_id : InstrumentId - The instrument ID for the tick updates. - capacity : int - The max length for the internal `QuoteTick` deque (determines averages). - Raises - ------ - ValueError - If `capacity` is not positive (> 0). + Parameters + ---------- + instrument_id : InstrumentId + The instrument ID for the tick updates. + capacity : int + The max length for the internal `QuoteTick` deque (determines averages). + + Raises + ------ + ValueError + If `capacity` is not positive (> 0). + """ - """ + def __init__(self, InstrumentId instrument_id not None, int capacity) -> None: Condition.positive_int(capacity, "capacity") super().__init__(params=[instrument_id, capacity]) diff --git a/nautilus_trader/indicators/stochastics.pyx b/nautilus_trader/indicators/stochastics.pyx index 1de7823cc6a5..c06892013d9a 100644 --- a/nautilus_trader/indicators/stochastics.pyx +++ b/nautilus_trader/indicators/stochastics.pyx @@ -25,30 +25,26 @@ cdef class Stochastics(Indicator): An oscillator which can indicate when an asset may be over bought or over sold. + Parameters + ---------- + period_k : int + The period for the K line. + period_d : int + The period for the D line. + + Raises + ------ + ValueError + If `period_k` is not positive (> 0). + ValueError + If `period_d` is not positive (> 0). + References ---------- https://www.forextraders.com/forex-education/forex-indicators/stochastics-indicator-explained/ """ def __init__(self, int period_k, int period_d): - """ - Initialize a new instance of the ``Stochastics`` class. - - Parameters - ---------- - period_k : int - The period for the K line. - period_d : int - The period for the D line. - - Raises - ------ - ValueError - If `period_k` is not positive (> 0). - ValueError - If `period_d` is not positive (> 0). - - """ Condition.positive_int(period_k, "period_k") Condition.positive_int(period_d, "period_d") super().__init__(params=[period_k, period_d]) diff --git a/nautilus_trader/indicators/swings.pyx b/nautilus_trader/indicators/swings.pyx index de7e63f2c2e6..91040a122848 100644 --- a/nautilus_trader/indicators/swings.pyx +++ b/nautilus_trader/indicators/swings.pyx @@ -26,18 +26,14 @@ from nautilus_trader.model.data.bar cimport Bar cdef class Swings(Indicator): """ A swing indicator which calculates and stores various swing metrics. + + Parameters + ---------- + period : int + The rolling window period for the indicator (> 0). """ def __init__(self, int period): - """ - Initialize a new instance of the Swings class. - - Parameters - ---------- - period : int - The rolling window period for the indicator (> 0). - - """ Condition.positive_int(period, "period") super().__init__(params=[period]) diff --git a/nautilus_trader/live/risk_engine.pyx b/nautilus_trader/live/risk_engine.pyx index 9b85e0ab2a77..c83765505ee4 100644 --- a/nautilus_trader/live/risk_engine.pyx +++ b/nautilus_trader/live/risk_engine.pyx @@ -67,32 +67,6 @@ cdef class LiveRiskEngine(RiskEngine): Logger logger not None, config: Optional[LiveRiskEngineConfig] = None, ): - """ - Initialize a new instance of the ``LiveRiskEngine`` class. - - Parameters - ---------- - loop : asyncio.AbstractEventLoop - The event loop for the engine. - portfolio : PortfolioFacade - The portfolio for the engine. - msgbus : MessageBus - The message bus for the engine. - cache : CacheFacade - The read-only cache for the engine. - clock : Clock - The clock for the engine. - logger : Logger - The logger for the engine. - config : LiveRiskEngineConfig - The configuration for the instance. - - Raises - ------ - TypeError - If `config` is not of type `LiveRiskEngineConfig`. - - """ if config is None: config = LiveRiskEngineConfig() Condition.type(config, LiveRiskEngineConfig, "config") diff --git a/nautilus_trader/persistence/external/core.py b/nautilus_trader/persistence/external/core.py index dd90952db02f..2bda98e82533 100644 --- a/nautilus_trader/persistence/external/core.py +++ b/nautilus_trader/persistence/external/core.py @@ -57,7 +57,16 @@ class RawFile: """ - Provides a wrapper of fsspec.OpenFile that processes a raw file and writes to parquet. + Provides a wrapper of `fsspec.OpenFile` that processes a raw file and writes to parquet. + + Parameters + ---------- + open_file : fsspec.core.OpenFile + The fsspec.OpenFile source of this data. + block_size: int + The max block (chunk) size in bytes to read from the file. + progress: bool, default False + If a progress bar should be shown when processing this individual file. """ def __init__( @@ -66,19 +75,6 @@ def __init__( block_size: Optional[int] = None, progress: bool = False, ): - """ - Initialize a new instance of the ``RawFile`` class. - - Parameters - ---------- - open_file : fsspec.core.OpenFile - The fsspec.OpenFile source of this data. - block_size: int - The max block (chunk) size in bytes to read from the file. - progress: bool, default False - If a progress bar should be shown when processing this individual file. - - """ self.open_file = open_file self.block_size = block_size # TODO - waiting for tqdm support in fsspec https://github.com/intake/filesystem_spec/pulls?q=callback From be39c9adb1779a5ad81ac6d8db09fe2f2802bcd9 Mon Sep 17 00:00:00 2001 From: Chris Sellers Date: Sat, 4 Feb 2023 14:22:07 +1100 Subject: [PATCH 15/81] Upgrade black --- .pre-commit-config.yaml | 2 +- poetry.lock | 44 +++++++++++++++++++++++++++-------------- pyproject.toml | 2 +- 3 files changed, 31 insertions(+), 17 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 924ed463d04e..5b5fa26e2338 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -97,7 +97,7 @@ repos: args: ["--settings-file", "pyproject.toml", "."] - repo: https://github.com/psf/black - rev: 22.12.0 + rev: 23.1.0 hooks: - id: black types_or: [python, pyi] diff --git a/poetry.lock b/poetry.lock index 04e497080f37..8f08d695acc8 100644 --- a/poetry.lock +++ b/poetry.lock @@ -234,32 +234,46 @@ msgspec = ">=0.11" [[package]] name = "black" -version = "22.12.0" +version = "23.1.0" description = "The uncompromising code formatter." category = "dev" optional = false python-versions = ">=3.7" files = [ - {file = "black-22.12.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9eedd20838bd5d75b80c9f5487dbcb06836a43833a37846cf1d8c1cc01cef59d"}, - {file = "black-22.12.0-cp310-cp310-win_amd64.whl", hash = "sha256:159a46a4947f73387b4d83e87ea006dbb2337eab6c879620a3ba52699b1f4351"}, - {file = "black-22.12.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d30b212bffeb1e252b31dd269dfae69dd17e06d92b87ad26e23890f3efea366f"}, - {file = "black-22.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:7412e75863aa5c5411886804678b7d083c7c28421210180d67dfd8cf1221e1f4"}, - {file = "black-22.12.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c116eed0efb9ff870ded8b62fe9f28dd61ef6e9ddd28d83d7d264a38417dcee2"}, - {file = "black-22.12.0-cp37-cp37m-win_amd64.whl", hash = "sha256:1f58cbe16dfe8c12b7434e50ff889fa479072096d79f0a7f25e4ab8e94cd8350"}, - {file = "black-22.12.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:77d86c9f3db9b1bf6761244bc0b3572a546f5fe37917a044e02f3166d5aafa7d"}, - {file = "black-22.12.0-cp38-cp38-win_amd64.whl", hash = "sha256:82d9fe8fee3401e02e79767016b4907820a7dc28d70d137eb397b92ef3cc5bfc"}, - {file = "black-22.12.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:101c69b23df9b44247bd88e1d7e90154336ac4992502d4197bdac35dd7ee3320"}, - {file = "black-22.12.0-cp39-cp39-win_amd64.whl", hash = "sha256:559c7a1ba9a006226f09e4916060982fd27334ae1998e7a38b3f33a37f7a2148"}, - {file = "black-22.12.0-py3-none-any.whl", hash = "sha256:436cc9167dd28040ad90d3b404aec22cedf24a6e4d7de221bec2730ec0c97bcf"}, - {file = "black-22.12.0.tar.gz", hash = "sha256:229351e5a18ca30f447bf724d007f890f97e13af070bb6ad4c0a441cd7596a2f"}, + {file = "black-23.1.0-cp310-cp310-macosx_10_16_arm64.whl", hash = "sha256:b6a92a41ee34b883b359998f0c8e6eb8e99803aa8bf3123bf2b2e6fec505a221"}, + {file = "black-23.1.0-cp310-cp310-macosx_10_16_universal2.whl", hash = "sha256:57c18c5165c1dbe291d5306e53fb3988122890e57bd9b3dcb75f967f13411a26"}, + {file = "black-23.1.0-cp310-cp310-macosx_10_16_x86_64.whl", hash = "sha256:9880d7d419bb7e709b37e28deb5e68a49227713b623c72b2b931028ea65f619b"}, + {file = "black-23.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e6663f91b6feca5d06f2ccd49a10f254f9298cc1f7f49c46e498a0771b507104"}, + {file = "black-23.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:9afd3f493666a0cd8f8df9a0200c6359ac53940cbde049dcb1a7eb6ee2dd7074"}, + {file = "black-23.1.0-cp311-cp311-macosx_10_16_arm64.whl", hash = "sha256:bfffba28dc52a58f04492181392ee380e95262af14ee01d4bc7bb1b1c6ca8d27"}, + {file = "black-23.1.0-cp311-cp311-macosx_10_16_universal2.whl", hash = "sha256:c1c476bc7b7d021321e7d93dc2cbd78ce103b84d5a4cf97ed535fbc0d6660648"}, + {file = "black-23.1.0-cp311-cp311-macosx_10_16_x86_64.whl", hash = "sha256:382998821f58e5c8238d3166c492139573325287820963d2f7de4d518bd76958"}, + {file = "black-23.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bf649fda611c8550ca9d7592b69f0637218c2369b7744694c5e4902873b2f3a"}, + {file = "black-23.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:121ca7f10b4a01fd99951234abdbd97728e1240be89fde18480ffac16503d481"}, + {file = "black-23.1.0-cp37-cp37m-macosx_10_16_x86_64.whl", hash = "sha256:a8471939da5e824b891b25751955be52ee7f8a30a916d570a5ba8e0f2eb2ecad"}, + {file = "black-23.1.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8178318cb74f98bc571eef19068f6ab5613b3e59d4f47771582f04e175570ed8"}, + {file = "black-23.1.0-cp37-cp37m-win_amd64.whl", hash = "sha256:a436e7881d33acaf2536c46a454bb964a50eff59b21b51c6ccf5a40601fbef24"}, + {file = "black-23.1.0-cp38-cp38-macosx_10_16_arm64.whl", hash = "sha256:a59db0a2094d2259c554676403fa2fac3473ccf1354c1c63eccf7ae65aac8ab6"}, + {file = "black-23.1.0-cp38-cp38-macosx_10_16_universal2.whl", hash = "sha256:0052dba51dec07ed029ed61b18183942043e00008ec65d5028814afaab9a22fd"}, + {file = "black-23.1.0-cp38-cp38-macosx_10_16_x86_64.whl", hash = "sha256:49f7b39e30f326a34b5c9a4213213a6b221d7ae9d58ec70df1c4a307cf2a1580"}, + {file = "black-23.1.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:162e37d49e93bd6eb6f1afc3e17a3d23a823042530c37c3c42eeeaf026f38468"}, + {file = "black-23.1.0-cp38-cp38-win_amd64.whl", hash = "sha256:8b70eb40a78dfac24842458476135f9b99ab952dd3f2dab738c1881a9b38b753"}, + {file = "black-23.1.0-cp39-cp39-macosx_10_16_arm64.whl", hash = "sha256:a29650759a6a0944e7cca036674655c2f0f63806ddecc45ed40b7b8aa314b651"}, + {file = "black-23.1.0-cp39-cp39-macosx_10_16_universal2.whl", hash = "sha256:bb460c8561c8c1bec7824ecbc3ce085eb50005883a6203dcfb0122e95797ee06"}, + {file = "black-23.1.0-cp39-cp39-macosx_10_16_x86_64.whl", hash = "sha256:c91dfc2c2a4e50df0026f88d2215e166616e0c80e86004d0003ece0488db2739"}, + {file = "black-23.1.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2a951cc83ab535d248c89f300eccbd625e80ab880fbcfb5ac8afb5f01a258ac9"}, + {file = "black-23.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:0680d4380db3719ebcfb2613f34e86c8e6d15ffeabcf8ec59355c5e7b85bb555"}, + {file = "black-23.1.0-py3-none-any.whl", hash = "sha256:7a0f701d314cfa0896b9001df70a530eb2472babb76086344e688829efd97d32"}, + {file = "black-23.1.0.tar.gz", hash = "sha256:b0bd97bea8903f5a2ba7219257a44e3f1f9d00073d6cc1add68f0beec69692ac"}, ] [package.dependencies] click = ">=8.0.0" mypy-extensions = ">=0.4.3" +packaging = ">=22.0" pathspec = ">=0.9.0" platformdirs = ">=2" -tomli = {version = ">=1.1.0", markers = "python_full_version < \"3.11.0a7\""} +tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} typing-extensions = {version = ">=3.10.0.0", markers = "python_version < \"3.10\""} [package.extras] @@ -2942,4 +2956,4 @@ redis = ["hiredis", "redis"] [metadata] lock-version = "2.0" python-versions = ">=3.9,<3.12" -content-hash = "23b831064255a4e8ddfa7c9e41a3d0a1da8c73e186cd2624370fa2ad91bc1960" +content-hash = "b6ac21bc828534d5836b9f67adf47e93a4c55dd8ba817dab9b966df8779fe9be" diff --git a/pyproject.toml b/pyproject.toml index 01f3a3363aaa..944c467ec185 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -79,7 +79,7 @@ redis = ["hiredis", "redis"] optional = true [tool.poetry.group.dev.dependencies] -black = "^22.12.0" +black = "^23.1.0" flake8 = "^6.0.0" isort = "^5.12.0" mypy = "^0.991" From 2d4c97fe0217ac267a07448068e953a208292892 Mon Sep 17 00:00:00 2001 From: Chris Sellers Date: Sat, 4 Feb 2023 14:22:56 +1100 Subject: [PATCH 16/81] Update docs --- README.md | 2 +- docs/getting_started/quick_start.md | 17 +++++++++-------- docs/index.md | 2 +- docs/user_guide/advanced/data.md | 2 +- docs/user_guide/advanced/emulated_orders.md | 17 ++++++++--------- docs/user_guide/architecture.md | 6 +++--- docs/user_guide/core_concepts.md | 2 +- docs/user_guide/strategies.md | 6 +++--- examples/indicators/ema_python.py | 2 +- .../adapters/betfair/parsing/streaming.py | 1 - .../adapters/binance/common/types.py | 1 - .../adapters/interactive_brokers/data.py | 2 -- nautilus_trader/core/asynchronous.py | 2 +- nautilus_trader/data/client.pyx | 4 ++-- nautilus_trader/execution/client.pyx | 2 +- nautilus_trader/persistence/catalog/parquet.py | 3 --- nautilus_trader/persistence/external/core.py | 1 - tests/performance_tests/test_perf_catalog.py | 3 --- .../model/test_model_objects_money.py | 1 - .../persistence/external/test_core.py | 2 -- tests/unit_tests/persistence/test_batching.py | 3 --- tests/unit_tests/persistence/test_catalog.py | 1 - tests/unit_tests/persistence/test_streaming.py | 2 -- 23 files changed, 32 insertions(+), 52 deletions(-) diff --git a/README.md b/README.md index 690fd5e4b74b..fa3d97916e5f 100644 --- a/README.md +++ b/README.md @@ -97,7 +97,7 @@ optional C-inspired syntax. The project heavily utilizes Cython to provide static type safety and increased performance for Python through [C extension modules](https://docs.python.org/3/extending/extending.html). The vast majority of the production code is actually -written in Cython, however the libraries can be accessed from both pure Python and Cython. +written in Cython, however the libraries can be accessed from both Python and Cython. ## What is Rust? diff --git a/docs/getting_started/quick_start.md b/docs/getting_started/quick_start.md index 9d44bbbdaae6..010d141a2891 100644 --- a/docs/getting_started/quick_start.md +++ b/docs/getting_started/quick_start.md @@ -65,6 +65,7 @@ registering indicators to receive certain data types, however in this example we ```python from typing import Optional +from nautilus_trader.core.message import Event from nautilus_trader.trading.strategy import Strategy, StrategyConfig from nautilus_trader.indicators.macd import MovingAverageConvergenceDivergence from nautilus_trader.model.data.tick import QuoteTick @@ -85,7 +86,7 @@ class MACDConfig(StrategyConfig): class MACDStrategy(Strategy): - def __init__(self, config: MACDConfig): + def __init__(self, config: MACDConfig) -> None: super().__init__(config=config) # Our "trading signal" self.macd = MovingAverageConvergenceDivergence( @@ -99,13 +100,13 @@ class MACDStrategy(Strategy): # Convenience self.position: Optional[Position] = None - def on_start(self): + def on_start(self) -> None: self.subscribe_quote_ticks(instrument_id=self.instrument_id) - def on_stop(self): + def on_stop(self) -> None: self.unsubscribe_quote_ticks(instrument_id=self.instrument_id) - def on_quote_tick(self, tick: QuoteTick): + def on_quote_tick(self, tick: QuoteTick) -> None: # Update our MACD self.macd.handle_quote_tick(tick) if self.macd.value: @@ -115,11 +116,11 @@ class MACDStrategy(Strategy): if self.position: assert self.position.quantity <= 1000 - def on_event(self, event): + def on_event(self, event: Event) -> None: if isinstance(event, PositionEvent): self.position = self.cache.position(event.position_id) - def check_for_entry(self): + def check_for_entry(self) -> None: if self.cache.positions(): # If we have a position, do not enter again return @@ -136,7 +137,7 @@ class MACDStrategy(Strategy): ) self.submit_order(order) - def check_for_exit(self): + def check_for_exit(self) -> None: if not self.cache.positions(): # If we don't have a position, return early return @@ -154,7 +155,7 @@ class MACDStrategy(Strategy): ) self.submit_order(order) - def on_dispose(self): + def on_dispose(self) -> None: pass # Do nothing else ``` diff --git a/docs/index.md b/docs/index.md index d412ab9c6f0f..73e937cb2d77 100644 --- a/docs/index.md +++ b/docs/index.md @@ -73,7 +73,7 @@ optional additional C-inspired syntax. The project heavily utilizes Cython to provide static type safety and increased performance for Python through [C extension modules](https://docs.python.org/3/extending/extending.html). The vast majority of the production code is actually -written in Cython, however the libraries can be accessed from both pure Python and Cython. +written in Cython, however the libraries can be accessed from both Python and Cython. ## What is Rust? diff --git a/docs/user_guide/advanced/data.md b/docs/user_guide/advanced/data.md index 70cb220c8f9a..63cb483117c5 100644 --- a/docs/user_guide/advanced/data.md +++ b/docs/user_guide/advanced/data.md @@ -57,7 +57,7 @@ objects to your `on_data` method. You will need to check the type, as this method acts as a flexible handler for all custom/generic data. ```python -def on_data(self, data: Data): +def on_data(self, data: Data) -> None: # First check the type of data if isinstance(data, MyDataPoint): # Do something with the data diff --git a/docs/user_guide/advanced/emulated_orders.md b/docs/user_guide/advanced/emulated_orders.md index ab12632dcf14..020641b67925 100644 --- a/docs/user_guide/advanced/emulated_orders.md +++ b/docs/user_guide/advanced/emulated_orders.md @@ -5,10 +5,9 @@ of whether the type is supported on a trading venue. The logic and code paths fo order emulation are exactly the same for all environment contexts (backtest, sandbox, live), and utilize a common `OrderEmulator` component. -## Limitations +```{note} There is no limitation on the number of emulated orders you can have per running instance. -Currently only individual orders can be emulated, so it is not possible to submit contingency order lists -for emulation (this may be supported in a future version). +``` ## Submitting for emulation The only requirement to emulate an order is to pass a `TriggerType` to the `emulation_trigger` @@ -29,18 +28,18 @@ An emulated order will retain its original client order ID throughout its entire ## Life cycle An emulated order will progress through the following stages: - Submitted by a `Strategy` through the `submit_order` method -- Then sent to the `RiskEngine` for pre-trade risk checks (if may be denied at this point) +- Then sent to the `RiskEngine` for pre-trade risk checks (it may be denied at this point) - Then sent to the `OrderEmulator` where it is _held_ / emulated ### Held emulated orders -The following will occur for an emulated order now inside the `OrderEmulator` component: +The following will occur for an emulated order now _held_ by the `OrderEmulator` component: - The original `SubmitOrder` command will be cached -- The emulated order will be held inside a local `MatchingCore` component +- The emulated order will be processed inside a local `MatchingCore` component - The `OrderEmulator` will subscribe to any needed market data (if not already) to update the matching core -- The emulated order will be modified (by the trader) and updated (by the market) until _released_ or canceled +- The emulated order can be modified (by the trader) and updated (by the market) until _released_ or canceled ### Released emulated orders -Once an emulated order is triggered / matched locally based on a data feed, the following +Once an emulated order is triggered / matched locally based on the arrival of data, the following _release_ actions will occur: - The order will be transformed to either a `MARKET` or `LIMIT` order (see below table) through an additional `OrderInitialized` event - The orders `emulation_trigger` will be set to `NONE` (it will no longer be treated as an emulated order by any component) @@ -73,7 +72,7 @@ It's possible to query for emulated orders through the following `Cache` methods See the full [API reference](../../api_reference/cache) for additional details. -You can also query order objects directly in pure Python: +You can also query order objects directly in Python: - `order.is_emulated` Or through the C API if in Cython: diff --git a/docs/user_guide/architecture.md b/docs/user_guide/architecture.md index bc6b1b09e18e..f8c254d946e6 100644 --- a/docs/user_guide/architecture.md +++ b/docs/user_guide/architecture.md @@ -33,7 +33,7 @@ when making design and architectural decisions, roughly in order of 'weighting'. ## System architecture The NautilusTrader codebase is actually both a framework for composing trading -systems, and a set of default system applications which can operate in various +systems, and a set of default system implementations which can operate in various environment contexts. ### Environment contexts @@ -44,7 +44,7 @@ environment contexts. ### Common core The platform has been designed to share as much common code between backtest, sandbox and live trading systems as possible. This is formalized in the `system` subpackage, where you will find the `NautilusKernel` class, -providing a common core system kernel. +providing a common core system 'kernel'. A _ports and adapters_ architectural style allows modular components to be 'plugged into' the core system, providing many hooks for user defined / custom component implementations. @@ -100,7 +100,7 @@ for each of these subpackages from the left nav menu. ## Code structure The foundation of the codebase is the `nautilus_core` directory, containing a collection of core Rust libraries including a C API interface generated by `cbindgen`. -The bulk of the production code resides in the `nautilus_trader` directory, which contains a collection of pure Python and Cython modules. +The bulk of the production code resides in the `nautilus_trader` directory, which contains a collection of Python and Cython modules. Python bindings for the Rust core are achieved by statically linking the Rust libraries to the C extension modules generated by Cython at compile time (effectively extending the CPython API). diff --git a/docs/user_guide/core_concepts.md b/docs/user_guide/core_concepts.md index 2a6bbb907df8..891a4208b383 100644 --- a/docs/user_guide/core_concepts.md +++ b/docs/user_guide/core_concepts.md @@ -4,7 +4,7 @@ There are three main use cases for this software package: - Backtesting trading systems with historical data (`backtest`) - Testing trading systems with real-time data and simulated execution (`sandbox`) -- Deploying trading systems with real-time data and executing on venues with real accounts (`live`) +- Deploying trading systems with real-time data and executing on venues with real (or paper) accounts (`live`) The projects codebase provides a framework for implementing the software layer of systems which achieve the above. You will find the default `backtest` and `live` system implementations in their respectively named subpackages. A `sandbox` environment can diff --git a/docs/user_guide/strategies.md b/docs/user_guide/strategies.md index 8e06c1d01980..b7b540ebc973 100644 --- a/docs/user_guide/strategies.md +++ b/docs/user_guide/strategies.md @@ -4,7 +4,7 @@ The heart of the NautilusTrader user experience is in writing and working with trading strategies. Defining a trading strategy is achieved by inheriting the `Strategy` class, and implementing the methods required by the strategy. -Using the basic building blocks of data ingest and order management (which we will discuss +Using the basic building blocks of data ingest, event handling, and order management (which we will discuss below), it's possible to implement any type of trading strategy including directional, momentum, re-balancing, pairs, market making etc. @@ -25,7 +25,7 @@ a constructor where you can handle initialization. Minimally the base/super clas ```python class MyStrategy(Strategy): - def __init__(self): + def __init__(self) -> None: super().__init__() # <-- the super class must be called to initialize the strategy ``` @@ -61,7 +61,7 @@ class MyStrategyConfig(StrategyConfig): # parameterize the instrument the strategy will trade. class MyStrategy(Strategy): - def __init__(self, config: MyStrategyConfig): + def __init__(self, config: MyStrategyConfig) -> None: super().__init__(config) # Configuration diff --git a/examples/indicators/ema_python.py b/examples/indicators/ema_python.py index 809e8a8a932c..da85adf8461f 100644 --- a/examples/indicators/ema_python.py +++ b/examples/indicators/ema_python.py @@ -23,7 +23,7 @@ # It's generally recommended to code indicators in Cython as per the built-in # indicators found in the `indicators` subpackage. However this is an example -# demonstrating an equivalent EMA indicator written in pure Python. +# demonstrating an equivalent EMA indicator written in Python. # Note: The `MovingAverage` base class has not being used in this example to # provide more clarity on how to implement custom indicators. Basically you need diff --git a/nautilus_trader/adapters/betfair/parsing/streaming.py b/nautilus_trader/adapters/betfair/parsing/streaming.py index 1356b8cb3e0b..5e11768ef2f1 100644 --- a/nautilus_trader/adapters/betfair/parsing/streaming.py +++ b/nautilus_trader/adapters/betfair/parsing/streaming.py @@ -340,7 +340,6 @@ def runner_change_all_depth_to_order_book_snapshot( ts_event: int, ts_init: int, ) -> Optional[OrderBookSnapshot]: - # Bids are available to lay (atl) if rc.atl: bids = [ diff --git a/nautilus_trader/adapters/binance/common/types.py b/nautilus_trader/adapters/binance/common/types.py index 0ab8d81eaed8..09cde0bcb6d0 100644 --- a/nautilus_trader/adapters/binance/common/types.py +++ b/nautilus_trader/adapters/binance/common/types.py @@ -108,7 +108,6 @@ def __getstate__(self): ) def __setstate__(self, state): - super().__setstate__(state[:14]) self.quote_volume = Decimal(state[14]) self.count = state[15] diff --git a/nautilus_trader/adapters/interactive_brokers/data.py b/nautilus_trader/adapters/interactive_brokers/data.py index 34140e431e25..ed09627fbf82 100644 --- a/nautilus_trader/adapters/interactive_brokers/data.py +++ b/nautilus_trader/adapters/interactive_brokers/data.py @@ -425,7 +425,6 @@ def _on_bar_update( has_new_bar: bool, bar_type: BarType, ): - if not has_new_bar: return @@ -454,7 +453,6 @@ def _on_historical_bar_update( has_new_bar: bool, process_all: bool = False, ) -> None: - if not process_all: if self._handle_revised_bars: bars = [bar_data_list[-1]] diff --git a/nautilus_trader/core/asynchronous.py b/nautilus_trader/core/asynchronous.py index d97bc0b7d467..7ebc4ab5c659 100644 --- a/nautilus_trader/core/asynchronous.py +++ b/nautilus_trader/core/asynchronous.py @@ -22,7 +22,7 @@ def sleep0(): Skip one event loop run cycle. This is equivalent to `asyncio.sleep(0)` however avoids the overhead - of the pure Python function call and integer comparison <= 0. + of the Python function call and integer comparison <= 0. Uses a bare 'yield' expression (which Task.__step knows how to handle) instead of creating a Future object. diff --git a/nautilus_trader/data/client.pyx b/nautilus_trader/data/client.pyx index 9438c513a794..ed3c4d1acd9f 100644 --- a/nautilus_trader/data/client.pyx +++ b/nautilus_trader/data/client.pyx @@ -94,7 +94,7 @@ cdef class DataClient(Component): cpdef void _set_connected(self, bint value=True) except *: """ - Setter for pure Python implementations to change the readonly property. + Setter for Python implementations to change the readonly property. Parameters ---------- @@ -1008,7 +1008,7 @@ cdef class MarketDataClient(DataClient): # -- PYTHON WRAPPERS ------------------------------------------------------------------------------ - # Convenient pure Python wrappers for the data handlers. Often Python methods + # Convenient Python wrappers for the data handlers. Often Python methods # involving threads or the event loop don't work with `cpdef` methods. def _handle_data_py(self, Data data): diff --git a/nautilus_trader/execution/client.pyx b/nautilus_trader/execution/client.pyx index 877e2a81bd4c..a427d0015e24 100644 --- a/nautilus_trader/execution/client.pyx +++ b/nautilus_trader/execution/client.pyx @@ -142,7 +142,7 @@ cdef class ExecutionClient(Component): return f"{type(self).__name__}-{self.id.value}" cpdef void _set_connected(self, bint value=True) except *: - # Setter for pure Python implementations to change the readonly property + # Setter for Python implementations to change the readonly property self.is_connected = value cpdef void _set_account_id(self, AccountId account_id) except *: diff --git a/nautilus_trader/persistence/catalog/parquet.py b/nautilus_trader/persistence/catalog/parquet.py index 0246b60a7807..cf26f7987542 100644 --- a/nautilus_trader/persistence/catalog/parquet.py +++ b/nautilus_trader/persistence/catalog/parquet.py @@ -109,7 +109,6 @@ def from_uri(cls, uri): # -- QUERIES ----------------------------------------------------------------------------------- def query(self, cls, filter_expr=None, instrument_ids=None, as_nautilus=False, **kwargs): - if not is_nautilus_class(cls): # Special handling for generic data return self.generic_data( @@ -177,7 +176,6 @@ def _query( # noqa (too complex) # Load rust objects use_rust = kwargs.get("use_rust") and cls in (QuoteTick, TradeTick) if use_rust and kwargs.get("as_nautilus"): - # start_nanos = int(pd.Timestamp(end).to_datetime64()) if start else None # end_nanos = int(pd.Timestamp(end).to_datetime64()) if end else None from nautilus_trader.persistence.batching import ( @@ -261,7 +259,6 @@ def _get_files( start_nanos: Optional[int] = None, end_nanos: Optional[int] = None, ) -> list[str]: - if instrument_id is None: folder = self.path else: diff --git a/nautilus_trader/persistence/external/core.py b/nautilus_trader/persistence/external/core.py index 2bda98e82533..75d6c5986856 100644 --- a/nautilus_trader/persistence/external/core.py +++ b/nautilus_trader/persistence/external/core.py @@ -283,7 +283,6 @@ def write_tables( def write_parquet_rust(catalog: ParquetDataCatalog, objs: list, instrument: Instrument): - cls = type(objs[0]) assert cls in (QuoteTick, TradeTick) diff --git a/tests/performance_tests/test_perf_catalog.py b/tests/performance_tests/test_perf_catalog.py index c6e0582b3fed..6bb8b2c225e2 100644 --- a/tests/performance_tests/test_perf_catalog.py +++ b/tests/performance_tests/test_perf_catalog.py @@ -27,11 +27,9 @@ class TestBacktestEnginePerformance(PerformanceHarness): @staticmethod def test_load_quote_ticks_python(benchmark): - tempdir = tempfile.mkdtemp() def setup(): - # Arrange cls = TestPersistenceCatalogFile() @@ -51,7 +49,6 @@ def run(catalog): @staticmethod def test_load_quote_ticks_rust(benchmark): - tempdir = tempfile.mkdtemp() def setup(): diff --git a/tests/unit_tests/model/test_model_objects_money.py b/tests/unit_tests/model/test_model_objects_money.py index 266b15bd8578..a8c5ae2d761c 100644 --- a/tests/unit_tests/model/test_model_objects_money.py +++ b/tests/unit_tests/model/test_model_objects_money.py @@ -46,7 +46,6 @@ def test_instantiate_with_value_exceeding_negative_limit_raises_value_error(self Money(-9_223_372_036 - 1, currency=USD) def test_instantiate_with_none_value_returns_money_with_zero_amount(self) -> None: - # Arrange, Act money_zero = Money(None, currency=USD) diff --git a/tests/unit_tests/persistence/external/test_core.py b/tests/unit_tests/persistence/external/test_core.py index 4b1026adf594..02700a684a23 100644 --- a/tests/unit_tests/persistence/external/test_core.py +++ b/tests/unit_tests/persistence/external/test_core.py @@ -60,7 +60,6 @@ def setup(self): self.fs: fsspec.AbstractFileSystem = self.catalog.fs def teardown(self): - # Cleanup path = self.catalog.path fs = self.catalog.fs @@ -202,7 +201,6 @@ def test_write_parquet_determine_partitions_writes_instrument_id(self): assert expected in files def test_data_catalog_instruments_no_partition(self): - # Arrange, Act self._load_data_into_catalog() path = f"{self.catalog.path}/data/betting_instrument.parquet" diff --git a/tests/unit_tests/persistence/test_batching.py b/tests/unit_tests/persistence/test_batching.py index add82a10982a..49b054fb8fe2 100644 --- a/tests/unit_tests/persistence/test_batching.py +++ b/tests/unit_tests/persistence/test_batching.py @@ -137,7 +137,6 @@ def test_batch_generic_data(self): class TestBatchingData: - test_parquet_files = [ os.path.join(TEST_DATA_DIR, "quote_tick_eurusd_2019_sim_rust.parquet"), os.path.join(TEST_DATA_DIR, "quote_tick_usdjpy_2019_sim_rust.parquet"), @@ -154,7 +153,6 @@ class TestBatchingData: class TestGenerateBatches(TestBatchingData): def test_generate_batches_returns_empty_list_before_start_timestamp_with_end_timestamp(self): - start_timestamp = 1546389021944999936 batch_gen = generate_batches( files=[self.test_parquet_files[1]], @@ -195,7 +193,6 @@ def test_generate_batches_returns_batches_of_expected_size(self): assert all([len(x) == 1000 for x in batches]) def test_generate_batches_returns_empty_list_before_start_timestamp(self): - # Arrange parquet_data_path = self.test_parquet_files[0] start_timestamp = 1546383601403000064 # index 10 (1st item in batch) diff --git a/tests/unit_tests/persistence/test_catalog.py b/tests/unit_tests/persistence/test_catalog.py index 8f0330272798..4f367d3609dd 100644 --- a/tests/unit_tests/persistence/test_catalog.py +++ b/tests/unit_tests/persistence/test_catalog.py @@ -96,7 +96,6 @@ def _load_quote_ticks_into_catalog_rust(self) -> list[QuoteTick]: # Write EUR/USD and USD/JPY rust quotes for instrument_id in ("EUR/USD.SIM", "USD/JPY.SIM"): - # Reset reader reader = ParquetReader( parquet_data_path, diff --git a/tests/unit_tests/persistence/test_streaming.py b/tests/unit_tests/persistence/test_streaming.py index 559435db2747..d6d79a099eb9 100644 --- a/tests/unit_tests/persistence/test_streaming.py +++ b/tests/unit_tests/persistence/test_streaming.py @@ -109,7 +109,6 @@ def test_feather_writer(self): assert result == expected def test_feather_writer_generic_data(self): - # Arrange TestPersistenceStubs.setup_news_event_persistence() @@ -157,7 +156,6 @@ def test_feather_writer_generic_data(self): @pytest.mark.skip(reason="fix after merge") def test_feather_writer_signal_data(self): - # Arrange instrument_id = self.catalog.instruments(as_nautilus=True)[0].id.value data_config = BacktestDataConfig( From fb51e288f1d08b54a2028458d4bc06dab477dd49 Mon Sep 17 00:00:00 2001 From: Chris Sellers Date: Sat, 4 Feb 2023 16:06:22 +1100 Subject: [PATCH 17/81] Upgrade Rust --- README.md | 20 ++++++++++---------- nautilus_core/Cargo.toml | 2 +- nautilus_core/rust-toolchain.toml | 2 +- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index fa3d97916e5f..69bd86560281 100644 --- a/README.md +++ b/README.md @@ -15,9 +15,9 @@ | Platform | Rust | Python | |:------------------|:----------|:-------| -| Linux (x86\_64) | `1.66.1+` | `3.9+` | -| macOS (x86\_64) | `1.66.1+` | `3.9+` | -| Windows (x86\_64) | `1.66.1+` | `3.9+` | +| Linux (x86\_64) | `1.67.0+` | `3.9+` | +| macOS (x86\_64) | `1.67.0+` | `3.9+` | +| Windows (x86\_64) | `1.67.0+` | `3.9+` | - **Website:** https://nautilustrader.io - **Docs:** https://docs.nautilustrader.io @@ -276,7 +276,7 @@ class EMACross(Strategy): Cancels all orders and closes all positions on stop. """ - def __init__(self, config: EMACrossConfig): + def __init__(self, config: EMACrossConfig) -> None: super().__init__(config) # Configuration @@ -290,7 +290,7 @@ class EMACross(Strategy): self.instrument: Optional[Instrument] = None # Initialized in on_start - def on_start(self): + def on_start(self) -> None: """Actions to be performed on strategy start.""" # Get instrument self.instrument = self.cache.instrument(self.instrument_id) @@ -305,7 +305,7 @@ class EMACross(Strategy): # Subscribe to live data self.subscribe_bars(self.bar_type) - def on_bar(self, bar: Bar): + def on_bar(self, bar: Bar) -> None: """Actions to be performed when the strategy receives a bar.""" # BUY LOGIC if self.fast_ema.value >= self.slow_ema.value: @@ -322,7 +322,7 @@ class EMACross(Strategy): self.close_all_positions(self.instrument_id) self.sell() - def buy(self): + def buy(self) -> None: """Users simple buy method (example).""" order: MarketOrder = self.order_factory.market( instrument_id=self.instrument_id, @@ -332,7 +332,7 @@ class EMACross(Strategy): self.submit_order(order) - def sell(self): + def sell(self) -> None: """Users simple sell method (example).""" order: MarketOrder = self.order_factory.market( instrument_id=self.instrument_id, @@ -342,7 +342,7 @@ class EMACross(Strategy): self.submit_order(order) - def on_stop(self): + def on_stop(self) -> None: """Actions to be performed when the strategy is stopped.""" # Cleanup orders and positions self.cancel_all_orders(self.instrument_id) @@ -351,7 +351,7 @@ class EMACross(Strategy): # Unsubscribe from data self.unsubscribe_bars(self.bar_type) - def on_reset(self): + def on_reset(self) -> None: """Actions to be performed when the strategy is reset.""" # Reset indicators here self.fast_ema.reset() diff --git a/nautilus_core/Cargo.toml b/nautilus_core/Cargo.toml index 9339eb16c94e..57b5c5ffd600 100644 --- a/nautilus_core/Cargo.toml +++ b/nautilus_core/Cargo.toml @@ -8,7 +8,7 @@ members = [ ] [workspace.package] -rust-version = "1.66.1" +rust-version = "1.67.0" version = "0.2.0" edition = "2021" authors = ["Nautech Systems "] diff --git a/nautilus_core/rust-toolchain.toml b/nautilus_core/rust-toolchain.toml index 3ac3ddd03d48..c8890a7c7d3e 100644 --- a/nautilus_core/rust-toolchain.toml +++ b/nautilus_core/rust-toolchain.toml @@ -1,3 +1,3 @@ [toolchain] -version = "1.66.1" +version = "1.67.0" channel = "stable" From 22ec32bcbd16bc85c7bf990a3460094c9b55b83a Mon Sep 17 00:00:00 2001 From: Chris Sellers Date: Sat, 4 Feb 2023 18:02:12 +1100 Subject: [PATCH 18/81] Refine core Logger with MPSC channel - Spawns message handling thread at initialization - Uses simple token bucket algorithm for rate limiting --- .../betfair_backtest_orderbook_imbalance.py | 3 + .../crypto_ema_cross_ethusdt_trade_ticks.py | 2 + .../crypto_ema_cross_ethusdt_trailing_stop.py | 2 + .../fx_ema_cross_audusd_bars_from_ticks.py | 2 + .../backtest/fx_ema_cross_audusd_ticks.py | 2 + ..._ema_cross_bracket_gbpusd_bars_external.py | 2 + ..._ema_cross_bracket_gbpusd_bars_internal.py | 2 + .../backtest/fx_market_maker_gbpusd_bars.py | 2 + examples/live/betfair.py | 4 +- examples/live/betfair_sandbox.py | 4 +- nautilus_core/common/src/logging.rs | 238 ++++++++++++------ nautilus_trader/adapters/betfair/factories.py | 9 +- nautilus_trader/adapters/binance/factories.py | 9 +- .../adapters/interactive_brokers/factories.py | 9 +- nautilus_trader/adapters/sandbox/factory.py | 6 +- nautilus_trader/common/logging.pxd | 13 - nautilus_trader/common/logging.pyx | 218 ---------------- nautilus_trader/core/includes/common.h | 2 - nautilus_trader/core/rust/common.pxd | 2 - nautilus_trader/live/factories.py | 10 +- nautilus_trader/live/node.py | 8 +- nautilus_trader/live/node_builder.py | 6 +- nautilus_trader/system/kernel.py | 27 +- nautilus_trader/test_kit/stubs/component.py | 7 +- .../adapters/betfair/test_betfair_account.py | 4 +- .../adapters/betfair/test_betfair_client.py | 4 +- .../adapters/betfair/test_betfair_data.py | 7 +- .../adapters/betfair/test_betfair_factory.py | 4 +- .../adapters/betfair/test_betfair_parsing.py | 4 +- .../betfair/test_betfair_providers.py | 4 +- .../adapters/betfair/test_betfair_sockets.py | 4 +- .../sandbox/sandbox_ws_futures_market.py | 4 +- .../binance/sandbox/sandbox_ws_spot_user.py | 3 +- .../adapters/binance/test_factories.py | 8 +- .../adapters/interactive_brokers/base.py | 8 +- .../interactive_brokers/test_providers.py | 8 +- .../sandbox/test_sandbox_execution.py | 4 +- tests/integration_tests/network/conftest.py | 4 +- .../unit_tests/common/test_common_logging.py | 79 ------ .../live/test_live_execution_recon.py | 4 +- 40 files changed, 251 insertions(+), 491 deletions(-) diff --git a/examples/backtest/betfair_backtest_orderbook_imbalance.py b/examples/backtest/betfair_backtest_orderbook_imbalance.py index 518105c93ab9..1d245db814d0 100644 --- a/examples/backtest/betfair_backtest_orderbook_imbalance.py +++ b/examples/backtest/betfair_backtest_orderbook_imbalance.py @@ -14,6 +14,8 @@ # limitations under the License. # ------------------------------------------------------------------------------------------------- +import time + import pandas as pd from nautilus_trader.adapters.betfair.common import BETFAIR_VENUE @@ -84,6 +86,7 @@ ] engine.add_strategies(strategies) + time.sleep(0.1) input("Press Enter to continue...") # noqa (always Python 3) # Run the engine (from start to end of data) diff --git a/examples/backtest/crypto_ema_cross_ethusdt_trade_ticks.py b/examples/backtest/crypto_ema_cross_ethusdt_trade_ticks.py index d334295f266a..e13a5449bd54 100644 --- a/examples/backtest/crypto_ema_cross_ethusdt_trade_ticks.py +++ b/examples/backtest/crypto_ema_cross_ethusdt_trade_ticks.py @@ -14,6 +14,7 @@ # limitations under the License. # ------------------------------------------------------------------------------------------------- +import time from decimal import Decimal import pandas as pd @@ -72,6 +73,7 @@ strategy = EMACross(config=config) engine.add_strategy(strategy=strategy) + time.sleep(0.1) input("Press Enter to continue...") # noqa (always Python 3) # Run the engine (from start to end of data) diff --git a/examples/backtest/crypto_ema_cross_ethusdt_trailing_stop.py b/examples/backtest/crypto_ema_cross_ethusdt_trailing_stop.py index 3b1b899faf02..8d321d0a8ee9 100644 --- a/examples/backtest/crypto_ema_cross_ethusdt_trailing_stop.py +++ b/examples/backtest/crypto_ema_cross_ethusdt_trailing_stop.py @@ -14,6 +14,7 @@ # limitations under the License. # ------------------------------------------------------------------------------------------------- +import time from decimal import Decimal import pandas as pd @@ -76,6 +77,7 @@ strategy = EMACrossTrailingStop(config=config) engine.add_strategy(strategy=strategy) + time.sleep(0.1) input("Press Enter to continue...") # noqa (always Python 3) # Run the engine (from start to end of data) diff --git a/examples/backtest/fx_ema_cross_audusd_bars_from_ticks.py b/examples/backtest/fx_ema_cross_audusd_bars_from_ticks.py index d0799c97679c..2456320bf2aa 100644 --- a/examples/backtest/fx_ema_cross_audusd_bars_from_ticks.py +++ b/examples/backtest/fx_ema_cross_audusd_bars_from_ticks.py @@ -14,6 +14,7 @@ # limitations under the License. # ------------------------------------------------------------------------------------------------- +import time from decimal import Decimal import pandas as pd @@ -82,6 +83,7 @@ strategy = EMACross(config=config) engine.add_strategy(strategy=strategy) + time.sleep(0.1) input("Press Enter to continue...") # noqa (always Python 3) # Run the engine (from start to end of data) diff --git a/examples/backtest/fx_ema_cross_audusd_ticks.py b/examples/backtest/fx_ema_cross_audusd_ticks.py index 9d6655eb8193..88d823dfa046 100644 --- a/examples/backtest/fx_ema_cross_audusd_ticks.py +++ b/examples/backtest/fx_ema_cross_audusd_ticks.py @@ -14,6 +14,7 @@ # limitations under the License. # ------------------------------------------------------------------------------------------------- +import time from decimal import Decimal import pandas as pd @@ -93,6 +94,7 @@ strategy = EMACross(config=config) engine.add_strategy(strategy=strategy) + time.sleep(0.1) input("Press Enter to continue...") # noqa (always Python 3) # Run the engine (from start to end of data) diff --git a/examples/backtest/fx_ema_cross_bracket_gbpusd_bars_external.py b/examples/backtest/fx_ema_cross_bracket_gbpusd_bars_external.py index 7435497e6326..71c5f09bd010 100644 --- a/examples/backtest/fx_ema_cross_bracket_gbpusd_bars_external.py +++ b/examples/backtest/fx_ema_cross_bracket_gbpusd_bars_external.py @@ -14,6 +14,7 @@ # limitations under the License. # ------------------------------------------------------------------------------------------------- +import time from decimal import Decimal import pandas as pd @@ -114,6 +115,7 @@ strategy = EMACrossBracket(config=config) engine.add_strategy(strategy=strategy) + time.sleep(0.1) input("Press Enter to continue...") # noqa (always Python 3) # Run the engine (from start to end of data) diff --git a/examples/backtest/fx_ema_cross_bracket_gbpusd_bars_internal.py b/examples/backtest/fx_ema_cross_bracket_gbpusd_bars_internal.py index a453a5116a39..e092c2b3969e 100644 --- a/examples/backtest/fx_ema_cross_bracket_gbpusd_bars_internal.py +++ b/examples/backtest/fx_ema_cross_bracket_gbpusd_bars_internal.py @@ -14,6 +14,7 @@ # limitations under the License. # ------------------------------------------------------------------------------------------------- +import time from decimal import Decimal import pandas as pd @@ -101,6 +102,7 @@ strategy = EMACrossBracket(config=config) engine.add_strategy(strategy=strategy) + time.sleep(0.1) input("Press Enter to continue...") # noqa (always Python 3) # Run the engine (from start to end of data) diff --git a/examples/backtest/fx_market_maker_gbpusd_bars.py b/examples/backtest/fx_market_maker_gbpusd_bars.py index 0b8bf668e99f..656852ab0735 100644 --- a/examples/backtest/fx_market_maker_gbpusd_bars.py +++ b/examples/backtest/fx_market_maker_gbpusd_bars.py @@ -14,6 +14,7 @@ # limitations under the License. # ------------------------------------------------------------------------------------------------- +import time from datetime import datetime from decimal import Decimal @@ -97,6 +98,7 @@ strategy = VolatilityMarketMaker(config=config) engine.add_strategy(strategy=strategy) + time.sleep(0.1) input("Press Enter to continue...") # noqa (always Python 3) # Run the engine (from start to end of data) diff --git a/examples/live/betfair.py b/examples/live/betfair.py index 6cf478df2e4e..478ba9910572 100644 --- a/examples/live/betfair.py +++ b/examples/live/betfair.py @@ -24,7 +24,7 @@ from nautilus_trader.adapters.betfair.factories import get_cached_betfair_client from nautilus_trader.adapters.betfair.factories import get_cached_betfair_instrument_provider from nautilus_trader.common.clock import LiveClock -from nautilus_trader.common.logging import LiveLogger +from nautilus_trader.common.logging import Logger from nautilus_trader.config import CacheDatabaseConfig from nautilus_trader.config import TradingNodeConfig from nautilus_trader.examples.strategies.orderbook_imbalance import OrderBookImbalance @@ -39,7 +39,7 @@ async def main(market_id: str): # Connect to Betfair client early to load instruments and account currency loop = asyncio.get_event_loop() - logger = LiveLogger(loop=loop, clock=LiveClock()) + logger = Logger(clock=LiveClock()) client = get_cached_betfair_client( username=None, # Pass here or will source from the `BETFAIR_USERNAME` env var password=None, # Pass here or will source from the `BETFAIR_PASSWORD` env var diff --git a/examples/live/betfair_sandbox.py b/examples/live/betfair_sandbox.py index 2b00a6a2b094..2e4d07d6f072 100644 --- a/examples/live/betfair_sandbox.py +++ b/examples/live/betfair_sandbox.py @@ -22,7 +22,7 @@ from nautilus_trader.adapters.sandbox.execution import SandboxExecutionClient from nautilus_trader.adapters.sandbox.factory import SandboxLiveExecClientFactory from nautilus_trader.common.clock import LiveClock -from nautilus_trader.common.logging import LiveLogger +from nautilus_trader.common.logging import Logger from nautilus_trader.config import CacheDatabaseConfig from nautilus_trader.config import TradingNodeConfig from nautilus_trader.examples.strategies.orderbook_imbalance import OrderBookImbalance @@ -37,7 +37,7 @@ async def main(market_id: str): # Connect to Betfair client early to load instruments and account currency loop = asyncio.get_event_loop() - logger = LiveLogger(loop=loop, clock=LiveClock()) + logger = Logger(clock=LiveClock()) client = get_cached_betfair_client( username=None, # Pass here or will source from the `BETFAIR_USERNAME` env var password=None, # Pass here or will source from the `BETFAIR_PASSWORD` env var diff --git a/nautilus_core/common/src/logging.rs b/nautilus_core/common/src/logging.rs index 9cfab9668e33..f21dd82e7717 100644 --- a/nautilus_core/common/src/logging.rs +++ b/nautilus_core/common/src/logging.rs @@ -14,141 +14,229 @@ // ------------------------------------------------------------------------------------------------- use std::ffi::c_char; +use std::io::{Stderr, Stdout}; +use std::sync::mpsc::{channel, Receiver, SendError, Sender}; +use std::time::{Duration, Instant}; use std::{ - io::{self, BufWriter, Stderr, Stdout, Write}, + io::{self, BufWriter, Write}, ops::{Deref, DerefMut}, + thread, }; use nautilus_core::datetime::unix_nanos_to_iso8601; use nautilus_core::string::{cstr_to_string, string_to_cstr}; +use nautilus_core::time::UnixNanos; use nautilus_core::uuid::UUID4; use nautilus_model::identifiers::trader_id::TraderId; use crate::enums::{LogColor, LogLevel}; pub struct Logger { + /// The trader ID for the logger. pub trader_id: TraderId, + /// The machine ID for the logger. pub machine_id: String, + /// The instance ID for the logger. pub instance_id: UUID4, + /// The maximum log level to write to stdout. pub level_stdout: LogLevel, + /// The maximum messages per second which can be flushed to stdout or stderr. + pub rate_limit: usize, + /// If logging is bypassed. pub is_bypassed: bool, - log_template: String, - out: BufWriter, - err: BufWriter, + tx: Sender, } +#[derive(Clone, Debug)] +pub struct LogMessage { + timestamp_ns: UnixNanos, + level: LogLevel, + color: LogColor, + component: String, + msg: String, +} + +/// Provides a high-performance logger utilizing a MPSC channel under the hood. +/// +/// A separate thead is spawned at initialization which receives `LogMessage` structs over the +/// channel. Rate limiting is implemented using a simple token bucket algorithm (maximum messages +/// per second). impl Logger { fn new( trader_id: TraderId, machine_id: String, instance_id: UUID4, level_stdout: LogLevel, + rate_limit: usize, is_bypassed: bool, ) -> Self { + let trader_id_clone = trader_id.value.to_string(); + let (tx, rx) = channel::(); + + thread::spawn(move || { + Self::handle_messages(&trader_id_clone, level_stdout, rate_limit, rx) + }); + Logger { trader_id, machine_id, instance_id, level_stdout, + rate_limit, is_bypassed, - log_template: String::from( - "\x1b[1m{ts}\x1b[0m {color}[{level}] {trader_id}.{component}: {msg}\x1b[0m\n", - ), - out: BufWriter::new(io::stdout()), - err: BufWriter::new(io::stderr()), + tx, + } + } + + fn handle_messages( + trader_id: &str, + level_stdout: LogLevel, + rate_limit: usize, + rx: Receiver, + ) { + let mut out = BufWriter::new(io::stdout()); + let mut err = BufWriter::new(io::stderr()); + + let log_template = String::from( + "\x1b[1m{ts}\x1b[0m {color}[{level}] {trader_id}.{component}: {msg}\x1b[0m\n", + ); + + let mut msg_count = 0; + let mut bucket_time = Instant::now(); + + // Continue to receive and handle log messages until channel is hung up + while let Ok(log_msg) = rx.recv() { + if log_msg.level < level_stdout { + continue; + } + + while msg_count >= rate_limit { + if bucket_time.elapsed().as_secs() >= 1 { + msg_count = 0; + bucket_time = Instant::now(); + } else { + thread::sleep(Duration::from_millis(10)); + } + } + + let fmt_line = log_template + .replace("{ts}", &unix_nanos_to_iso8601(log_msg.timestamp_ns)) + .replace("{color}", &log_msg.color.to_string()) + .replace("{level}", &log_msg.level.to_string()) + .replace("{trader_id}", trader_id) + .replace("{component}", &log_msg.component) + .replace("{msg}", &log_msg.msg); + + if log_msg.level >= LogLevel::Error { + Self::write_stderr(&mut err, fmt_line); + Self::flush_stderr(&mut err); + } else { + Self::write_stdout(&mut out, fmt_line); + Self::flush_stdout(&mut out); + } + + msg_count += 1; } + + // Finally ensure remaining buffers are flushed + Self::flush_stderr(&mut err); + Self::flush_stdout(&mut out); } - #[inline] - fn log( + fn write_stdout(out: &mut BufWriter, line: String) { + match out.write_all(line.as_bytes()) { + Ok(_) => {} + Err(e) => eprintln!("Error writing to stdout: {e:?}"), + } + } + + fn flush_stdout(out: &mut BufWriter) { + match out.flush() { + Ok(_) => {} + Err(e) => eprintln!("Error flushing stdout: {e:?}"), + } + } + + fn write_stderr(err: &mut BufWriter, line: String) { + match err.write_all(line.as_bytes()) { + Ok(_) => {} + Err(e) => eprintln!("Error writing to stderr: {e:?}"), + } + } + + fn flush_stderr(err: &mut BufWriter) { + match err.flush() { + Ok(_) => {} + Err(e) => eprintln!("Error flushing stderr: {e:?}"), + } + } + + fn send( &mut self, timestamp_ns: u64, level: LogLevel, color: LogColor, - component: &str, - msg: &str, - ) -> Result<(), io::Error> { - if level < self.level_stdout { - return Ok(()); - } - - let fmt_line = self - .log_template - .replace("{ts}", &unix_nanos_to_iso8601(timestamp_ns)) - .replace("{color}", &color.to_string()) - .replace("{level}", &level.to_string()) - .replace("{trader_id}", &self.trader_id.to_string()) - .replace("{component}", component) - .replace("{msg}", msg); - - if level >= LogLevel::Error { - self.err.write_all(fmt_line.as_bytes())?; - self.err.flush() - } else { - self.out.write_all(fmt_line.as_bytes())?; - self.out.flush() - } + component: String, + msg: String, + ) -> Result<(), SendError> { + let log_message = LogMessage { + timestamp_ns, + level, + color, + component, + msg, + }; + self.tx.send(log_message) } - #[inline] pub fn debug( &mut self, timestamp_ns: u64, color: LogColor, - component: &str, - msg: &str, - ) -> Result<(), io::Error> { - self.log(timestamp_ns, LogLevel::Debug, color, component, msg) + component: String, + msg: String, + ) -> Result<(), SendError> { + self.send(timestamp_ns, LogLevel::Debug, color, component, msg) } - #[inline] pub fn info( &mut self, timestamp_ns: u64, color: LogColor, - component: &str, - msg: &str, - ) -> Result<(), io::Error> { - self.log(timestamp_ns, LogLevel::Info, color, component, msg) + component: String, + msg: String, + ) -> Result<(), SendError> { + self.send(timestamp_ns, LogLevel::Info, color, component, msg) } - #[inline] pub fn warn( &mut self, timestamp_ns: u64, color: LogColor, - component: &str, - msg: &str, - ) -> Result<(), io::Error> { - self.log(timestamp_ns, LogLevel::Warning, color, component, msg) + component: String, + msg: String, + ) -> Result<(), SendError> { + self.send(timestamp_ns, LogLevel::Warning, color, component, msg) } - #[inline] pub fn error( &mut self, timestamp_ns: u64, color: LogColor, - component: &str, - msg: &str, - ) -> Result<(), io::Error> { - self.log(timestamp_ns, LogLevel::Error, color, component, msg) + component: String, + msg: String, + ) -> Result<(), SendError> { + self.send(timestamp_ns, LogLevel::Error, color, component, msg) } - #[inline] pub fn critical( &mut self, timestamp_ns: u64, color: LogColor, - component: &str, - msg: &str, - ) -> Result<(), io::Error> { - self.log(timestamp_ns, LogLevel::Critical, color, component, msg) - } - - #[inline] - fn flush(&mut self) -> Result<(), io::Error> { - self.out.flush()?; - self.err.flush() + component: String, + msg: String, + ) -> Result<(), SendError> { + self.send(timestamp_ns, LogLevel::Critical, color, component, msg) } } @@ -194,21 +282,16 @@ pub unsafe extern "C" fn logger_new( String::from(&cstr_to_string(machine_id_ptr)), UUID4::from(cstr_to_string(instance_id_ptr).as_str()), level_stdout, + 100_000, // TODO(cs): Hardcoded for the moment is_bypassed != 0, ))) } #[no_mangle] -pub extern "C" fn logger_free(mut logger: CLogger) { - let _ = logger.flush(); // ignore flushing error if any +pub extern "C" fn logger_free(logger: CLogger) { drop(logger); // Memory freed here } -#[no_mangle] -pub extern "C" fn flush(logger: &mut CLogger) { - let _ = logger.flush(); -} - #[no_mangle] pub extern "C" fn logger_get_trader_id_cstr(logger: &CLogger) -> *const c_char { string_to_cstr(&logger.trader_id.to_string()) @@ -245,7 +328,7 @@ pub unsafe extern "C" fn logger_log( ) { let component = cstr_to_string(component_ptr); let msg = cstr_to_string(msg_ptr); - let _ = logger.log(timestamp_ns, level, color, &component, &msg); + let _ = logger.send(timestamp_ns, level, color, component, msg); } //////////////////////////////////////////////////////////////////////////////// @@ -253,11 +336,10 @@ pub unsafe extern "C" fn logger_log( //////////////////////////////////////////////////////////////////////////////// #[cfg(test)] mod tests { + use crate::logging::{LogColor, LogLevel, Logger}; use nautilus_core::uuid::UUID4; use nautilus_model::identifiers::trader_id::TraderId; - use crate::logging::{LogColor, LogLevel, Logger}; - #[test] fn test_new_logger() { let logger = Logger::new( @@ -265,6 +347,7 @@ mod tests { String::from("user-01"), UUID4::new(), LogLevel::Debug, + 100_000, false, ); assert_eq!(logger.trader_id, TraderId::new("TRADER-000")); @@ -278,6 +361,7 @@ mod tests { String::from("user-01"), UUID4::new(), LogLevel::Info, + 100_000, false, ); @@ -285,8 +369,8 @@ mod tests { .info( 1650000000000000, LogColor::Normal, - "RiskEngine", - "This is a test.", + String::from("RiskEngine"), + String::from("This is a test."), ) .expect("Error while logging"); } diff --git a/nautilus_trader/adapters/betfair/factories.py b/nautilus_trader/adapters/betfair/factories.py index 586c4b12bcb4..e34e726270a0 100644 --- a/nautilus_trader/adapters/betfair/factories.py +++ b/nautilus_trader/adapters/betfair/factories.py @@ -26,7 +26,6 @@ from nautilus_trader.adapters.betfair.providers import BetfairInstrumentProvider from nautilus_trader.cache.cache import Cache from nautilus_trader.common.clock import LiveClock -from nautilus_trader.common.logging import LiveLogger from nautilus_trader.common.logging import Logger from nautilus_trader.common.logging import LoggerAdapter from nautilus_trader.live.factories import LiveDataClientFactory @@ -153,7 +152,7 @@ def create( # type: ignore msgbus: MessageBus, cache: Cache, clock: LiveClock, - logger: LiveLogger, + logger: Logger, ): """ Create a new Betfair data client. @@ -172,7 +171,7 @@ def create( # type: ignore The cache for the client. clock : LiveClock The clock for the client. - logger : LiveLogger + logger : Logger The logger for the client. Returns @@ -223,7 +222,7 @@ def create( # type: ignore msgbus: MessageBus, cache: Cache, clock: LiveClock, - logger: LiveLogger, + logger: Logger, ): """ Create a new Betfair execution client. @@ -242,7 +241,7 @@ def create( # type: ignore The cache for the client. clock : LiveClock The clock for the client. - logger : LiveLogger + logger : Logger The logger for the client. Returns diff --git a/nautilus_trader/adapters/binance/factories.py b/nautilus_trader/adapters/binance/factories.py index d886db4c9926..89d09ac3d731 100644 --- a/nautilus_trader/adapters/binance/factories.py +++ b/nautilus_trader/adapters/binance/factories.py @@ -30,7 +30,6 @@ from nautilus_trader.adapters.binance.spot.providers import BinanceSpotInstrumentProvider from nautilus_trader.cache.cache import Cache from nautilus_trader.common.clock import LiveClock -from nautilus_trader.common.logging import LiveLogger from nautilus_trader.common.logging import Logger from nautilus_trader.config import InstrumentProviderConfig from nautilus_trader.live.factories import LiveDataClientFactory @@ -197,7 +196,7 @@ def create( # type: ignore msgbus: MessageBus, cache: Cache, clock: LiveClock, - logger: LiveLogger, + logger: Logger, ) -> Union[BinanceSpotDataClient, BinanceFuturesDataClient]: """ Create a new Binance data client. @@ -216,7 +215,7 @@ def create( # type: ignore The cache for the client. clock : LiveClock The clock for the client. - logger : LiveLogger + logger : Logger The logger for the client. Returns @@ -308,7 +307,7 @@ def create( # type: ignore msgbus: MessageBus, cache: Cache, clock: LiveClock, - logger: LiveLogger, + logger: Logger, ) -> Union[BinanceSpotExecutionClient, BinanceFuturesExecutionClient]: """ Create a new Binance execution client. @@ -327,7 +326,7 @@ def create( # type: ignore The cache for the client. clock : LiveClock The clock for the client. - logger : LiveLogger + logger : Logger The logger for the client. Returns diff --git a/nautilus_trader/adapters/interactive_brokers/factories.py b/nautilus_trader/adapters/interactive_brokers/factories.py index bcceeb0977d3..ef35204528a4 100644 --- a/nautilus_trader/adapters/interactive_brokers/factories.py +++ b/nautilus_trader/adapters/interactive_brokers/factories.py @@ -31,7 +31,6 @@ ) from nautilus_trader.cache.cache import Cache from nautilus_trader.common.clock import LiveClock -from nautilus_trader.common.logging import LiveLogger from nautilus_trader.common.logging import Logger from nautilus_trader.config import InstrumentProviderConfig from nautilus_trader.live.factories import LiveDataClientFactory @@ -163,7 +162,7 @@ def create( # type: ignore msgbus: MessageBus, cache: Cache, clock: LiveClock, - logger: LiveLogger, + logger: Logger, ) -> InteractiveBrokersDataClient: """ Create a new InteractiveBrokers data client. @@ -182,7 +181,7 @@ def create( # type: ignore The cache for the client. clock : LiveClock The clock for the client. - logger : LiveLogger + logger : Logger The logger for the client. Returns @@ -235,7 +234,7 @@ def create( # type: ignore msgbus: MessageBus, cache: Cache, clock: LiveClock, - logger: LiveLogger, + logger: Logger, ) -> InteractiveBrokersExecutionClient: """ Create a new InteractiveBrokers execution client. @@ -254,7 +253,7 @@ def create( # type: ignore The cache for the client. clock : LiveClock The clock for the client. - logger : LiveLogger + logger : Logger The logger for the client. Returns diff --git a/nautilus_trader/adapters/sandbox/factory.py b/nautilus_trader/adapters/sandbox/factory.py index 0b8cfd01a245..dacafee7f287 100644 --- a/nautilus_trader/adapters/sandbox/factory.py +++ b/nautilus_trader/adapters/sandbox/factory.py @@ -19,7 +19,7 @@ from nautilus_trader.adapters.sandbox.execution import SandboxExecutionClient from nautilus_trader.cache.cache import Cache from nautilus_trader.common.clock import LiveClock -from nautilus_trader.common.logging import LiveLogger +from nautilus_trader.common.logging import Logger from nautilus_trader.live.factories import LiveExecClientFactory from nautilus_trader.msgbus.bus import MessageBus @@ -37,7 +37,7 @@ def create( # type: ignore msgbus: MessageBus, cache: Cache, clock: LiveClock, - logger: LiveLogger, + logger: Logger, ) -> SandboxExecutionClient: """ Create a new Sandbox execution client. @@ -56,7 +56,7 @@ def create( # type: ignore The cache for the client. clock : LiveClock The clock for the client. - logger : LiveLogger + logger : Logger The logger for the client. Returns diff --git a/nautilus_trader/common/logging.pxd b/nautilus_trader/common/logging.pxd index c6c54d4d8273..ab0828c39134 100644 --- a/nautilus_trader/common/logging.pxd +++ b/nautilus_trader/common/logging.pxd @@ -81,16 +81,3 @@ cdef class LoggerAdapter: cpdef void nautilus_header(LoggerAdapter logger) except * cpdef void log_memory(LoggerAdapter logger) except * - - -cdef class LiveLogger(Logger): - cdef object _loop - cdef object _run_task - cdef timedelta _blocked_log_interval - cdef Queue _queue - cdef bint _is_running - cdef datetime _last_blocked - - cpdef void start(self) except * - cpdef void stop(self) except * - cdef void _enqueue_sentinel(self) except * diff --git a/nautilus_trader/common/logging.pyx b/nautilus_trader/common/logging.pyx index 276620e07746..a1d8a226490b 100644 --- a/nautilus_trader/common/logging.pyx +++ b/nautilus_trader/common/logging.pyx @@ -648,221 +648,3 @@ cpdef void log_memory(LoggerAdapter logger) except *: logger.warning(f"RAM-Avail: {ram_avail_mb:,} MB ({ram_avail_pc:.2f}%)") else: logger.info(f"RAM-Avail: {ram_avail_mb:,} MB ({ram_avail_pc:.2f}%)") - - -cdef class LiveLogger(Logger): - """ - Provides a high-performance logger which runs on the event loop. - - Parameters - ---------- - loop : asyncio.AbstractEventLoop - The event loop to run the logger on. - clock : LiveClock - The clock for the logger. - trader_id : TraderId, optional - The trader ID for the logger. - machine_id : str, optional - The machine ID for the logger. - instance_id : UUID4, optional - The systems unique instantiation ID. - level_stdout : LogLevel - The minimum log level for logging messages to stdout. - bypass : bool - If the logger should be bypassed. - maxsize : int, optional - The maximum capacity for the log queue. - """ - _sentinel = None - - def __init__( - self, - loop not None, - LiveClock clock not None, - TraderId trader_id = None, - str machine_id = None, - UUID4 instance_id = None, - LogLevel level_stdout = LogLevel.INFO, - bint bypass = False, - int maxsize = 10000, - ): - super().__init__( - clock=clock, - trader_id=trader_id, - machine_id=machine_id, - instance_id=instance_id, - level_stdout=level_stdout, - bypass=bypass, - ) - - self._loop = loop - self._queue = Queue(maxsize=maxsize) - self._run_task: Optional[Task] = None - self._blocked_log_interval = timedelta(seconds=1) - - self._is_running = False - self._last_blocked: Optional[datetime] = None - - @property - def is_running(self) -> bool: - """ - Return whether the logger is running. - - Returns - ------- - bool - - """ - return self._is_running - - @property - def last_blocked(self) -> Optional[datetime]: - """ - Return the timestamp (UTC) the logger last blocked. - - Returns - ------- - datetime or ``None`` - - """ - return self._last_blocked - - def get_run_task(self) -> asyncio.Task: - """ - Return the internal run queue task for the engine. - - Returns - ------- - asyncio.Task - - """ - return self._run_task - - cdef void log( - self, - uint64_t timestamp_ns, - LogLevel level, - LogColor color, - str component, - str msg, - dict annotations = None, - ) except *: - """ - Log the given message. - - If the internal queue is already full then will log a warning and block - until queue size reduces. - - If the event loop is not running then messages will be passed directly - to the `Logger` base class for logging. - - """ - Condition.not_none(component, "component") - Condition.not_none(msg, "msg") - - if self._is_running: - try: - self._queue.put_nowait((timestamp_ns, level, color, component, msg, annotations)) - except asyncio.QueueFull: - now = self._clock.utc_now() - next_msg = self._queue.peek_front()[4] - - # Log blocking message once a second - if ( - self._last_blocked is None - or now >= self._last_blocked + self._blocked_log_interval - ): - self._last_blocked = now - - messages = [r[4] for r in self._queue.to_list()] - message_types = defaultdict(lambda: 0) - for msg in messages: - message_types[msg] += 1 - sorted_types = sorted( - message_types.items(), - key=lambda kv: kv[1], - reverse=True, - ) - - blocked_msg = '\n'.join([f"'{kv[0]}' [x{kv[1]}]" for kv in sorted_types]) - log_msg = (f"Blocking full log queue at " - f"{self._queue.qsize()} items. " - f"\nNext msg = '{next_msg}'.\n{blocked_msg}") - - self._log( - timestamp_ns, - LogLevel.WARNING, - LogColor.YELLOW, - type(self).__name__, - log_msg, - annotations, - ) - - # If not spamming then add record to event loop - if next_msg != msg: - self._loop.create_task(self._queue.put((timestamp_ns, level, color, component, msg, annotations))) # Blocking until qsize reduces - else: - # If event loop is not running then pass message directly to the - # base class to log. - self._log( - timestamp_ns, - level, - color, - component, - msg, - annotations, - ) - - cpdef void start(self) except *: - """ - Start the logger on a running event loop. - """ - if not self._is_running: - self._run_task = self._loop.create_task(self._consume_messages()) - self._is_running = True - - cpdef void stop(self) except *: - """ - Stop the logger by canceling the internal event loop task. - - Future messages sent to the logger will be passed directly to the - `Logger` base class for logging. - - """ - if self._run_task: - self._is_running = False - self._enqueue_sentinel() - - async def _consume_messages(self): - cdef tuple record - try: - while self._is_running: - record = await self._queue.get() - if record is None: # Sentinel message (fast C-level check) - continue # Returns to the top to check `self._is_running` - self._log( - record[0], - record[1], - record[2], - record[3], - record[4], - record[5], - ) - except asyncio.CancelledError: - pass - finally: - # Pass remaining messages directly to the base class - while not self._queue.empty(): - record = self._queue.get_nowait() - if record: - self._log( - record[0], - record[1], - record[2], - record[3], - record[4], - record[5], - ) - - cdef void _enqueue_sentinel(self) except *: - self._queue.put_nowait(self._sentinel) diff --git a/nautilus_trader/core/includes/common.h b/nautilus_trader/core/includes/common.h index 94bbf7a5ca52..6fe80fcbb6f4 100644 --- a/nautilus_trader/core/includes/common.h +++ b/nautilus_trader/core/includes/common.h @@ -214,8 +214,6 @@ struct CLogger logger_new(const char *trader_id_ptr, void logger_free(struct CLogger logger); -void flush(struct CLogger *logger); - const char *logger_get_trader_id_cstr(const struct CLogger *logger); const char *logger_get_machine_id_cstr(const struct CLogger *logger); diff --git a/nautilus_trader/core/rust/common.pxd b/nautilus_trader/core/rust/common.pxd index bedb9e7d53bc..901a8671687a 100644 --- a/nautilus_trader/core/rust/common.pxd +++ b/nautilus_trader/core/rust/common.pxd @@ -176,8 +176,6 @@ cdef extern from "../includes/common.h": void logger_free(CLogger logger); - void flush(CLogger *logger); - const char *logger_get_trader_id_cstr(const CLogger *logger); const char *logger_get_machine_id_cstr(const CLogger *logger); diff --git a/nautilus_trader/live/factories.py b/nautilus_trader/live/factories.py index 228f83c2bfcf..cae783f7a823 100644 --- a/nautilus_trader/live/factories.py +++ b/nautilus_trader/live/factories.py @@ -17,7 +17,7 @@ from nautilus_trader.cache.cache import Cache from nautilus_trader.common.clock import LiveClock -from nautilus_trader.common.logging import LiveLogger +from nautilus_trader.common.logging import Logger from nautilus_trader.config import LiveDataClientConfig from nautilus_trader.config import LiveExecClientConfig from nautilus_trader.msgbus.bus import MessageBus @@ -36,7 +36,7 @@ def create( msgbus: MessageBus, cache: Cache, clock: LiveClock, - logger: LiveLogger, + logger: Logger, ): """ Return a new data client. @@ -55,7 +55,7 @@ def create( The cache for the client. clock : LiveClock The clock for the client. - logger : LiveLogger + logger : Logger The logger for the client. Returns @@ -79,7 +79,7 @@ def create( msgbus: MessageBus, cache: Cache, clock: LiveClock, - logger: LiveLogger, + logger: Logger, ): """ Return a new execution client. @@ -98,7 +98,7 @@ def create( The cache for the client. clock : LiveClock The clock for the client. - logger : LiveLogger + logger : Logger The logger for the client. Returns diff --git a/nautilus_trader/live/node.py b/nautilus_trader/live/node.py index a4b9c55e92c0..d775dd1446ed 100644 --- a/nautilus_trader/live/node.py +++ b/nautilus_trader/live/node.py @@ -22,7 +22,7 @@ from nautilus_trader.common import Environment from nautilus_trader.common.enums import LogColor from nautilus_trader.common.enums import log_level_from_str -from nautilus_trader.common.logging import LiveLogger +from nautilus_trader.common.logging import Logger from nautilus_trader.config import CacheConfig from nautilus_trader.config import CacheDatabaseConfig from nautilus_trader.config import LiveDataEngineConfig @@ -204,13 +204,13 @@ def get_event_loop(self) -> Optional[asyncio.AbstractEventLoop]: """ return self.kernel.loop - def get_logger(self) -> LiveLogger: + def get_logger(self) -> Logger: """ Return the logger for the trading node. Returns ------- - LiveLogger + Logger """ return self.kernel.logger @@ -398,7 +398,6 @@ async def _run(self) -> None: self._is_running = True # Start system - self.kernel.logger.start() self.kernel.data_engine.start() self.kernel.risk_engine.start() self.kernel.exec_engine.start() @@ -575,7 +574,6 @@ async def _stop(self) -> None: self.kernel.writer.flush() self.kernel.log.info("STOPPED.") - self.kernel.logger.stop() self._is_running = False async def _await_engines_disconnected(self) -> bool: diff --git a/nautilus_trader/live/node_builder.py b/nautilus_trader/live/node_builder.py index 7f9939836d72..6f1d12153106 100644 --- a/nautilus_trader/live/node_builder.py +++ b/nautilus_trader/live/node_builder.py @@ -17,7 +17,7 @@ from nautilus_trader.cache.cache import Cache from nautilus_trader.common.clock import LiveClock -from nautilus_trader.common.logging import LiveLogger +from nautilus_trader.common.logging import Logger from nautilus_trader.common.logging import LoggerAdapter from nautilus_trader.config import ImportableConfig from nautilus_trader.config import LiveDataClientConfig @@ -49,7 +49,7 @@ class TradingNodeBuilder: The cache for building clients. clock : LiveClock The clock for building clients. - logger : LiveLogger + logger : Logger The logger for building clients. log : LoggerAdapter The trading nodes logger. @@ -63,7 +63,7 @@ def __init__( msgbus: MessageBus, cache: Cache, clock: LiveClock, - logger: LiveLogger, + logger: Logger, log: LoggerAdapter, ): self._msgbus = msgbus diff --git a/nautilus_trader/system/kernel.py b/nautilus_trader/system/kernel.py index b4181c87534b..244c2131b817 100644 --- a/nautilus_trader/system/kernel.py +++ b/nautilus_trader/system/kernel.py @@ -31,7 +31,6 @@ from nautilus_trader.common.clock import LiveClock from nautilus_trader.common.clock import TestClock from nautilus_trader.common.enums import LogLevel -from nautilus_trader.common.logging import LiveLogger from nautilus_trader.common.logging import Logger from nautilus_trader.common.logging import LoggerAdapter from nautilus_trader.common.logging import nautilus_header @@ -205,29 +204,23 @@ def __init__( # noqa (too complex) # Components if self._environment == Environment.BACKTEST: self._clock = TestClock() - self._logger = Logger( - clock=LiveClock(loop=loop), - trader_id=self._trader_id, - machine_id=self._machine_id, - instance_id=self._instance_id, - level_stdout=log_level, - bypass=bypass_logging, - ) elif self.environment in (Environment.SANDBOX, Environment.LIVE): self._clock = LiveClock(loop=loop) - self._logger = LiveLogger( - loop=loop, - clock=self._clock, - trader_id=self._trader_id, - machine_id=self._machine_id, - instance_id=self._instance_id, - level_stdout=log_level, - ) + bypass_logging = False # Safety measure so live logging is visible else: raise NotImplementedError( # pragma: no cover (design-time error) f"environment {environment} not recognized", # pragma: no cover (design-time error) ) + self._logger = Logger( + clock=self._clock, + trader_id=self._trader_id, + machine_id=self._machine_id, + instance_id=self._instance_id, + level_stdout=log_level, + bypass=bypass_logging, + ) + # Setup logging self._log = LoggerAdapter( component_name=name, diff --git a/nautilus_trader/test_kit/stubs/component.py b/nautilus_trader/test_kit/stubs/component.py index 83e94de82c83..636335b24c0b 100644 --- a/nautilus_trader/test_kit/stubs/component.py +++ b/nautilus_trader/test_kit/stubs/component.py @@ -24,7 +24,7 @@ from nautilus_trader.common.clock import LiveClock from nautilus_trader.common.enums import log_level_from_str from nautilus_trader.common.factories import OrderFactory -from nautilus_trader.common.logging import LiveLogger +from nautilus_trader.common.logging import Logger from nautilus_trader.core.data import Data from nautilus_trader.model.currencies import USD from nautilus_trader.model.currency import Currency @@ -51,9 +51,8 @@ def clock() -> LiveClock: return LiveClock() @staticmethod - def logger(level="INFO") -> LiveLogger: - return LiveLogger( - loop=asyncio.get_event_loop(), + def logger(level="INFO") -> Logger: + return Logger( clock=TestComponentStubs.clock(), level_stdout=log_level_from_str(level), ) diff --git a/tests/integration_tests/adapters/betfair/test_betfair_account.py b/tests/integration_tests/adapters/betfair/test_betfair_account.py index fce382038447..723efbe0ce2a 100644 --- a/tests/integration_tests/adapters/betfair/test_betfair_account.py +++ b/tests/integration_tests/adapters/betfair/test_betfair_account.py @@ -20,7 +20,7 @@ from nautilus_trader.backtest.data.providers import TestInstrumentProvider from nautilus_trader.common.clock import LiveClock from nautilus_trader.common.enums import LogLevel -from nautilus_trader.common.logging import LiveLogger +from nautilus_trader.common.logging import Logger from nautilus_trader.model.objects import Price from nautilus_trader.model.objects import Quantity from nautilus_trader.msgbus.bus import MessageBus @@ -41,7 +41,7 @@ def setup(self): self.instrument = TestInstrumentProvider.betting_instrument() # Setup logging - self.logger = LiveLogger(loop=self.loop, clock=self.clock, level_stdout=LogLevel.DEBUG) + self.logger = Logger(clock=self.clock, level_stdout=LogLevel.DEBUG) self.msgbus = MessageBus( trader_id=TestIdStubs.trader_id(), diff --git a/tests/integration_tests/adapters/betfair/test_betfair_client.py b/tests/integration_tests/adapters/betfair/test_betfair_client.py index 1e65a354982b..40b062000e66 100644 --- a/tests/integration_tests/adapters/betfair/test_betfair_client.py +++ b/tests/integration_tests/adapters/betfair/test_betfair_client.py @@ -32,7 +32,7 @@ from nautilus_trader.adapters.betfair.parsing.requests import order_update_to_betfair from nautilus_trader.backtest.data.providers import TestInstrumentProvider from nautilus_trader.common.clock import LiveClock -from nautilus_trader.common.logging import LiveLogger +from nautilus_trader.common.logging import Logger from nautilus_trader.core.uuid import UUID4 from nautilus_trader.execution.messages import SubmitOrder from nautilus_trader.model.enums import OrderSide @@ -55,7 +55,7 @@ def setup(self): # Fixture Setup self.loop = asyncio.get_event_loop() self.clock = LiveClock() - self.logger = LiveLogger(loop=self.loop, clock=self.clock) + self.logger = Logger(clock=self.clock) self.client = BetfairClient( # noqa: S106 (no hardcoded password) username="username", password="password", diff --git a/tests/integration_tests/adapters/betfair/test_betfair_data.py b/tests/integration_tests/adapters/betfair/test_betfair_data.py index 2ebff35f7e96..4061c9e43866 100644 --- a/tests/integration_tests/adapters/betfair/test_betfair_data.py +++ b/tests/integration_tests/adapters/betfair/test_betfair_data.py @@ -35,7 +35,7 @@ from nautilus_trader.backtest.data.providers import TestInstrumentProvider from nautilus_trader.common.clock import LiveClock from nautilus_trader.common.enums import LogLevel -from nautilus_trader.common.logging import LiveLogger +from nautilus_trader.common.logging import Logger from nautilus_trader.common.logging import LoggerAdapter from nautilus_trader.core.uuid import UUID4 from nautilus_trader.live.data_engine import LiveDataEngine @@ -79,9 +79,8 @@ def instrument_list(mock_load_markets_metadata, loop: asyncio.AbstractEventLoop) global INSTRUMENTS # Setup - logger = LiveLogger(loop=loop, clock=LiveClock(), level_stdout=LogLevel.ERROR) + logger = Logger(clock=LiveClock(), level_stdout=LogLevel.ERROR) client = BetfairTestStubs.betfair_client(loop=loop, logger=logger) - logger = LiveLogger(loop=loop, clock=LiveClock(), level_stdout=LogLevel.DEBUG) instrument_provider = BetfairInstrumentProvider(client=client, logger=logger, filters={}) # Load instruments @@ -111,7 +110,7 @@ def setup(self): self.venue = BETFAIR_VENUE # Setup logging - self.logger = LiveLogger(loop=self.loop, clock=self.clock, level_stdout=LogLevel.ERROR) + self.logger = Logger(clock=self.clock, level_stdout=LogLevel.ERROR) self._log = LoggerAdapter("TestBetfairExecutionClient", self.logger) self.msgbus = MessageBus( diff --git a/tests/integration_tests/adapters/betfair/test_betfair_factory.py b/tests/integration_tests/adapters/betfair/test_betfair_factory.py index 5bf0494c0b6d..ab4c47d7e5f5 100644 --- a/tests/integration_tests/adapters/betfair/test_betfair_factory.py +++ b/tests/integration_tests/adapters/betfair/test_betfair_factory.py @@ -28,7 +28,7 @@ from nautilus_trader.adapters.betfair.factories import BetfairLiveExecClientFactory from nautilus_trader.common.clock import LiveClock from nautilus_trader.common.enums import LogLevel -from nautilus_trader.common.logging import LiveLogger +from nautilus_trader.common.logging import Logger from nautilus_trader.common.logging import LoggerAdapter from nautilus_trader.msgbus.bus import MessageBus from nautilus_trader.test_kit.stubs.component import TestComponentStubs @@ -47,7 +47,7 @@ def setup(self): self.venue = BETFAIR_VENUE # Setup logging - self.logger = LiveLogger(loop=self.loop, clock=self.clock, level_stdout=LogLevel.DEBUG) + self.logger = Logger(clock=self.clock, level_stdout=LogLevel.DEBUG) self._log = LoggerAdapter("TestBetfairExecutionClient", self.logger) self.msgbus = MessageBus( diff --git a/tests/integration_tests/adapters/betfair/test_betfair_parsing.py b/tests/integration_tests/adapters/betfair/test_betfair_parsing.py index 83cc5bab284e..a8c1091ccd81 100644 --- a/tests/integration_tests/adapters/betfair/test_betfair_parsing.py +++ b/tests/integration_tests/adapters/betfair/test_betfair_parsing.py @@ -49,7 +49,7 @@ ) from nautilus_trader.backtest.data.providers import TestInstrumentProvider from nautilus_trader.common.clock import LiveClock -from nautilus_trader.common.logging import LiveLogger +from nautilus_trader.common.logging import Logger from nautilus_trader.core.uuid import UUID4 from nautilus_trader.model.currencies import GBP from nautilus_trader.model.data.tick import TradeTick @@ -240,7 +240,7 @@ def setup(self): # Fixture Setup self.loop = asyncio.get_event_loop() self.clock = LiveClock() - self.logger = LiveLogger(loop=self.loop, clock=self.clock) + self.logger = Logger(clock=self.clock) self.instrument = TestInstrumentProvider.betting_instrument() self.client = BetfairTestStubs.betfair_client(loop=self.loop, logger=self.logger) self.provider = BetfairTestStubs.instrument_provider(self.client) diff --git a/tests/integration_tests/adapters/betfair/test_betfair_providers.py b/tests/integration_tests/adapters/betfair/test_betfair_providers.py index 4e920d628d50..5e4457182bd0 100644 --- a/tests/integration_tests/adapters/betfair/test_betfair_providers.py +++ b/tests/integration_tests/adapters/betfair/test_betfair_providers.py @@ -28,7 +28,7 @@ from nautilus_trader.adapters.betfair.providers import make_instruments from nautilus_trader.adapters.betfair.providers import parse_market_catalog from nautilus_trader.common.clock import LiveClock -from nautilus_trader.common.logging import LiveLogger +from nautilus_trader.common.logging import Logger from nautilus_trader.model.enums import MarketStatus from nautilus_trader.test_kit.stubs.component import TestComponentStubs from tests.integration_tests.adapters.betfair.test_kit import BetfairResponses @@ -42,7 +42,7 @@ def setup(self): # Fixture Setup self.loop = asyncio.get_event_loop() self.clock = LiveClock() - self.logger = LiveLogger(loop=self.loop, clock=self.clock) + self.logger = Logger(clock=self.clock) self.client = BetfairTestStubs.betfair_client(loop=self.loop, logger=self.logger) self.provider = BetfairInstrumentProvider( client=self.client, diff --git a/tests/integration_tests/adapters/betfair/test_betfair_sockets.py b/tests/integration_tests/adapters/betfair/test_betfair_sockets.py index 9459a1a01d64..b30c8f65eb9f 100644 --- a/tests/integration_tests/adapters/betfair/test_betfair_sockets.py +++ b/tests/integration_tests/adapters/betfair/test_betfair_sockets.py @@ -18,7 +18,7 @@ from nautilus_trader.adapters.betfair.sockets import BetfairMarketStreamClient from nautilus_trader.adapters.betfair.sockets import BetfairOrderStreamClient from nautilus_trader.common.clock import LiveClock -from nautilus_trader.common.logging import LiveLogger +from nautilus_trader.common.logging import Logger from tests.integration_tests.adapters.betfair.test_kit import BetfairTestStubs @@ -27,7 +27,7 @@ def setup(self): # Fixture Setup self.loop = asyncio.get_event_loop() self.clock = LiveClock() - self.logger = LiveLogger(loop=self.loop, clock=self.clock) + self.logger = Logger(clock=self.clock) self.client = BetfairTestStubs.betfair_client(loop=self.loop, logger=self.logger) def test_unique_id(self): diff --git a/tests/integration_tests/adapters/binance/sandbox/sandbox_ws_futures_market.py b/tests/integration_tests/adapters/binance/sandbox/sandbox_ws_futures_market.py index e93f6cb4f1fc..cdfd38aaf5d0 100644 --- a/tests/integration_tests/adapters/binance/sandbox/sandbox_ws_futures_market.py +++ b/tests/integration_tests/adapters/binance/sandbox/sandbox_ws_futures_market.py @@ -19,7 +19,7 @@ from nautilus_trader.adapters.binance.websocket.client import BinanceWebSocketClient from nautilus_trader.common.clock import LiveClock -from nautilus_trader.common.logging import LiveLogger +from nautilus_trader.common.logging import Logger @pytest.mark.asyncio @@ -30,7 +30,7 @@ async def test_binance_websocket_client(): client = BinanceWebSocketClient( loop=loop, clock=clock, - logger=LiveLogger(loop=loop, clock=clock), + logger=Logger(clock=clock), handler=print, base_url="wss://fstream.binance.com", ) diff --git a/tests/integration_tests/adapters/binance/sandbox/sandbox_ws_spot_user.py b/tests/integration_tests/adapters/binance/sandbox/sandbox_ws_spot_user.py index c36f2faffbcb..4911c5c705f0 100644 --- a/tests/integration_tests/adapters/binance/sandbox/sandbox_ws_spot_user.py +++ b/tests/integration_tests/adapters/binance/sandbox/sandbox_ws_spot_user.py @@ -23,7 +23,6 @@ from nautilus_trader.adapters.binance.spot.http.user import BinanceSpotUserDataHttpAPI from nautilus_trader.adapters.binance.websocket.client import BinanceWebSocketClient from nautilus_trader.common.clock import LiveClock -from nautilus_trader.common.logging import LiveLogger from nautilus_trader.common.logging import Logger @@ -49,7 +48,7 @@ async def test_binance_websocket_client(): ws = BinanceWebSocketClient( loop=loop, clock=clock, - logger=LiveLogger(loop=loop, clock=clock), + logger=Logger(clock=clock), handler=print, ) diff --git a/tests/integration_tests/adapters/binance/test_factories.py b/tests/integration_tests/adapters/binance/test_factories.py index b468e4292b33..156458b36ec9 100644 --- a/tests/integration_tests/adapters/binance/test_factories.py +++ b/tests/integration_tests/adapters/binance/test_factories.py @@ -31,7 +31,7 @@ from nautilus_trader.cache.cache import Cache from nautilus_trader.common.clock import LiveClock from nautilus_trader.common.enums import LogLevel -from nautilus_trader.common.logging import LiveLogger +from nautilus_trader.common.logging import Logger from nautilus_trader.msgbus.bus import MessageBus from nautilus_trader.test_kit.mocks.cache_database import MockCacheDatabase from nautilus_trader.test_kit.stubs.identifiers import TestIdStubs @@ -42,11 +42,7 @@ def setup(self): # Fixture Setup self.loop = asyncio.get_event_loop() self.clock = LiveClock() - self.logger = LiveLogger( - loop=self.loop, - clock=self.clock, - level_stdout=LogLevel.DEBUG, - ) + self.logger = Logger(clock=self.clock, level_stdout=LogLevel.DEBUG) self.trader_id = TestIdStubs.trader_id() self.strategy_id = TestIdStubs.strategy_id() diff --git a/tests/integration_tests/adapters/interactive_brokers/base.py b/tests/integration_tests/adapters/interactive_brokers/base.py index 8d12c1fe1175..b0eee1aa2061 100644 --- a/tests/integration_tests/adapters/interactive_brokers/base.py +++ b/tests/integration_tests/adapters/interactive_brokers/base.py @@ -19,7 +19,7 @@ from nautilus_trader.cache.cache import Cache from nautilus_trader.common.clock import LiveClock from nautilus_trader.common.enums import LogLevel -from nautilus_trader.common.logging import LiveLogger +from nautilus_trader.common.logging import Logger from nautilus_trader.msgbus.bus import MessageBus from nautilus_trader.test_kit.mocks.cache_database import MockCacheDatabase from nautilus_trader.test_kit.stubs.identifiers import TestIdStubs @@ -36,11 +36,7 @@ def setup(self): # Fixture Setup self.loop = asyncio.get_event_loop() self.clock = LiveClock() - self.logger = LiveLogger( - loop=self.loop, - clock=self.clock, - level_stdout=LogLevel.DEBUG, - ) + self.logger = Logger(clock=self.clock, level_stdout=LogLevel.DEBUG) self.trader_id = TestIdStubs.trader_id() self.strategy_id = TestIdStubs.strategy_id() diff --git a/tests/integration_tests/adapters/interactive_brokers/test_providers.py b/tests/integration_tests/adapters/interactive_brokers/test_providers.py index b92c73e6cf3b..fd77fd47830b 100644 --- a/tests/integration_tests/adapters/interactive_brokers/test_providers.py +++ b/tests/integration_tests/adapters/interactive_brokers/test_providers.py @@ -32,7 +32,7 @@ ) from nautilus_trader.common.clock import LiveClock from nautilus_trader.common.enums import LogLevel -from nautilus_trader.common.logging import LiveLogger +from nautilus_trader.common.logging import Logger from nautilus_trader.config import InstrumentProviderConfig from nautilus_trader.model.enums import AssetClass from nautilus_trader.model.enums import AssetType @@ -49,11 +49,7 @@ def setup(self): self.ib = MagicMock() self.loop = asyncio.get_event_loop() self.clock = LiveClock() - self.logger = LiveLogger( - loop=self.loop, - clock=self.clock, - level_stdout=LogLevel.DEBUG, - ) + self.logger = Logger(clock=self.clock, level_stdout=LogLevel.DEBUG) self.provider = InteractiveBrokersInstrumentProvider( client=self.ib, logger=self.logger, diff --git a/tests/integration_tests/adapters/sandbox/test_sandbox_execution.py b/tests/integration_tests/adapters/sandbox/test_sandbox_execution.py index afde455d1afb..5713bee9ee21 100644 --- a/tests/integration_tests/adapters/sandbox/test_sandbox_execution.py +++ b/tests/integration_tests/adapters/sandbox/test_sandbox_execution.py @@ -22,7 +22,7 @@ from nautilus_trader.backtest.exchange import SimulatedExchange from nautilus_trader.common.clock import LiveClock from nautilus_trader.common.enums import LogLevel -from nautilus_trader.common.logging import LiveLogger +from nautilus_trader.common.logging import Logger from nautilus_trader.common.logging import LoggerAdapter from nautilus_trader.config import LiveExecEngineConfig from nautilus_trader.live.data_engine import LiveDataEngine @@ -64,7 +64,7 @@ def setup(self): self.account_id = AccountId(f"{self.venue.value}-001") # Setup logging - self.logger = LiveLogger(loop=self.loop, clock=self.clock, level_stdout=LogLevel.DEBUG) + self.logger = Logger(clock=self.clock, level_stdout=LogLevel.DEBUG) self._log = LoggerAdapter("TestBetfairExecutionClient", self.logger) self.msgbus = MessageBus( diff --git a/tests/integration_tests/network/conftest.py b/tests/integration_tests/network/conftest.py index e4b017a67d05..1a7b87d1e79b 100644 --- a/tests/integration_tests/network/conftest.py +++ b/tests/integration_tests/network/conftest.py @@ -24,7 +24,7 @@ from aiohttp.test_utils import TestServer from nautilus_trader.common.clock import LiveClock -from nautilus_trader.common.logging import LiveLogger +from nautilus_trader.common.logging import Logger async def handle_echo(reader: asyncio.StreamReader, writer: asyncio.StreamWriter): @@ -107,4 +107,4 @@ async def on_shutdown(app): @pytest.fixture() def logger(event_loop): clock = LiveClock() - return LiveLogger(loop=event_loop, clock=clock) + return Logger(clock=clock) diff --git a/tests/unit_tests/common/test_common_logging.py b/tests/unit_tests/common/test_common_logging.py index da4da9b93031..78335f36c6c0 100644 --- a/tests/unit_tests/common/test_common_logging.py +++ b/tests/unit_tests/common/test_common_logging.py @@ -13,18 +13,15 @@ # limitations under the License. # ------------------------------------------------------------------------------------------------- -import asyncio import socket import pytest -from nautilus_trader.common.clock import LiveClock from nautilus_trader.common.clock import TestClock from nautilus_trader.common.enums import LogColor from nautilus_trader.common.enums import LogLevel from nautilus_trader.common.enums import log_level_from_str from nautilus_trader.common.enums import log_level_to_str -from nautilus_trader.common.logging import LiveLogger from nautilus_trader.common.logging import Logger from nautilus_trader.common.logging import LoggerAdapter @@ -193,79 +190,3 @@ def test_register_sink_sends_records_to_sink(self): "timestamp": 0, "trader_id": "TRADER-000", } - - -class TestLiveLogger: - def setup(self): - # Fixture Setup - self.loop = asyncio.get_event_loop() - self.loop.set_debug(True) - - self.logger = LiveLogger( - loop=self.loop, - clock=LiveClock(), - level_stdout=LogLevel.DEBUG, - ) - - self.logger_adapter = LoggerAdapter(component_name="LIVER_LOGGER", logger=self.logger) - - def test_log_when_not_running_on_event_loop_successfully_logs(self): - # Arrange, Act - self.logger_adapter.info("test message") - - # Assert - assert True # No exceptions raised - - @pytest.mark.asyncio - async def test_start_runs_on_event_loop(self): - # Arrange - self.logger.start() - - self.logger_adapter.info("A log message.") - await asyncio.sleep(0) - - # Act, Assert - assert self.logger.is_running - self.logger.stop() - - @pytest.mark.asyncio - async def test_stop_when_running_stops_logger(self): - # Arrange - self.logger.start() - - self.logger_adapter.info("A log message.") - await asyncio.sleep(0) - - # Act - self.logger.stop() - self.logger_adapter.info("A log message.") - - # Assert - assert not self.logger.is_running - - @pytest.mark.asyncio - async def test_log_when_queue_over_maxsize_blocks(self): - # Arrange - logger = LiveLogger( - loop=self.loop, - clock=LiveClock(), - maxsize=5, - ) - - logger_adapter = LoggerAdapter(component_name="LIVE_LOGGER", logger=logger) - logger.start() - - # Act - logger_adapter.info("A log message.") - logger_adapter.info("A log message.") # <-- blocks - logger_adapter.info("A different log message.") # <-- blocks - logger_adapter.info("A log message.") # <-- blocks - logger_adapter.info("A different log message.") # <-- blocks - logger_adapter.info("A log message.") # <-- blocks - - await asyncio.sleep(0.3) # <-- processes all log messages - logger.stop() - await asyncio.sleep(0.3) - - # Assert - assert not logger.is_running diff --git a/tests/unit_tests/live/test_live_execution_recon.py b/tests/unit_tests/live/test_live_execution_recon.py index 2acfbf978cd3..978a4bb7ab71 100644 --- a/tests/unit_tests/live/test_live_execution_recon.py +++ b/tests/unit_tests/live/test_live_execution_recon.py @@ -22,7 +22,7 @@ from nautilus_trader.backtest.data.providers import TestInstrumentProvider from nautilus_trader.common.clock import LiveClock from nautilus_trader.common.factories import OrderFactory -from nautilus_trader.common.logging import LiveLogger +from nautilus_trader.common.logging import Logger from nautilus_trader.common.providers import InstrumentProvider from nautilus_trader.core.uuid import UUID4 from nautilus_trader.execution.reports import OrderStatusReport @@ -68,7 +68,7 @@ def setup(self): self.loop.set_debug(True) self.clock = LiveClock() - self.logger = LiveLogger(self.loop, self.clock) + self.logger = Logger(self.clock) self.account_id = TestIdStubs.account_id() self.trader_id = TestIdStubs.trader_id() From 6cb779f95f8168a7cf421ec91f22a4d1fdd6b98f Mon Sep 17 00:00:00 2001 From: Chris Sellers Date: Sat, 4 Feb 2023 19:48:01 +1100 Subject: [PATCH 19/81] Pass rate_limit config through to Rust logger --- nautilus_core/common/src/logging.rs | 3 ++- nautilus_trader/common/logging.pxd | 3 --- nautilus_trader/common/logging.pyx | 11 ++++------- nautilus_trader/core/includes/common.h | 1 + nautilus_trader/core/rust/common.pxd | 1 + 5 files changed, 8 insertions(+), 11 deletions(-) diff --git a/nautilus_core/common/src/logging.rs b/nautilus_core/common/src/logging.rs index f21dd82e7717..ea70b4af2a6c 100644 --- a/nautilus_core/common/src/logging.rs +++ b/nautilus_core/common/src/logging.rs @@ -275,6 +275,7 @@ pub unsafe extern "C" fn logger_new( machine_id_ptr: *const c_char, instance_id_ptr: *const c_char, level_stdout: LogLevel, + rate_limit: usize, is_bypassed: u8, ) -> CLogger { CLogger(Box::new(Logger::new( @@ -282,7 +283,7 @@ pub unsafe extern "C" fn logger_new( String::from(&cstr_to_string(machine_id_ptr)), UUID4::from(cstr_to_string(instance_id_ptr).as_str()), level_stdout, - 100_000, // TODO(cs): Hardcoded for the moment + rate_limit, is_bypassed != 0, ))) } diff --git a/nautilus_trader/common/logging.pxd b/nautilus_trader/common/logging.pxd index ab0828c39134..7c3988d9f663 100644 --- a/nautilus_trader/common/logging.pxd +++ b/nautilus_trader/common/logging.pxd @@ -15,13 +15,10 @@ from typing import Callable -from cpython.datetime cimport datetime -from cpython.datetime cimport timedelta from libc.stdint cimport uint64_t from nautilus_trader.common.clock cimport Clock from nautilus_trader.common.logging cimport Logger -from nautilus_trader.common.queue cimport Queue from nautilus_trader.core.rust.common cimport CLogger from nautilus_trader.core.rust.common cimport LogColor from nautilus_trader.core.rust.common cimport LogLevel diff --git a/nautilus_trader/common/logging.pyx b/nautilus_trader/common/logging.pyx index a1d8a226490b..bce6dedb5e0d 100644 --- a/nautilus_trader/common/logging.pyx +++ b/nautilus_trader/common/logging.pyx @@ -13,15 +13,11 @@ # limitations under the License. # ------------------------------------------------------------------------------------------------- -import asyncio import platform import socket import sys import traceback -from asyncio import Task -from collections import defaultdict from platform import python_version -from typing import Optional import aiohttp import msgspec @@ -33,14 +29,11 @@ import pytz from nautilus_trader import __version__ -from cpython.datetime cimport timedelta from libc.stdint cimport uint64_t from nautilus_trader.common.clock cimport Clock -from nautilus_trader.common.clock cimport LiveClock from nautilus_trader.common.enums_c cimport log_level_to_str from nautilus_trader.common.logging cimport Logger -from nautilus_trader.common.queue cimport Queue from nautilus_trader.core.correctness cimport Condition from nautilus_trader.core.rust.common cimport LogColor from nautilus_trader.core.rust.common cimport LogLevel @@ -83,6 +76,8 @@ cdef class Logger: The instance ID. level_stdout : LogLevel The minimum log level for logging messages to stdout. + rate_limit : int, default 100_000 + The maximum messages per second which can be flushed to stdout or stderr. bypass : bool If the logger should be bypassed. """ @@ -94,6 +89,7 @@ cdef class Logger: str machine_id = None, UUID4 instance_id = None, LogLevel level_stdout = LogLevel.INFO, + int rate_limit = 100_000, bint bypass = False, ): if trader_id is None: @@ -112,6 +108,7 @@ cdef class Logger: pystr_to_cstr(machine_id), pystr_to_cstr(instance_id_str), level_stdout, + rate_limit, bypass, ) self._sinks = [] diff --git a/nautilus_trader/core/includes/common.h b/nautilus_trader/core/includes/common.h index 6fe80fcbb6f4..65556eeaeb40 100644 --- a/nautilus_trader/core/includes/common.h +++ b/nautilus_trader/core/includes/common.h @@ -210,6 +210,7 @@ struct CLogger logger_new(const char *trader_id_ptr, const char *machine_id_ptr, const char *instance_id_ptr, enum LogLevel level_stdout, + uintptr_t rate_limit, uint8_t is_bypassed); void logger_free(struct CLogger logger); diff --git a/nautilus_trader/core/rust/common.pxd b/nautilus_trader/core/rust/common.pxd index 901a8671687a..040e20feb007 100644 --- a/nautilus_trader/core/rust/common.pxd +++ b/nautilus_trader/core/rust/common.pxd @@ -172,6 +172,7 @@ cdef extern from "../includes/common.h": const char *machine_id_ptr, const char *instance_id_ptr, LogLevel level_stdout, + uintptr_t rate_limit, uint8_t is_bypassed); void logger_free(CLogger logger); From d0cfac3b6dcc5d992f1db08b034694ef4b5c295e Mon Sep 17 00:00:00 2001 From: Chris Sellers Date: Sat, 4 Feb 2023 19:52:35 +1100 Subject: [PATCH 20/81] Pass rate_limit config through to Rust logger --- nautilus_trader/adapters/binance/common/data.py | 1 - 1 file changed, 1 deletion(-) diff --git a/nautilus_trader/adapters/binance/common/data.py b/nautilus_trader/adapters/binance/common/data.py index 81dcda29bace..586e5ecd7fd9 100644 --- a/nautilus_trader/adapters/binance/common/data.py +++ b/nautilus_trader/adapters/binance/common/data.py @@ -182,7 +182,6 @@ async def _connect(self) -> None: self._log.info("Initialising instruments...") await self._instrument_provider.initialize() - self._log.info("Connected!") self._send_all_instruments_to_data_engine() self._update_instruments_task = self.create_task(self._update_instruments()) From b135a63da13686c56d0254497754f71c92cca9ea Mon Sep 17 00:00:00 2001 From: Chris Sellers Date: Sat, 4 Feb 2023 20:21:04 +1100 Subject: [PATCH 21/81] Add log_rate_limit to NautilusKernelConfig --- nautilus_trader/adapters/interactive_brokers/data.py | 2 +- nautilus_trader/backtest/engine.pyx | 1 + nautilus_trader/config/common.py | 3 +++ nautilus_trader/live/node.py | 1 + nautilus_trader/system/kernel.py | 4 ++++ 5 files changed, 10 insertions(+), 1 deletion(-) diff --git a/nautilus_trader/adapters/interactive_brokers/data.py b/nautilus_trader/adapters/interactive_brokers/data.py index ed09627fbf82..d38a712ca7e8 100644 --- a/nautilus_trader/adapters/interactive_brokers/data.py +++ b/nautilus_trader/adapters/interactive_brokers/data.py @@ -14,6 +14,7 @@ # ------------------------------------------------------------------------------------------------- import asyncio +from collections import defaultdict from functools import partial from typing import Callable, Optional @@ -39,7 +40,6 @@ from nautilus_trader.cache.cache import Cache from nautilus_trader.common.clock import LiveClock from nautilus_trader.common.logging import Logger -from nautilus_trader.common.logging import defaultdict from nautilus_trader.core.datetime import dt_to_unix_nanos from nautilus_trader.live.data_client import LiveMarketDataClient from nautilus_trader.model.data.bar import Bar diff --git a/nautilus_trader/backtest/engine.pyx b/nautilus_trader/backtest/engine.pyx index ace2fb2668c5..2cbbbe9f52da 100644 --- a/nautilus_trader/backtest/engine.pyx +++ b/nautilus_trader/backtest/engine.pyx @@ -137,6 +137,7 @@ cdef class BacktestEngine: load_state=config.load_state, save_state=config.save_state, log_level=log_level_from_str(config.log_level.upper()), + log_rate_limit=config.log_rate_limit, bypass_logging=config.bypass_logging, ) diff --git a/nautilus_trader/config/common.py b/nautilus_trader/config/common.py index a256fe1f2756..ac2d587ae9b3 100644 --- a/nautilus_trader/config/common.py +++ b/nautilus_trader/config/common.py @@ -470,6 +470,8 @@ class NautilusKernelConfig(NautilusConfig): If the asyncio event loop should be in debug mode. log_level : str, default "INFO" The stdout log level for the node. + log_rate_limit : int, default 100_000 + The maximum messages per second which can be flushed to stdout or stderr. bypass_logging : bool, default False If logging to stdout should be bypassed. """ @@ -489,6 +491,7 @@ class NautilusKernelConfig(NautilusConfig): save_state: bool = False loop_debug: bool = False log_level: str = "INFO" + log_rate_limit: int = 100_000 bypass_logging: bool = False diff --git a/nautilus_trader/live/node.py b/nautilus_trader/live/node.py index d775dd1446ed..da4538aea9f2 100644 --- a/nautilus_trader/live/node.py +++ b/nautilus_trader/live/node.py @@ -80,6 +80,7 @@ def __init__(self, config: Optional[TradingNodeConfig] = None): save_state=config.save_state, loop_sig_callback=self._loop_sig_handler, log_level=log_level_from_str(config.log_level.upper()), + log_rate_limit=config.log_rate_limit, ) self._builder = TradingNodeBuilder( diff --git a/nautilus_trader/system/kernel.py b/nautilus_trader/system/kernel.py index 244c2131b817..b9a98ac4dbc5 100644 --- a/nautilus_trader/system/kernel.py +++ b/nautilus_trader/system/kernel.py @@ -121,6 +121,8 @@ class NautilusKernel: If strategy state should be saved on stop. log_level : LogLevel, default LogLevel.INFO The log level for the kernels logger. + log_rate_limit : int, default 100_000 + The maximum messages per second which can be flushed to stdout or stderr. bypass_logging : bool, default False If logging to stdout should be bypassed. @@ -155,6 +157,7 @@ def __init__( # noqa (too complex) load_state: bool = False, save_state: bool = False, log_level: LogLevel = LogLevel.INFO, + log_rate_limit: int = 100_000, bypass_logging: bool = False, ): PyCondition.not_none(environment, "environment") @@ -218,6 +221,7 @@ def __init__( # noqa (too complex) machine_id=self._machine_id, instance_id=self._instance_id, level_stdout=log_level, + rate_limit=log_rate_limit, bypass=bypass_logging, ) From 8d3747c5257b4d73b9eae649c0c2407f11b0eb4e Mon Sep 17 00:00:00 2001 From: ghill2 Date: Thu, 9 Feb 2023 02:04:17 +0000 Subject: [PATCH 22/81] Implement rust batching (#990) --- nautilus_trader/backtest/node.py | 17 +- nautilus_trader/common/actor.pyx | 2 +- nautilus_trader/config/backtest.py | 18 +- nautilus_trader/persistence/batching.py | 260 ------- .../persistence/catalog/parquet.py | 71 +- .../persistence/streaming/__init__.py | 0 .../persistence/streaming/batching.py | 156 +++++ .../persistence/streaming/engine.py | 238 +++++++ .../{streaming.py => streaming/writer.py} | 15 - nautilus_trader/system/kernel.py | 2 +- .../backtest/test_backtest_config.py | 14 +- tests/unit_tests/common/test_common_actor.py | 2 +- .../persistence/external/test_core.py | 1 + tests/unit_tests/persistence/test_catalog.py | 14 +- .../unit_tests/persistence/test_streaming.py | 2 +- ...batching.py => test_streaming_batching.py} | 208 ++---- .../persistence/test_streaming_engine.py | 635 ++++++++++++++++++ 17 files changed, 1161 insertions(+), 494 deletions(-) create mode 100644 nautilus_trader/persistence/streaming/__init__.py create mode 100644 nautilus_trader/persistence/streaming/batching.py create mode 100644 nautilus_trader/persistence/streaming/engine.py rename nautilus_trader/persistence/{streaming.py => streaming/writer.py} (89%) rename tests/unit_tests/persistence/{test_batching.py => test_streaming_batching.py} (62%) create mode 100644 tests/unit_tests/persistence/test_streaming_engine.py diff --git a/nautilus_trader/backtest/node.py b/nautilus_trader/backtest/node.py index 33a87ea43803..e3974e3fcf7a 100644 --- a/nautilus_trader/backtest/node.py +++ b/nautilus_trader/backtest/node.py @@ -37,10 +37,9 @@ from nautilus_trader.model.identifiers import InstrumentId from nautilus_trader.model.identifiers import Venue from nautilus_trader.model.objects import Money -from nautilus_trader.persistence.batching import batch_files -from nautilus_trader.persistence.batching import extract_generic_data_client_ids -from nautilus_trader.persistence.batching import groupby_datatype -from nautilus_trader.persistence.catalog.parquet import ParquetDataCatalog +from nautilus_trader.persistence.streaming.engine import StreamingEngine +from nautilus_trader.persistence.streaming.engine import extract_generic_data_client_ids +from nautilus_trader.persistence.streaming.engine import groupby_datatype class BacktestNode: @@ -253,16 +252,14 @@ def _run_streaming( data_configs: list[BacktestDataConfig], batch_size_bytes: int, ) -> None: - config = data_configs[0] - catalog: ParquetDataCatalog = config.catalog() - data_client_ids = extract_generic_data_client_ids(data_configs=data_configs) - for batch in batch_files( - catalog=catalog, + streaming_engine = StreamingEngine( data_configs=data_configs, target_batch_size_bytes=batch_size_bytes, - ): + ) + + for batch in streaming_engine: engine.clear_data() grouped = groupby_datatype(batch) for data in grouped: diff --git a/nautilus_trader/common/actor.pyx b/nautilus_trader/common/actor.pyx index 3c611fdb6e60..d85d06b5ef40 100644 --- a/nautilus_trader/common/actor.pyx +++ b/nautilus_trader/common/actor.pyx @@ -30,7 +30,7 @@ import cython from nautilus_trader.config import ActorConfig from nautilus_trader.config import ImportableActorConfig -from nautilus_trader.persistence.streaming import generate_signal_class +from nautilus_trader.persistence.streaming.writer import generate_signal_class from cpython.datetime cimport datetime from libc.stdint cimport uint64_t diff --git a/nautilus_trader/config/backtest.py b/nautilus_trader/config/backtest.py index 49aad56d6ad7..850cbd522989 100644 --- a/nautilus_trader/config/backtest.py +++ b/nautilus_trader/config/backtest.py @@ -30,6 +30,7 @@ from nautilus_trader.config.common import NautilusKernelConfig from nautilus_trader.config.common import RiskEngineConfig from nautilus_trader.core.datetime import maybe_dt_to_unix_nanos +from nautilus_trader.model.data.bar import Bar from nautilus_trader.model.identifiers import ClientId @@ -68,6 +69,9 @@ class BacktestDataConfig(NautilusConfig): filter_expr: Optional[str] = None client_id: Optional[str] = None metadata: Optional[dict] = None + bar_spec: Optional[str] = None + use_rust: Optional[bool] = False + batch_size: Optional[int] = 10_000 @property def data_type(self): @@ -80,13 +84,21 @@ def data_type(self): @property def query(self): + if self.data_cls is Bar and self.bar_spec: + bar_type = f"{self.instrument_id}-{self.bar_spec}-EXTERNAL" + filter_expr = f'field("bar_type") == "{bar_type}"' + else: + filter_expr = self.filter_expr + return dict( cls=self.data_type, instrument_ids=[self.instrument_id] if self.instrument_id else None, start=self.start_time, end=self.end_time, - filter_expr=self.filter_expr, + filter_expr=parse_filters_expr(filter_expr), as_nautilus=True, + metadata=self.metadata, + use_rust=self.use_rust, ) @property @@ -114,14 +126,14 @@ def load( self, start_time: Optional[pd.Timestamp] = None, end_time: Optional[pd.Timestamp] = None, + as_nautilus: bool = True, ): query = self.query query.update( { "start": start_time or query["start"], "end": end_time or query["end"], - "filter_expr": parse_filters_expr(query.pop("filter_expr", "None")), - "metadata": self.metadata, + "as_nautilus": as_nautilus, }, ) diff --git a/nautilus_trader/persistence/batching.py b/nautilus_trader/persistence/batching.py index c92dd9fd2f5d..e69de29bb2d1 100644 --- a/nautilus_trader/persistence/batching.py +++ b/nautilus_trader/persistence/batching.py @@ -1,260 +0,0 @@ -# ------------------------------------------------------------------------------------------------- -# Copyright (C) 2015-2023 Nautech Systems Pty Ltd. All rights reserved. -# https://nautechsystems.io -# -# Licensed under the GNU Lesser General Public License Version 3.0 (the "License"); -# You may not use this file except in compliance with the License. -# You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# ------------------------------------------------------------------------------------------------- - -import heapq -import itertools -import sys -from collections import namedtuple -from collections.abc import Iterator -from pathlib import Path - -import fsspec -import numpy as np -import pandas as pd -import pyarrow.dataset as ds -import pyarrow.parquet as pq -from pyarrow.lib import ArrowInvalid - -from nautilus_trader.config import BacktestDataConfig -from nautilus_trader.core.nautilus_pyo3.persistence import ParquetReader -from nautilus_trader.core.nautilus_pyo3.persistence import ParquetReaderType -from nautilus_trader.model.data.tick import QuoteTick -from nautilus_trader.model.data.tick import TradeTick -from nautilus_trader.persistence.catalog.parquet import ParquetDataCatalog -from nautilus_trader.persistence.external.util import py_type_to_parquet_type -from nautilus_trader.persistence.funcs import parse_bytes -from nautilus_trader.serialization.arrow.serializer import ParquetSerializer -from nautilus_trader.serialization.arrow.util import clean_key - - -FileMeta = namedtuple("FileMeta", "filename datatype instrument_id client_id start end") - - -def _generate_batches( - files: list[str], - cls: type, - fs: fsspec.AbstractFileSystem, - use_rust: bool = False, - n_rows: int = 10_000, -): - use_rust = use_rust and cls in (QuoteTick, TradeTick) - files = sorted(files, key=lambda x: Path(x).stem) - for file in files: - if use_rust: - reader = ParquetReader( - file, - n_rows, - py_type_to_parquet_type(cls), - ParquetReaderType.File, - ) - - for capsule in reader: - # PyCapsule > List - if cls == QuoteTick: - objs = QuoteTick.list_from_capsule(capsule) - elif cls == TradeTick: - objs = TradeTick.list_from_capsule(capsule) - - yield objs - else: - for batch in pq.ParquetFile(fs.open(file)).iter_batches(batch_size=n_rows): - if batch.num_rows == 0: - break - objs = ParquetSerializer.deserialize(cls=cls, chunk=batch.to_pylist()) - yield objs - - -def generate_batches( - files: list[str], - cls: type, - fs: fsspec.AbstractFileSystem, - use_rust: bool = False, - n_rows: int = 10_000, - start_time: int = None, - end_time: int = None, -): - if start_time is None: - start_time = 0 - - if end_time is None: - end_time = sys.maxsize - - batches = _generate_batches(files, cls, fs, use_rust=use_rust, n_rows=n_rows) - - start = start_time - end = end_time - started = False - for batch in batches: - min = batch[0].ts_init - max = batch[-1].ts_init - if min < start and max < start: - batch = [] # not started yet - - if max >= start and not started: - timestamps = np.array([x.ts_init for x in batch]) - mask = timestamps >= start - masked = list(itertools.compress(batch, mask)) - batch = masked - started = True - - if max > end: - timestamps = np.array([x.ts_init for x in batch]) - mask = timestamps <= end - masked = list(itertools.compress(batch, mask)) - batch = masked - if batch: - yield batch - return # stop iterating - - yield batch - - -def dataset_batches( - file_meta: FileMeta, - fs: fsspec.AbstractFileSystem, - n_rows: int, -) -> Iterator[pd.DataFrame]: - try: - d: ds.Dataset = ds.dataset(file_meta.filename, filesystem=fs) - except ArrowInvalid: - return - for fn in sorted(map(str, d.files)): - f = pq.ParquetFile(fs.open(fn)) - for batch in f.iter_batches(batch_size=n_rows): - if batch.num_rows == 0: - break - df = batch.to_pandas() - df = df[(df["ts_init"] >= file_meta.start) & (df["ts_init"] <= file_meta.end)] - if df.empty: - continue - if file_meta.instrument_id: - df.loc[:, "instrument_id"] = file_meta.instrument_id - yield df - - -def build_filenames( - catalog: ParquetDataCatalog, - data_configs: list[BacktestDataConfig], -) -> list[FileMeta]: - files = [] - for config in data_configs: - filename = catalog._make_path(cls=config.data_type) - if config.instrument_id: - filename += f"/instrument_id={clean_key(config.instrument_id)}" - if not catalog.fs.exists(filename): - continue - files.append( - FileMeta( - filename=filename, - datatype=config.data_type, - instrument_id=config.instrument_id, - client_id=config.client_id, - start=config.start_time_nanos, - end=config.end_time_nanos, - ), - ) - return files - - -def frame_to_nautilus(df: pd.DataFrame, cls: type): - return ParquetSerializer.deserialize(cls=cls, chunk=df.to_dict("records")) - - -def batch_files( # noqa: C901 - catalog: ParquetDataCatalog, - data_configs: list[BacktestDataConfig], - read_num_rows: int = 10_000, - target_batch_size_bytes: int = parse_bytes("100mb"), # noqa: B008, -): - files = build_filenames(catalog=catalog, data_configs=data_configs) - buffer = {fn.filename: pd.DataFrame() for fn in files} - datasets = { - f.filename: dataset_batches(file_meta=f, fs=catalog.fs, n_rows=read_num_rows) for f in files - } - completed: set[str] = set() - bytes_read = 0 - values = [] - sent_count = 0 - while {f.filename for f in files} != completed: - # Fill buffer (if required) - for fn in buffer: - if len(buffer[fn]) < read_num_rows: - next_buf = next(datasets[fn], None) - if next_buf is None: - completed.add(fn) - continue - buffer[fn] = pd.concat([buffer[fn], next_buf]) - - # Determine minimum timestamp - max_ts_per_frame = {fn: df["ts_init"].max() for fn, df in buffer.items() if not df.empty} - if not max_ts_per_frame: - continue - min_ts = min(max_ts_per_frame.values()) - - # Filter buffer dataframes based on min_timestamp - batches = [] - for f in files: - df = buffer[f.filename] - if df.empty: - continue - ts_filter = df["ts_init"] <= min_ts # min of max timestamps - batch = df[ts_filter] - buffer[f.filename] = df[~ts_filter] - objs = frame_to_nautilus(df=batch, cls=f.datatype) - batches.append(objs) - bytes_read += sum([sys.getsizeof(x) for x in objs]) - - # Merge ticks - values.extend(list(heapq.merge(*batches, key=lambda x: x.ts_init))) - if bytes_read > target_batch_size_bytes: - yield values - sent_count += len(values) - bytes_read = 0 - values = [] - - if values: - yield values - sent_count += len(values) - - if sent_count == 0: - raise ValueError("No data found, check data_configs") - - -def groupby_datatype(data): - def _groupby_key(x): - return type(x).__name__ - - return [ - {"type": type(v[0]), "data": v} - for v in [ - list(v) for _, v in itertools.groupby(sorted(data, key=_groupby_key), key=_groupby_key) - ] - ] - - -def extract_generic_data_client_ids(data_configs: list[BacktestDataConfig]) -> dict: - """ - Extract a mapping of data_type : client_id from the list of `data_configs`. - In the process of merging the streaming data, we lose the `client_id` for - generic data, we need to inject this back in so the backtest engine can be - correctly loaded. - """ - data_client_ids = [ - (config.data_type, config.client_id) for config in data_configs if config.client_id - ] - assert len(set(data_client_ids)) == len( - dict(data_client_ids), - ), "data_type found with multiple client_ids" - return dict(data_client_ids) diff --git a/nautilus_trader/persistence/catalog/parquet.py b/nautilus_trader/persistence/catalog/parquet.py index cf26f7987542..89283d5ffb2c 100644 --- a/nautilus_trader/persistence/catalog/parquet.py +++ b/nautilus_trader/persistence/catalog/parquet.py @@ -34,6 +34,8 @@ from nautilus_trader.core.datetime import dt_to_unix_nanos from nautilus_trader.core.inspect import is_nautilus_class +from nautilus_trader.model.data.bar import Bar +from nautilus_trader.model.data.bar import BarSpecification from nautilus_trader.model.data.base import DataType from nautilus_trader.model.data.base import GenericData from nautilus_trader.model.data.tick import QuoteTick @@ -42,6 +44,7 @@ from nautilus_trader.persistence.catalog.base import BaseDataCatalog from nautilus_trader.persistence.external.metadata import load_mappings from nautilus_trader.persistence.external.util import is_filename_in_time_range +from nautilus_trader.persistence.streaming.batching import generate_batches_rust from nautilus_trader.serialization.arrow.serializer import ParquetSerializer from nautilus_trader.serialization.arrow.serializer import list_schemas from nautilus_trader.serialization.arrow.util import camel_to_snake_case @@ -155,7 +158,7 @@ def _query( # noqa (too complex) if end is not None: filters.append(ds.field(ts_column) <= pd.Timestamp(end).value) - full_path = self._make_path(cls=cls) + full_path = self.make_path(cls=cls) if not (self.fs.exists(full_path) or self.fs.isdir(full_path)): if raise_on_empty: @@ -163,6 +166,7 @@ def _query( # noqa (too complex) else: return pd.DataFrame() if as_dataframe else None + # Load rust objects if isinstance(start, int) or start is None: start_nanos = start else: @@ -173,34 +177,26 @@ def _query( # noqa (too complex) else: end_nanos = dt_to_unix_nanos(end) # datetime > nanos - # Load rust objects use_rust = kwargs.get("use_rust") and cls in (QuoteTick, TradeTick) if use_rust and kwargs.get("as_nautilus"): - # start_nanos = int(pd.Timestamp(end).to_datetime64()) if start else None - # end_nanos = int(pd.Timestamp(end).to_datetime64()) if end else None - from nautilus_trader.persistence.batching import ( - generate_batches, # avoid circular import error - ) - assert instrument_ids is not None assert len(instrument_ids) > 0 to_merge = [] for instrument_id in instrument_ids: - files = self._get_files(cls, instrument_id, start_nanos, end_nanos) + files = self.get_files(cls, instrument_id, start_nanos, end_nanos) if raise_on_empty and not files: raise RuntimeError("No files found.") - batches = generate_batches( + + batches = generate_batches_rust( files=files, cls=cls, - fs=self.fs, - use_rust=True, - n_rows=sys.maxsize, - start_time=start_nanos, - end_time=end_nanos, + batch_size=sys.maxsize, + start_nanos=start_nanos, + end_nanos=end_nanos, ) - objs = list(itertools.chain(*batches)) + objs = list(itertools.chain.from_iterable(batches)) if len(instrument_ids) == 1: return objs # skip merge, only 1 instrument to_merge.append(objs) @@ -243,14 +239,51 @@ def _query( # noqa (too complex) else: return self._handle_table_nautilus(table=table, cls=cls, mappings=mappings) - def make_path(self, cls: type, instrument_id: Optional[str] = None, clean=True) -> str: + def make_path(self, cls: type, instrument_id: Optional[str] = None) -> str: path = f"{self.path}/data/{class_to_filename(cls=cls)}.parquet" if instrument_id is not None: path += f"/instrument_id={clean_key(instrument_id)}" return path - def _make_path(self, cls: type) -> str: - return f"{self.path}/data/{class_to_filename(cls=cls)}.parquet" + def get_files( + self, + cls: type, + instrument_id: Optional[str] = None, + start_nanos: Optional[int] = None, + end_nanos: Optional[int] = None, + bar_spec: Optional[BarSpecification] = None, + ) -> list[str]: + if instrument_id is None: + folder = self.path + else: + folder = self.make_path(cls=cls, instrument_id=instrument_id) + + "/var/folders/fc/g4mqb35j0jvf7zpj4k76j4yw0000gn/T/tmp7cdq2cbx/data/order_book_data.parquet/instrument_id=1.166564490-237491-0.0.BETFAIR" + "/var/folders/fc/g4mqb35j0jvf7zpj4k76j4yw0000gn/T/tmp7cdq2cbx/data/order_book_data.parquet/instrument_id=1.166564490-237491-0.0.BETFAIR" + + if not self.fs.isdir(folder): + return [] + + paths = self.fs.glob(f"{folder}/**") + + file_paths = [] + for path in paths: + # Filter by BarType + bar_spec_matched = False + if cls is Bar: + bar_spec_matched = bar_spec and str(bar_spec) in path + if not bar_spec_matched: + continue + + # Filter by time range + file_path = pathlib.PurePosixPath(path).name + matched = is_filename_in_time_range(file_path, start_nanos, end_nanos) + if matched: + file_paths.append(str(path)) + + file_paths = sorted(file_paths, key=lambda x: Path(x).stem) + + return file_paths def _get_files( self, diff --git a/nautilus_trader/persistence/streaming/__init__.py b/nautilus_trader/persistence/streaming/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/nautilus_trader/persistence/streaming/batching.py b/nautilus_trader/persistence/streaming/batching.py new file mode 100644 index 000000000000..2440ea65bf76 --- /dev/null +++ b/nautilus_trader/persistence/streaming/batching.py @@ -0,0 +1,156 @@ +# ------------------------------------------------------------------------------------------------- +# Copyright (C) 2015-2023 Nautech Systems Pty Ltd. All rights reserved. +# https://nautechsystems.io +# +# Licensed under the GNU Lesser General Public License Version 3.0 (the "License"); +# You may not use this file except in compliance with the License. +# You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ------------------------------------------------------------------------------------------------- + +import itertools +import sys +from collections.abc import Generator +from pathlib import Path +from typing import Optional, Union + +import fsspec +import numpy as np +import pyarrow as pa +import pyarrow.parquet as pq + +from nautilus_trader.core.data import Data +from nautilus_trader.core.nautilus_pyo3.persistence import ParquetReader +from nautilus_trader.core.nautilus_pyo3.persistence import ParquetReaderType +from nautilus_trader.model.data.tick import QuoteTick +from nautilus_trader.model.data.tick import TradeTick +from nautilus_trader.model.identifiers import InstrumentId +from nautilus_trader.persistence.external.util import py_type_to_parquet_type +from nautilus_trader.serialization.arrow.serializer import ParquetSerializer + + +def _generate_batches_within_time_range( + batches: Generator[list[Data], None, None], + start_nanos: int = None, + end_nanos: int = None, +) -> Generator[list[Data], None, None]: + if start_nanos is None and end_nanos is None: + yield from batches + return + + if start_nanos is None: + start_nanos = 0 + + if end_nanos is None: + end_nanos = sys.maxsize + + start = start_nanos + end = end_nanos + started = False + for batch in batches: + min = batch[0].ts_init + max = batch[-1].ts_init + if min < start and max < start: + batch = [] # not started yet + + if max >= start and not started: + timestamps = np.array([x.ts_init for x in batch]) + mask = timestamps >= start + masked = list(itertools.compress(batch, mask)) + batch = masked + started = True + + if max > end: + timestamps = np.array([x.ts_init for x in batch]) + mask = timestamps <= end + masked = list(itertools.compress(batch, mask)) + batch = masked + if batch: + yield batch + return # stop iterating + + yield batch + + +def _generate_batches_rust( + files: list[str], + cls: type, + batch_size: int = 10_000, +) -> Generator[list[Union[QuoteTick, TradeTick]], None, None]: + assert cls in (QuoteTick, TradeTick) + + files = sorted(files, key=lambda x: Path(x).stem) + for file in files: + reader = ParquetReader( + file, + batch_size, + py_type_to_parquet_type(cls), + ParquetReaderType.File, + ) + for capsule in reader: + # PyCapsule > List + if cls == QuoteTick: + objs = QuoteTick.list_from_capsule(capsule) + elif cls == TradeTick: + objs = TradeTick.list_from_capsule(capsule) + + yield objs + + +def generate_batches_rust( + files: list[str], + cls: type, + batch_size: int = 10_000, + start_nanos: int = None, + end_nanos: int = None, +) -> Generator[list[Data], None, None]: + batches = _generate_batches_rust(files=files, cls=cls, batch_size=batch_size) + yield from _generate_batches_within_time_range(batches, start_nanos, end_nanos) + + +def _generate_batches( + files: list[str], + cls: type, + fs: fsspec.AbstractFileSystem, + instrument_id: Optional[InstrumentId] = None, # should be stored in metadata of parquet file? + batch_size: int = 10_000, +) -> Generator[list[Data], None, None]: + files = sorted(files, key=lambda x: Path(x).stem) + for file in files: + for batch in pq.ParquetFile(fs.open(file)).iter_batches(batch_size=batch_size): + if batch.num_rows == 0: + break + + table = pa.Table.from_batches([batch]) + + if instrument_id is not None and "instrument_id" not in batch.schema.names: + table = table.append_column( + "instrument_id", + pa.array([str(instrument_id)] * len(table), pa.string()), + ) + objs = ParquetSerializer.deserialize(cls=cls, chunk=table.to_pylist()) + yield objs + + +def generate_batches( + files: list[str], + cls: type, + fs: fsspec.AbstractFileSystem, + instrument_id: Optional[InstrumentId] = None, + batch_size: int = 10_000, + start_nanos: int = None, + end_nanos: int = None, +) -> Generator[list[Data], None, None]: + batches = _generate_batches( + files=files, + cls=cls, + instrument_id=instrument_id, + fs=fs, + batch_size=batch_size, + ) + yield from _generate_batches_within_time_range(batches, start_nanos, end_nanos) diff --git a/nautilus_trader/persistence/streaming/engine.py b/nautilus_trader/persistence/streaming/engine.py new file mode 100644 index 000000000000..d9fda852c300 --- /dev/null +++ b/nautilus_trader/persistence/streaming/engine.py @@ -0,0 +1,238 @@ +# ------------------------------------------------------------------------------------------------- +# Copyright (C) 2015-2023 Nautech Systems Pty Ltd. All rights reserved. +# https://nautechsystems.io +# +# Licensed under the GNU Lesser General Public License Version 3.0 (the "License"); +# You may not use this file except in compliance with the License. +# You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ------------------------------------------------------------------------------------------------- + +import heapq +import itertools +import sys +from collections.abc import Generator + +import fsspec +import numpy as np + +from nautilus_trader.config import BacktestDataConfig +from nautilus_trader.core.data import Data +from nautilus_trader.model.data.bar import Bar +from nautilus_trader.model.data.bar import BarSpecification +from nautilus_trader.model.identifiers import InstrumentId +from nautilus_trader.persistence.funcs import parse_bytes +from nautilus_trader.persistence.streaming.batching import generate_batches +from nautilus_trader.persistence.streaming.batching import generate_batches_rust + + +class _StreamingBuffer: + def __init__(self, batches: Generator): + self._data: list = [] + self._is_complete = False + self._batches = batches + self._size = 10_000 + + @property + def is_complete(self) -> bool: + return self._is_complete and len(self) == 0 + + def remove_front(self, timestamp_ns: int) -> list: + if len(self) == 0 or timestamp_ns < self._data[0].ts_init: + return [] # nothing to remove + + timestamps = np.array([x.ts_init for x in self._data]) + mask = timestamps <= timestamp_ns + removed = list(itertools.compress(self._data, mask)) + self._data = list(itertools.compress(self._data, np.invert(mask))) + return removed + + def add_data(self) -> None: + if len(self) >= self._size: + return # buffer filled already + + objs = next(self._batches, None) + if objs is None: + self._is_complete = True + else: + self._data.extend(objs) + + @property + def max_timestamp(self) -> int: + return self._data[-1].ts_init + + def __len__(self) -> int: + return len(self._data) + + def __repr__(self): + return f"{self.__class__.__name__}({len(self)})" + + +class _BufferIterator: + """ + Streams merged batches of nautilus objects from _StreamingBuffer objects + """ + + def __init__( + self, + buffers: list[_StreamingBuffer], + target_batch_size_bytes: int = parse_bytes("100mb"), # noqa: B008, + ): + self._buffers = buffers + self._target_batch_size_bytes = target_batch_size_bytes + + def __iter__(self) -> Generator[list[Data], None, None]: + yield from self._iterate_batches_to_target_memory() + + def _iterate_batches_to_target_memory(self) -> Generator[list[Data], None, None]: + bytes_read = 0 + values = [] + + for objs in self._iterate_batches(): + values.extend(objs) + + bytes_read += sum([sys.getsizeof(x) for x in values]) + + if bytes_read > self._target_batch_size_bytes: + yield values + bytes_read = 0 + values = [] + + if values: # yield remaining values + yield values + + def _iterate_batches(self) -> Generator[list[Data], None, None]: + while True: + for buffer in self._buffers: + buffer.add_data() + + self._remove_completed() + + if len(self._buffers) == 0: + return # stop iterating + + yield self._remove_front() + + self._remove_completed() + + def _remove_front(self) -> list[Data]: + # Get the timestamp to trim at (the minimum of the maximum timestamps) + trim_timestamp = min(buffer.max_timestamp for buffer in self._buffers if len(buffer) > 0) + + # Trim front of buffers by timestamp + chunks = [] + for buffer in self._buffers: + chunk = buffer.remove_front(trim_timestamp) + if chunk == []: + continue + chunks.append(chunk) + + if not chunks: + return [] + + # Merge chunks together + objs = list(heapq.merge(*chunks, key=lambda x: x.ts_init)) + return objs + + def _remove_completed(self) -> None: + self._buffers = [b for b in self._buffers if not b.is_complete] + + +class StreamingEngine(_BufferIterator): + """ + Streams merged batches of nautilus objects from BacktestDataConfig objects + + """ + + def __init__( + self, + data_configs: list[BacktestDataConfig], + target_batch_size_bytes: int = parse_bytes("100mb"), # noqa: B008, + ): + # Sort configs (larger time_aggregated bar specifications first) + # Define the order of objects with the same timestamp. + # Larger bar aggregations first. H4 > H1 + def _sort_larger_specifications_first(config) -> tuple[int, int]: + if config.bar_spec is None: + return sys.maxsize, sys.maxsize # last + else: + spec = BarSpecification.from_str(config.bar_spec) + return spec.aggregation * -1, spec.step * -1 + + self._configs = sorted(data_configs, key=_sort_larger_specifications_first) + + buffers = list(map(self._config_to_buffer, data_configs)) + + super().__init__( + buffers=buffers, + target_batch_size_bytes=target_batch_size_bytes, + ) + + @staticmethod + def _config_to_buffer(config: BacktestDataConfig) -> _StreamingBuffer: + if config.data_type is Bar: + assert config.bar_spec + + files = config.catalog().get_files( + cls=config.data_type, + instrument_id=config.instrument_id, + start_nanos=config.start_time_nanos, + end_nanos=config.end_time_nanos, + bar_spec=BarSpecification.from_str(config.bar_spec) if config.bar_spec else None, + ) + assert files, f"No files found for {config}" + if config.use_rust: + batches = generate_batches_rust( + files=files, + cls=config.data_type, + batch_size=config.batch_size, + start_nanos=config.start_time_nanos, + end_nanos=config.end_time_nanos, + ) + else: + batches = generate_batches( + files=files, + cls=config.data_type, + instrument_id=InstrumentId.from_str(config.instrument_id) + if config.instrument_id + else None, + fs=fsspec.filesystem(config.catalog_fs_protocol or "file"), + batch_size=config.batch_size, + start_nanos=config.start_time_nanos, + end_nanos=config.end_time_nanos, + ) + + return _StreamingBuffer(batches=batches) + + +def extract_generic_data_client_ids(data_configs: list["BacktestDataConfig"]) -> dict: + """ + Extract a mapping of data_type : client_id from the list of `data_configs`. + In the process of merging the streaming data, we lose the `client_id` for + generic data, we need to inject this back in so the backtest engine can be + correctly loaded. + """ + data_client_ids = [ + (config.data_type, config.client_id) for config in data_configs if config.client_id + ] + assert len(set(data_client_ids)) == len( + dict(data_client_ids), + ), "data_type found with multiple client_ids" + return dict(data_client_ids) + + +def groupby_datatype(data): + def _groupby_key(x): + return type(x).__name__ + + return [ + {"type": type(v[0]), "data": v} + for v in [ + list(v) for _, v in itertools.groupby(sorted(data, key=_groupby_key), key=_groupby_key) + ] + ] diff --git a/nautilus_trader/persistence/streaming.py b/nautilus_trader/persistence/streaming/writer.py similarity index 89% rename from nautilus_trader/persistence/streaming.py rename to nautilus_trader/persistence/streaming/writer.py index 60af777fac13..c67c263c6110 100644 --- a/nautilus_trader/persistence/streaming.py +++ b/nautilus_trader/persistence/streaming/writer.py @@ -1,18 +1,3 @@ -# ------------------------------------------------------------------------------------------------- -# Copyright (C) 2015-2023 Nautech Systems Pty Ltd. All rights reserved. -# https://nautechsystems.io -# -# Licensed under the GNU Lesser General Public License Version 3.0 (the "License"); -# You may not use this file except in compliance with the License. -# You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# ------------------------------------------------------------------------------------------------- - import datetime from typing import BinaryIO, Optional diff --git a/nautilus_trader/system/kernel.py b/nautilus_trader/system/kernel.py index b9a98ac4dbc5..54d96df45a3c 100644 --- a/nautilus_trader/system/kernel.py +++ b/nautilus_trader/system/kernel.py @@ -60,7 +60,7 @@ from nautilus_trader.live.risk_engine import LiveRiskEngine from nautilus_trader.model.identifiers import TraderId from nautilus_trader.msgbus.bus import MessageBus -from nautilus_trader.persistence.streaming import StreamingFeatherWriter +from nautilus_trader.persistence.streaming.writer import StreamingFeatherWriter from nautilus_trader.portfolio.base import PortfolioFacade from nautilus_trader.portfolio.portfolio import Portfolio from nautilus_trader.risk.engine import RiskEngine diff --git a/tests/unit_tests/backtest/test_backtest_config.py b/tests/unit_tests/backtest/test_backtest_config.py index b842b7f8ff49..f000bcba21be 100644 --- a/tests/unit_tests/backtest/test_backtest_config.py +++ b/tests/unit_tests/backtest/test_backtest_config.py @@ -86,6 +86,8 @@ def test_backtest_data_config_load(self): "filter_expr": None, "start": 1580398089820000000, "end": 1580504394501000000, + "use_rust": False, + "metadata": None, } def test_backtest_data_config_generic_data(self): @@ -211,7 +213,7 @@ def test_run_config_to_json(self): ) json = msgspec.json.encode(run_config) result = len(msgspec.json.encode(json)) - assert result in (786, 790) # unix, windows sizes + assert result in (854, 858) # unix, windows sizes def test_run_config_parse_obj(self): run_config = TestConfigStubs.backtest_run_config( @@ -221,7 +223,7 @@ def test_run_config_parse_obj(self): BacktestVenueConfig( name="SIM", oms_type="HEDGING", - account_type="MARGIN", + account_type="MARG IN", starting_balances=["1_000_000 USD"], ), ], @@ -231,7 +233,7 @@ def test_run_config_parse_obj(self): assert isinstance(config, BacktestRunConfig) node = BacktestNode(configs=[config]) assert isinstance(node, BacktestNode) - assert len(raw) in (587, 589) # unix, windows sizes + assert len(raw) in (641, 643) # unix, windows sizes def test_backtest_data_config_to_dict(self): run_config = TestConfigStubs.backtest_run_config( @@ -251,7 +253,7 @@ def test_backtest_data_config_to_dict(self): ) json = msgspec.json.encode(run_config) result = len(msgspec.json.encode(json)) - assert result in (1510, 1518) # unix, windows + assert result in (1718, 1726) # unix, windows def test_backtest_run_config_id(self): token = self.backtest_config.id @@ -259,8 +261,8 @@ def test_backtest_run_config_id(self): value: bytes = msgspec.json.encode(self.backtest_config.dict(), enc_hook=json_encoder) print("token_value:", value.decode()) assert token in ( - "025fddcf56215cdd9be2a7b1ccc0e48abfd76fc44839d793fa07d326655b70a9", # unix - "585913bbdf353d7e00b74c8f0a00f0eb8771da901faefeecf3fb9df1f3d48854", # windows + "f36364e423ae67307b08a68feb7cf18353d2983fc8a2f1b9683c44bd707007b3", # unix + "4b985813f597118e367ccc462bcd19a4752fbeff7b73c71ff518dbdef8ef2a47", # windows ) @pytest.mark.skip(reason="fix after merge") diff --git a/tests/unit_tests/common/test_common_actor.py b/tests/unit_tests/common/test_common_actor.py index 1a58a3b1f90c..d5f4bef06178 100644 --- a/tests/unit_tests/common/test_common_actor.py +++ b/tests/unit_tests/common/test_common_actor.py @@ -43,7 +43,7 @@ from nautilus_trader.model.identifiers import Symbol from nautilus_trader.model.identifiers import Venue from nautilus_trader.msgbus.bus import MessageBus -from nautilus_trader.persistence.streaming import StreamingFeatherWriter +from nautilus_trader.persistence.streaming.writer import StreamingFeatherWriter from nautilus_trader.test_kit.mocks.actors import KaboomActor from nautilus_trader.test_kit.mocks.actors import MockActor from nautilus_trader.test_kit.mocks.data import data_catalog_setup diff --git a/tests/unit_tests/persistence/external/test_core.py b/tests/unit_tests/persistence/external/test_core.py index 02700a684a23..f56fccfbf37b 100644 --- a/tests/unit_tests/persistence/external/test_core.py +++ b/tests/unit_tests/persistence/external/test_core.py @@ -555,6 +555,7 @@ def test_write_parquet_rust_quote_ticks_writes_expected(self): path = f"{self.catalog.path}/data/quote_tick.parquet/instrument_id=EUR-USD.SIM/0000000000000000001-0000000000000000010-0.parquet" assert self.fs.exists(path) + assert len(pd.read_parquet(path)) == 2 def test_write_parquet_rust_trade_ticks_writes_expected(self): # Arrange diff --git a/tests/unit_tests/persistence/test_catalog.py b/tests/unit_tests/persistence/test_catalog.py index 4f367d3609dd..63caf00282df 100644 --- a/tests/unit_tests/persistence/test_catalog.py +++ b/tests/unit_tests/persistence/test_catalog.py @@ -187,9 +187,9 @@ def test_get_files_for_expected_instrument_id(self): self._load_quote_ticks_into_catalog_rust() # Act - files1 = self.catalog._get_files(cls=QuoteTick, instrument_id="USD/JPY.SIM") - files2 = self.catalog._get_files(cls=QuoteTick, instrument_id="EUR/USD.SIM") - files3 = self.catalog._get_files(cls=QuoteTick, instrument_id="USD/CHF.SIM") + files1 = self.catalog.get_files(cls=QuoteTick, instrument_id="USD/JPY.SIM") + files2 = self.catalog.get_files(cls=QuoteTick, instrument_id="EUR/USD.SIM") + files3 = self.catalog.get_files(cls=QuoteTick, instrument_id="USD/CHF.SIM") # Assert assert files1 == [ @@ -205,7 +205,7 @@ def test_get_files_for_no_instrument_id(self): self._load_quote_ticks_into_catalog_rust() # Act - files = self.catalog._get_files(cls=QuoteTick) + files = self.catalog.get_files(cls=QuoteTick) # Assert assert files == [ @@ -220,21 +220,21 @@ def test_get_files_for_timestamp_range(self): end = 1577919652000000125 # Act - files1 = self.catalog._get_files( + files1 = self.catalog.get_files( cls=QuoteTick, instrument_id="EUR/USD.SIM", start_nanos=start, end_nanos=start, ) - files2 = self.catalog._get_files( + files2 = self.catalog.get_files( cls=QuoteTick, instrument_id="EUR/USD.SIM", start_nanos=0, end_nanos=start - 1, ) - files3 = self.catalog._get_files( + files3 = self.catalog.get_files( cls=QuoteTick, instrument_id="EUR/USD.SIM", start_nanos=end + 1, diff --git a/tests/unit_tests/persistence/test_streaming.py b/tests/unit_tests/persistence/test_streaming.py index d6d79a099eb9..7b676c2ef666 100644 --- a/tests/unit_tests/persistence/test_streaming.py +++ b/tests/unit_tests/persistence/test_streaming.py @@ -32,7 +32,7 @@ from nautilus_trader.model.data.venue import InstrumentStatusUpdate from nautilus_trader.persistence.external.core import process_files from nautilus_trader.persistence.external.readers import CSVReader -from nautilus_trader.persistence.streaming import generate_signal_class +from nautilus_trader.persistence.streaming.writer import generate_signal_class from nautilus_trader.test_kit.mocks.data import NewsEventData from nautilus_trader.test_kit.mocks.data import data_catalog_setup from nautilus_trader.test_kit.stubs.persistence import TestPersistenceStubs diff --git a/tests/unit_tests/persistence/test_batching.py b/tests/unit_tests/persistence/test_streaming_batching.py similarity index 62% rename from tests/unit_tests/persistence/test_batching.py rename to tests/unit_tests/persistence/test_streaming_batching.py index 49b054fb8fe2..ed2cc3aa5c6f 100644 --- a/tests/unit_tests/persistence/test_batching.py +++ b/tests/unit_tests/persistence/test_streaming_batching.py @@ -16,124 +16,16 @@ import itertools import os -import fsspec import pandas as pd -from nautilus_trader.adapters.betfair.providers import BetfairInstrumentProvider from nautilus_trader.backtest.data.providers import TestInstrumentProvider -from nautilus_trader.backtest.node import BacktestNode -from nautilus_trader.config import BacktestDataConfig -from nautilus_trader.config import BacktestEngineConfig -from nautilus_trader.config import BacktestRunConfig from nautilus_trader.core.nautilus_pyo3.persistence import ParquetReader from nautilus_trader.core.nautilus_pyo3.persistence import ParquetReaderType from nautilus_trader.core.nautilus_pyo3.persistence import ParquetType from nautilus_trader.model.data.tick import QuoteTick -from nautilus_trader.model.data.venue import InstrumentStatusUpdate from nautilus_trader.model.identifiers import Venue -from nautilus_trader.model.orderbook.data import OrderBookData -from nautilus_trader.persistence.batching import batch_files -from nautilus_trader.persistence.batching import generate_batches -from nautilus_trader.persistence.external.core import process_files -from nautilus_trader.persistence.external.readers import CSVReader -from nautilus_trader.persistence.funcs import parse_bytes -from nautilus_trader.test_kit.mocks.data import NewsEventData -from nautilus_trader.test_kit.mocks.data import data_catalog_setup -from nautilus_trader.test_kit.stubs.persistence import TestPersistenceStubs +from nautilus_trader.persistence.streaming.batching import generate_batches_rust from tests import TEST_DATA_DIR -from tests.integration_tests.adapters.betfair.test_kit import BetfairTestStubs - - -class TestPersistenceBatching: - def setup(self): - self.catalog = data_catalog_setup(protocol="memory") - self.fs: fsspec.AbstractFileSystem = self.catalog.fs - self._load_data_into_catalog() - - def teardown(self): - # Cleanup - path = self.catalog.path - fs = self.catalog.fs - if fs.exists(path): - fs.rm(path, recursive=True) - - def _load_data_into_catalog(self): - self.instrument_provider = BetfairInstrumentProvider.from_instruments([]) - process_files( - glob_path=TEST_DATA_DIR + "/1.166564490.bz2", - reader=BetfairTestStubs.betfair_reader(instrument_provider=self.instrument_provider), - instrument_provider=self.instrument_provider, - catalog=self.catalog, - ) - - def test_batch_files_single(self): - # Arrange - instrument_ids = self.catalog.instruments()["id"].unique().tolist() - shared_kw = dict( - catalog_path=str(self.catalog.path), - catalog_fs_protocol=self.catalog.fs.protocol, - data_cls=OrderBookData, - ) - iter_batches = batch_files( - catalog=self.catalog, - data_configs=[ - BacktestDataConfig(**shared_kw, instrument_id=instrument_ids[0]), - BacktestDataConfig(**shared_kw, instrument_id=instrument_ids[1]), - ], - target_batch_size_bytes=parse_bytes("10kib"), - read_num_rows=300, - ) - - # Act - timestamp_chunks = [] - for batch in iter_batches: - timestamp_chunks.append([b.ts_init for b in batch]) - - # Assert - latest_timestamp = 0 - for timestamps in timestamp_chunks: - assert max(timestamps) > latest_timestamp - latest_timestamp = max(timestamps) - assert timestamps == sorted(timestamps) - - def test_batch_generic_data(self): - # Arrange - TestPersistenceStubs.setup_news_event_persistence() - process_files( - glob_path=f"{TEST_DATA_DIR}/news_events.csv", - reader=CSVReader(block_parser=TestPersistenceStubs.news_event_parser), - catalog=self.catalog, - ) - data_config = BacktestDataConfig( - catalog_path=self.catalog.path, - catalog_fs_protocol="memory", - data_cls=NewsEventData, - client_id="NewsClient", - ) - # Add some arbitrary instrument data to appease BacktestEngine - instrument_data_config = BacktestDataConfig( - catalog_path=self.catalog.path, - catalog_fs_protocol="memory", - instrument_id=self.catalog.instruments(as_nautilus=True)[0].id.value, - data_cls=InstrumentStatusUpdate, - ) - streaming = BetfairTestStubs.streaming_config( - catalog_path=self.catalog.path, - ) - engine = BacktestEngineConfig(streaming=streaming) - run_config = BacktestRunConfig( - engine=engine, - data=[data_config, instrument_data_config], - venues=[BetfairTestStubs.betfair_venue_config()], - batch_size_bytes=parse_bytes("1mib"), - ) - - # Act - node = BacktestNode(configs=[run_config]) - node.run() - - # Assert - assert node class TestBatchingData: @@ -154,40 +46,34 @@ class TestBatchingData: class TestGenerateBatches(TestBatchingData): def test_generate_batches_returns_empty_list_before_start_timestamp_with_end_timestamp(self): start_timestamp = 1546389021944999936 - batch_gen = generate_batches( + batch_gen = generate_batches_rust( files=[self.test_parquet_files[1]], cls=QuoteTick, - fs=fsspec.filesystem("file"), - n_rows=1000, - use_rust=True, - start_time=start_timestamp, - end_time=1546394394948999936, + batch_size=1000, + start_nanos=start_timestamp, + end_nanos=1546394394948999936, ) batches = list(batch_gen) assert [len(x) for x in batches] == [0, 0, 0, 0, 172, 1000, 1000, 1000, 1000, 887] assert batches[4][0].ts_init == start_timestamp ################################# - batch_gen = generate_batches( + batch_gen = generate_batches_rust( files=[self.test_parquet_files[1]], cls=QuoteTick, - fs=fsspec.filesystem("file"), - n_rows=1000, - use_rust=True, - start_time=start_timestamp - 1, - end_time=1546394394948999936, + batch_size=1000, + start_nanos=start_timestamp - 1, + end_nanos=1546394394948999936, ) batches = list(batch_gen) assert [len(x) for x in batches] == [0, 0, 0, 0, 172, 1000, 1000, 1000, 1000, 887] assert batches[4][0].ts_init == start_timestamp def test_generate_batches_returns_batches_of_expected_size(self): - batch_gen = generate_batches( + batch_gen = generate_batches_rust( files=[self.test_parquet_files[1]], cls=QuoteTick, - fs=fsspec.filesystem("file"), - n_rows=1000, - use_rust=True, + batch_size=1000, ) batches = list(batch_gen) assert all([len(x) == 1000 for x in batches]) @@ -196,13 +82,11 @@ def test_generate_batches_returns_empty_list_before_start_timestamp(self): # Arrange parquet_data_path = self.test_parquet_files[0] start_timestamp = 1546383601403000064 # index 10 (1st item in batch) - batch_gen = generate_batches( + batch_gen = generate_batches_rust( files=[parquet_data_path], cls=QuoteTick, - fs=fsspec.filesystem("file"), - use_rust=True, - n_rows=10, - start_time=start_timestamp, + batch_size=10, + start_nanos=start_timestamp, ) # Act @@ -215,13 +99,11 @@ def test_generate_batches_returns_empty_list_before_start_timestamp(self): # Arrange parquet_data_path = self.test_parquet_files[0] start_timestamp = 1546383601862999808 # index 18 (last item in batch) - batch_gen = generate_batches( + batch_gen = generate_batches_rust( files=[parquet_data_path], cls=QuoteTick, - fs=fsspec.filesystem("file"), - use_rust=True, - n_rows=10, - start_time=start_timestamp, + batch_size=10, + start_nanos=start_timestamp, ) # Act batch = next(batch_gen, None) @@ -233,13 +115,11 @@ def test_generate_batches_returns_empty_list_before_start_timestamp(self): # Arrange parquet_data_path = self.test_parquet_files[0] start_timestamp = 1546383601352000000 # index 9 - batch_gen = generate_batches( + batch_gen = generate_batches_rust( files=[parquet_data_path], cls=QuoteTick, - fs=fsspec.filesystem("file"), - use_rust=True, - n_rows=10, - start_time=start_timestamp, + batch_size=10, + start_nanos=start_timestamp, ) # Act @@ -251,24 +131,20 @@ def test_generate_batches_returns_empty_list_before_start_timestamp(self): def test_generate_batches_trims_first_batch_by_start_timestamp(self): def create_test_batch_gen(start_timestamp): parquet_data_path = self.test_parquet_files[0] - return generate_batches( + return generate_batches_rust( files=[parquet_data_path], cls=QuoteTick, - fs=fsspec.filesystem("file"), - use_rust=True, - n_rows=10, - start_time=start_timestamp, + batch_size=10, + start_nanos=start_timestamp, ) start_timestamp = 1546383605776999936 batches = list( - generate_batches( + generate_batches_rust( files=[self.test_parquet_files[0]], cls=QuoteTick, - fs=fsspec.filesystem("file"), - use_rust=True, - n_rows=300, - start_time=start_timestamp, + batch_size=300, + start_nanos=start_timestamp, ), ) @@ -358,13 +234,11 @@ def test_generate_batches_trims_end_batch_returns_no_empty_batch(self): # Timestamp, index -1, NOT exists # Arrange end_timestamp = 1546383601914000128 # index 19 - batch_gen = generate_batches( + batch_gen = generate_batches_rust( files=[parquet_data_path], cls=QuoteTick, - fs=fsspec.filesystem("file"), - use_rust=True, - n_rows=10, - end_time=end_timestamp, + batch_size=10, + end_nanos=end_timestamp, ) # Act @@ -377,13 +251,11 @@ def test_generate_batches_trims_end_batch_returns_no_empty_batch(self): def test_generate_batches_trims_end_batch_by_end_timestamp(self): def create_test_batch_gen(end_timestamp): parquet_data_path = self.test_parquet_files[0] - return generate_batches( + return generate_batches_rust( files=[parquet_data_path], cls=QuoteTick, - fs=fsspec.filesystem("file"), - use_rust=True, - n_rows=10, - end_time=end_timestamp, + batch_size=10, + end_nanos=end_timestamp, ) ############################################################### @@ -421,12 +293,10 @@ def create_test_batch_gen(end_timestamp): def test_generate_batches_returns_valid_data(self): # Arrange parquet_data_path = self.test_parquet_files[0] - batch_gen = generate_batches( + batch_gen = generate_batches_rust( files=[parquet_data_path], cls=QuoteTick, - fs=fsspec.filesystem("file"), - use_rust=True, - n_rows=300, + batch_size=300, ) reader = ParquetReader( parquet_data_path, @@ -461,14 +331,12 @@ def test_generate_batches_returns_has_inclusive_start_and_end(self): mapped_chunk = map(QuoteTick.list_from_capsule, reader) expected = list(itertools.chain(*mapped_chunk)) - batch_gen = generate_batches( + batch_gen = generate_batches_rust( files=[parquet_data_path], cls=QuoteTick, - fs=fsspec.filesystem("file"), - use_rust=True, - n_rows=500, - start_time=expected[0].ts_init, - end_time=expected[-1].ts_init, + batch_size=500, + start_nanos=expected[0].ts_init, + end_nanos=expected[-1].ts_init, ) # Act diff --git a/tests/unit_tests/persistence/test_streaming_engine.py b/tests/unit_tests/persistence/test_streaming_engine.py new file mode 100644 index 000000000000..2ffe57e47675 --- /dev/null +++ b/tests/unit_tests/persistence/test_streaming_engine.py @@ -0,0 +1,635 @@ +# ------------------------------------------------------------------------------------------------- +# Copyright (C) 2015-2023 Nautech Systems Pty Ltd. All rights reserved. +# https://nautechsystems.io +# +# Licensed under the GNU Lesser General Public License Version 3.0 (the "License"); +# You may not use this file except in compliance with the License. +# You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ------------------------------------------------------------------------------------------------- +import os + +import fsspec +import pandas as pd +import pytest + +from nautilus_trader.adapters.betfair.providers import BetfairInstrumentProvider +from nautilus_trader.backtest.data.providers import TestInstrumentProvider +from nautilus_trader.backtest.data.wranglers import BarDataWrangler +from nautilus_trader.backtest.data.wranglers import QuoteTickDataWrangler +from nautilus_trader.backtest.node import BacktestNode +from nautilus_trader.config import BacktestDataConfig +from nautilus_trader.config import BacktestEngineConfig +from nautilus_trader.config import BacktestRunConfig +from nautilus_trader.core.datetime import unix_nanos_to_dt +from nautilus_trader.model.data.bar import Bar +from nautilus_trader.model.data.bar import BarType +from nautilus_trader.model.data.tick import QuoteTick +from nautilus_trader.model.data.venue import InstrumentStatusUpdate +from nautilus_trader.model.identifiers import Venue +from nautilus_trader.model.orderbook.data import OrderBookData +from nautilus_trader.persistence.external.core import process_files +from nautilus_trader.persistence.external.readers import CSVReader +from nautilus_trader.persistence.external.readers import ParquetReader as ParquetByteReader +from nautilus_trader.persistence.funcs import parse_bytes +from nautilus_trader.persistence.streaming.batching import generate_batches +from nautilus_trader.persistence.streaming.batching import generate_batches_rust +from nautilus_trader.persistence.streaming.engine import StreamingEngine +from nautilus_trader.persistence.streaming.engine import _BufferIterator +from nautilus_trader.persistence.streaming.engine import _StreamingBuffer +from nautilus_trader.test_kit.mocks.data import NewsEventData +from nautilus_trader.test_kit.mocks.data import data_catalog_setup +from nautilus_trader.test_kit.stubs.persistence import TestPersistenceStubs +from tests import TEST_DATA_DIR +from tests.integration_tests.adapters.betfair.test_kit import BetfairTestStubs + + +class TestBatchingData: + test_parquet_files = [ + os.path.join(TEST_DATA_DIR, "quote_tick_eurusd_2019_sim_rust.parquet"), + os.path.join(TEST_DATA_DIR, "quote_tick_usdjpy_2019_sim_rust.parquet"), + os.path.join(TEST_DATA_DIR, "bars_eurusd_2019_sim.parquet"), + ] + + test_instruments = [ + TestInstrumentProvider.default_fx_ccy("EUR/USD", venue=Venue("SIM")), + TestInstrumentProvider.default_fx_ccy("USD/JPY", venue=Venue("SIM")), + TestInstrumentProvider.default_fx_ccy("EUR/USD", venue=Venue("SIM")), + ] + test_instrument_ids = [x.id for x in test_instruments] + + +class TestBuffer(TestBatchingData): + @pytest.mark.parametrize( + "trim_timestamp,expected", + [ + [1546383600588999936, 1546383600588999936], # 4, 4 + [1546383600588999936 + 1, 1546383600588999936], # 4, 4 + [1546383600588999936 - 1, 1546383600487000064], # 4, 3 + ], + ) + def test_removed_chunk_has_correct_last_timestamp( + self, + trim_timestamp: int, + expected: int, + ): + # Arrange + buffer = _StreamingBuffer( + generate_batches_rust( + files=[self.test_parquet_files[0]], + cls=QuoteTick, + batch_size=10, + ), + ) + + # Act + buffer.add_data() + removed = buffer.remove_front(trim_timestamp) # timestamp exists + + # Assert + assert removed[-1].ts_init == expected + + @pytest.mark.parametrize( + "trim_timestamp,expected", + [ + [1546383600588999936, 1546383600691000064], # 4, 5 + [1546383600588999936 + 1, 1546383600691000064], # 4, 5 + [1546383600588999936 - 1, 1546383600588999936], # 4, 4 + ], + ) + def test_streaming_buffer_remove_front_has_correct_next_timestamp( + self, + trim_timestamp: int, + expected: int, + ): + # Arrange + buffer = _StreamingBuffer( + generate_batches_rust( + files=[self.test_parquet_files[0]], + cls=QuoteTick, + batch_size=10, + ), + ) + + # Act + buffer.add_data() + buffer.remove_front(trim_timestamp) # timestamp exists + + # Assert + next_timestamp = buffer._data[0].ts_init + assert next_timestamp == expected + + +class TestBufferIterator(TestBatchingData): + def test_iterate_returns_expected_timestamps_single(self): + # Arrange + batches = generate_batches_rust( + files=[self.test_parquet_files[0]], + cls=QuoteTick, + batch_size=1000, + ) + + buffer = _StreamingBuffer(batches=batches) + + iterator = _BufferIterator(buffers=[buffer]) + + expected = list(pd.read_parquet(self.test_parquet_files[0]).ts_event) + + # Act + timestamps = [] + for batch in iterator: + timestamps.extend([x.ts_init for x in batch]) + + # Assert + assert len(timestamps) == len(expected) + assert timestamps == expected + + def test_iterate_returns_expected_timestamps(self): + # Arrange + expected = sorted( + list(pd.read_parquet(self.test_parquet_files[0]).ts_event) + + list(pd.read_parquet(self.test_parquet_files[1]).ts_event), + ) + + buffers = [ + _StreamingBuffer( + generate_batches_rust( + files=[self.test_parquet_files[0]], + cls=QuoteTick, + batch_size=1000, + ), + ), + _StreamingBuffer( + generate_batches_rust( + files=[self.test_parquet_files[1]], + cls=QuoteTick, + batch_size=1000, + ), + ), + ] + + iterator = _BufferIterator(buffers=buffers) + + # Act + timestamps = [] + for batch in iterator: + timestamps.extend([x.ts_init for x in batch]) + + # Assert + assert len(timestamps) == len(expected) + assert timestamps == expected + + def test_iterate_returns_expected_timestamps_with_start_end_range_rust(self): + # Arrange + start_timestamps = (1546383605776999936, 1546389021944999936) + end_timestamps = (1546390125908000000, 1546394394948999936) + buffers = [ + _StreamingBuffer( + generate_batches_rust( + files=[self.test_parquet_files[0]], + cls=QuoteTick, + batch_size=1000, + start_nanos=start_timestamps[0], + end_nanos=end_timestamps[0], + ), + ), + _StreamingBuffer( + generate_batches_rust( + files=[self.test_parquet_files[1]], + cls=QuoteTick, + batch_size=1000, + start_nanos=start_timestamps[1], + end_nanos=end_timestamps[1], + ), + ), + ] + + buffer_iterator = _BufferIterator(buffers=buffers) + + # Act + objs = [] + for batch in buffer_iterator: + objs.extend(batch) + + # Assert + instrument_1_timestamps = [ + x.ts_init for x in objs if x.instrument_id == self.test_instrument_ids[0] + ] + instrument_2_timestamps = [ + x.ts_init for x in objs if x.instrument_id == self.test_instrument_ids[1] + ] + assert instrument_1_timestamps[0] == start_timestamps[0] + assert instrument_1_timestamps[-1] == end_timestamps[0] + + assert instrument_2_timestamps[0] == start_timestamps[1] + assert instrument_2_timestamps[-1] == end_timestamps[1] + + timestamps = [x.ts_init for x in objs] + assert timestamps == sorted(timestamps) + + def test_iterate_returns_expected_timestamps_with_start_end_range_and_bars(self): + # Arrange + start_timestamps = (1546383605776999936, 1546389021944999936, 1559224800000000000) + end_timestamps = (1546390125908000000, 1546394394948999936, 1577710800000000000) + + buffers = [ + _StreamingBuffer( + generate_batches_rust( + files=[self.test_parquet_files[0]], + cls=QuoteTick, + batch_size=1000, + start_nanos=start_timestamps[0], + end_nanos=end_timestamps[0], + ), + ), + _StreamingBuffer( + generate_batches_rust( + files=[self.test_parquet_files[1]], + cls=QuoteTick, + batch_size=1000, + start_nanos=start_timestamps[1], + end_nanos=end_timestamps[1], + ), + ), + _StreamingBuffer( + generate_batches( + files=[self.test_parquet_files[2]], + cls=Bar, + instrument_id=self.test_instrument_ids[2], + batch_size=1000, + fs=fsspec.filesystem("file"), + start_nanos=start_timestamps[2], + end_nanos=end_timestamps[2], + ), + ), + ] + + # Act + results = [] + buffer_iterator = _BufferIterator(buffers=buffers) + + for batch in buffer_iterator: + results.extend(batch) + + # Assert + bars = [x for x in results if isinstance(x, Bar)] + + quote_ticks = [x for x in results if isinstance(x, QuoteTick)] + + instrument_1_timestamps = [ + x.ts_init for x in quote_ticks if x.instrument_id == self.test_instrument_ids[0] + ] + instrument_2_timestamps = [ + x.ts_init for x in quote_ticks if x.instrument_id == self.test_instrument_ids[1] + ] + instrument_3_timestamps = [ + x.ts_init for x in bars if x.bar_type.instrument_id == self.test_instrument_ids[2] + ] + + assert instrument_1_timestamps[0] == start_timestamps[0] + assert instrument_1_timestamps[-1] == end_timestamps[0] + + assert instrument_2_timestamps[0] == start_timestamps[1] + assert instrument_2_timestamps[-1] == end_timestamps[1] + + assert instrument_3_timestamps[0] == start_timestamps[2] + assert instrument_3_timestamps[-1] == end_timestamps[2] + + timestamps = [x.ts_init for x in results] + assert timestamps == sorted(timestamps) + + +class TestStreamingEngine(TestBatchingData): + def setup(self): + self.catalog = data_catalog_setup(protocol="file") + self._load_bars_into_catalog_rust() + self._load_quote_ticks_into_catalog_rust() + + def _load_bars_into_catalog_rust(self): + instrument = self.test_instruments[2] + parquet_data_path = self.test_parquet_files[2] + + def parser(df): + df.index = df["ts_init"].apply(unix_nanos_to_dt) + df = df["open high low close".split()] + for col in df: + df[col] = df[col].astype(float) + objs = BarDataWrangler( + bar_type=BarType.from_str("EUR/USD.SIM-1-HOUR-BID-EXTERNAL"), + instrument=instrument, + ).process(df) + yield from objs + + process_files( + glob_path=parquet_data_path, + reader=ParquetByteReader(parser=parser), + catalog=self.catalog, + use_rust=False, + ) + + def _load_quote_ticks_into_catalog_rust(self): + for instrument, parquet_data_path in zip( + self.test_instruments[:2], + self.test_parquet_files[:2], + ): + + def parser(df): + df.index = df["ts_init"].apply(unix_nanos_to_dt) + df = df["bid ask bid_size ask_size".split()] + for col in df: + df[col] = df[col].astype(float) + objs = QuoteTickDataWrangler(instrument=instrument).process(df) + yield from objs + + process_files( + glob_path=parquet_data_path, + reader=ParquetByteReader(parser=parser), # noqa: B023 + catalog=self.catalog, + use_rust=True, + instrument=instrument, + ) + + def test_iterate_returns_expected_timestamps_single(self): + # Arrange + config = BacktestDataConfig( + catalog_path=str(self.catalog.path), + instrument_id=str(self.test_instrument_ids[0]), + data_cls=QuoteTick, + use_rust=True, + ) + + expected = list(pd.read_parquet(self.test_parquet_files[0]).ts_event) + + iterator = StreamingEngine( + data_configs=[config], + target_batch_size_bytes=parse_bytes("10kib"), + ) + + # Act + timestamps = [] + for batch in iterator: + timestamps.extend([x.ts_init for x in batch]) + + # Assert + assert len(timestamps) == len(expected) + assert timestamps == expected + + def test_iterate_returns_expected_timestamps(self): + # Arrange + configs = [ + BacktestDataConfig( + catalog_path=str(self.catalog.path), + instrument_id=str(self.test_instrument_ids[0]), + data_cls=QuoteTick, + use_rust=True, + ), + BacktestDataConfig( + catalog_path=str(self.catalog.path), + instrument_id=str(self.test_instrument_ids[1]), + data_cls=QuoteTick, + use_rust=True, + ), + ] + + expected = sorted( + list(pd.read_parquet(self.test_parquet_files[0]).ts_event) + + list(pd.read_parquet(self.test_parquet_files[1]).ts_event), + ) + + iterator = StreamingEngine( + data_configs=configs, + target_batch_size_bytes=parse_bytes("10kib"), + ) + + # Act + timestamps = [] + for batch in iterator: + timestamps.extend([x.ts_init for x in batch]) + + # Assert + assert len(timestamps) == len(expected) + assert timestamps == expected + + def test_iterate_returns_expected_timestamps_with_start_end_range_rust( + self, + ): + # Arrange + + start_timestamps = (1546383605776999936, 1546389021944999936) + end_timestamps = (1546390125908000000, 1546394394948999936) + + configs = [ + BacktestDataConfig( + catalog_path=str(self.catalog.path), + instrument_id=str(self.test_instrument_ids[0]), + data_cls=QuoteTick, + use_rust=True, + start_time=unix_nanos_to_dt(start_timestamps[0]), + end_time=unix_nanos_to_dt(end_timestamps[0]), + ), + BacktestDataConfig( + catalog_path=str(self.catalog.path), + instrument_id=str(self.test_instrument_ids[1]), + data_cls=QuoteTick, + use_rust=True, + start_time=unix_nanos_to_dt(start_timestamps[1]), + end_time=unix_nanos_to_dt(end_timestamps[1]), + ), + ] + + iterator = StreamingEngine( + data_configs=configs, + target_batch_size_bytes=parse_bytes("10kib"), + ) + + # Act + objs = [] + for batch in iterator: + objs.extend(batch) + + # Assert + instrument_1_timestamps = [ + x.ts_init for x in objs if x.instrument_id == self.test_instrument_ids[0] + ] + instrument_2_timestamps = [ + x.ts_init for x in objs if x.instrument_id == self.test_instrument_ids[1] + ] + assert instrument_1_timestamps[0] == start_timestamps[0] + assert instrument_1_timestamps[-1] == end_timestamps[0] + + assert instrument_2_timestamps[0] == start_timestamps[1] + assert instrument_2_timestamps[-1] == end_timestamps[1] + + timestamps = [x.ts_init for x in objs] + assert timestamps == sorted(timestamps) + + def test_iterate_returns_expected_timestamps_with_start_end_range_and_bars( + self, + ): + # Arrange + start_timestamps = (1546383605776999936, 1546389021944999936, 1577725200000000000) + end_timestamps = (1546390125908000000, 1546394394948999936, 1577826000000000000) + + configs = [ + BacktestDataConfig( + catalog_path=str(self.catalog.path), + instrument_id=str(self.test_instrument_ids[0]), + data_cls=QuoteTick, + start_time=unix_nanos_to_dt(start_timestamps[0]), + end_time=unix_nanos_to_dt(end_timestamps[0]), + use_rust=True, + ), + BacktestDataConfig( + catalog_path=str(self.catalog.path), + instrument_id=str(self.test_instrument_ids[1]), + data_cls=QuoteTick, + start_time=unix_nanos_to_dt(start_timestamps[1]), + end_time=unix_nanos_to_dt(end_timestamps[1]), + use_rust=True, + ), + BacktestDataConfig( + catalog_path=str(self.catalog.path), + instrument_id=str(self.test_instrument_ids[2]), + data_cls=Bar, + start_time=unix_nanos_to_dt(start_timestamps[2]), + end_time=unix_nanos_to_dt(end_timestamps[2]), + bar_spec="1-HOUR-BID", + use_rust=False, + ), + ] + + # Act + iterator = StreamingEngine( + data_configs=configs, + target_batch_size_bytes=parse_bytes("10kib"), + ) + + # Act + objs = [] + for batch in iterator: + objs.extend(batch) + + # Assert + bars = [x for x in objs if isinstance(x, Bar)] + + quote_ticks = [x for x in objs if isinstance(x, QuoteTick)] + + instrument_1_timestamps = [ + x.ts_init for x in quote_ticks if x.instrument_id == self.test_instrument_ids[0] + ] + instrument_2_timestamps = [ + x.ts_init for x in quote_ticks if x.instrument_id == self.test_instrument_ids[1] + ] + instrument_3_timestamps = [ + x.ts_init for x in bars if x.bar_type.instrument_id == self.test_instrument_ids[2] + ] + + assert instrument_1_timestamps[0] == start_timestamps[0] + assert instrument_1_timestamps[-1] == end_timestamps[0] + + assert instrument_2_timestamps[0] == start_timestamps[1] + assert instrument_2_timestamps[-1] == end_timestamps[1] + + assert instrument_3_timestamps[0] == start_timestamps[2] + assert instrument_3_timestamps[-1] == end_timestamps[2] + + timestamps = [x.ts_init for x in objs] + assert timestamps == sorted(timestamps) + + +class TestPersistenceBatching: + def setup(self): + self.catalog = data_catalog_setup(protocol="memory") + self.fs: fsspec.AbstractFileSystem = self.catalog.fs + self._load_data_into_catalog() + + def teardown(self): + # Cleanup + path = self.catalog.path + fs = self.catalog.fs + if fs.exists(path): + fs.rm(path, recursive=True) + + def _load_data_into_catalog(self): + self.instrument_provider = BetfairInstrumentProvider.from_instruments([]) + process_files( + glob_path=TEST_DATA_DIR + "/1.166564490.bz2", + reader=BetfairTestStubs.betfair_reader(instrument_provider=self.instrument_provider), + instrument_provider=self.instrument_provider, + catalog=self.catalog, + ) + + def test_batch_files_single(self): + # Arrange + instrument_ids = self.catalog.instruments()["id"].unique().tolist() + + shared_kw = dict( + catalog_path=str(self.catalog.path), + catalog_fs_protocol=self.catalog.fs.protocol, + data_cls=OrderBookData, + ) + + engine = StreamingEngine( + data_configs=[ + BacktestDataConfig(**shared_kw, instrument_id=instrument_ids[0]), + BacktestDataConfig(**shared_kw, instrument_id=instrument_ids[1]), + ], + target_batch_size_bytes=parse_bytes("10kib"), + ) + + # Act + timestamp_chunks = [] + for batch in engine: + timestamp_chunks.append([b.ts_init for b in batch]) + + # Assert + latest_timestamp = 0 + for timestamps in timestamp_chunks: + assert max(timestamps) > latest_timestamp + latest_timestamp = max(timestamps) + assert timestamps == sorted(timestamps) + + @pytest.mark.skip(reason="deserialization error") + def test_batch_generic_data(self): + # Arrange + TestPersistenceStubs.setup_news_event_persistence() + process_files( + glob_path=f"{TEST_DATA_DIR}/news_events.csv", + reader=CSVReader(block_parser=TestPersistenceStubs.news_event_parser), + catalog=self.catalog, + ) + data_config = BacktestDataConfig( + catalog_path=self.catalog.path, + catalog_fs_protocol="memory", + data_cls=NewsEventData, + client_id="NewsClient", + ) + # Add some arbitrary instrument data to appease BacktestEngine + instrument_data_config = BacktestDataConfig( + catalog_path=self.catalog.path, + catalog_fs_protocol="memory", + instrument_id=self.catalog.instruments(as_nautilus=True)[0].id.value, + data_cls=InstrumentStatusUpdate, + ) + streaming = BetfairTestStubs.streaming_config( + catalog_path=self.catalog.path, + ) + engine = BacktestEngineConfig(streaming=streaming) + run_config = BacktestRunConfig( + engine=engine, + data=[data_config, instrument_data_config], + venues=[BetfairTestStubs.betfair_venue_config()], + batch_size_bytes=parse_bytes("1mib"), + ) + + # Act + node = BacktestNode(configs=[run_config]) + node.run() + + # Assert + assert node From 73e0a7e702dd2852bc2a4058f826fea9e8b019fd Mon Sep 17 00:00:00 2001 From: Reece Kibble Date: Thu, 9 Feb 2023 10:07:29 +0800 Subject: [PATCH 23/81] Add support for Binance aggregate trades (#992) --- docs/integrations/binance.md | 8 ++ .../adapters/binance/common/data.py | 78 ++++++++++++++----- .../adapters/binance/common/execution.py | 6 +- .../adapters/binance/common/schemas/market.py | 31 ++++++++ nautilus_trader/adapters/binance/config.py | 4 + nautilus_trader/adapters/binance/factories.py | 2 + .../adapters/binance/futures/data.py | 5 ++ .../adapters/binance/http/market.py | 78 ++++++++++++++++++- nautilus_trader/adapters/binance/spot/data.py | 5 ++ .../ws_messages/ws_spot_agg_trade.json | 16 ++++ .../adapters/binance/test_data_spot.py | 34 ++++++++ 11 files changed, 243 insertions(+), 24 deletions(-) create mode 100644 tests/integration_tests/adapters/binance/resources/ws_messages/ws_spot_agg_trade.json diff --git a/docs/integrations/binance.md b/docs/integrations/binance.md index 2e8c9494760d..a57286837936 100644 --- a/docs/integrations/binance.md +++ b/docs/integrations/binance.md @@ -183,6 +183,14 @@ config = TradingNodeConfig( ) ``` +### Aggregated Trades +Binance provide aggregated trade data endpoints as an alternative source of trade ticks. +In comparison to the default trade endpoints, aggregated trade data endpoints can return all +ticks between a `start_time` and `end_time`. + +To use aggregated trades and the endpoint features, set the `use_agg_trade_ticks` option +to `True` (this is `False` by default.) + ### Parser warnings Some Binance instruments are unable to be parsed into Nautilus objects if they contain enormous field values beyond what can be handled by the platform. diff --git a/nautilus_trader/adapters/binance/common/data.py b/nautilus_trader/adapters/binance/common/data.py index 586e5ecd7fd9..d8aa6484ea08 100644 --- a/nautilus_trader/adapters/binance/common/data.py +++ b/nautilus_trader/adapters/binance/common/data.py @@ -23,6 +23,7 @@ from nautilus_trader.adapters.binance.common.enums import BinanceAccountType from nautilus_trader.adapters.binance.common.enums import BinanceEnumParser from nautilus_trader.adapters.binance.common.enums import BinanceKlineInterval +from nautilus_trader.adapters.binance.common.schemas.market import BinanceAggregatedTradeMsg from nautilus_trader.adapters.binance.common.schemas.market import BinanceCandlestickMsg from nautilus_trader.adapters.binance.common.schemas.market import BinanceDataMsgWrapper from nautilus_trader.adapters.binance.common.schemas.market import BinanceOrderBookMsg @@ -46,6 +47,7 @@ from nautilus_trader.model.data.bar import BarType from nautilus_trader.model.data.base import DataType from nautilus_trader.model.data.tick import QuoteTick +from nautilus_trader.model.data.tick import TradeTick from nautilus_trader.model.enums import BookType from nautilus_trader.model.enums import PriceType from nautilus_trader.model.identifiers import ClientId @@ -86,6 +88,9 @@ class BinanceCommonDataClient(LiveMarketDataClient): The account type for the client. base_url_ws : str, optional The base URL for the WebSocket client. + use_agg_trade_ticks : bool, default False + Whether to use aggregated trade tick endpoints instead of raw trade ticks. + TradeId of ticks will be the Aggregate tradeId returned by Binance. Warnings -------- @@ -105,6 +110,7 @@ def __init__( instrument_provider: InstrumentProvider, account_type: BinanceAccountType, base_url_ws: Optional[str] = None, + use_agg_trade_ticks: bool = False, ): super().__init__( loop=loop, @@ -123,6 +129,7 @@ def __init__( ) self._binance_account_type = account_type + self._use_agg_trade_ticks = use_agg_trade_ticks self._log.info(f"Account type: {self._binance_account_type.value}.", LogColor.BLUE) self._update_instrument_interval: int = 60 * 60 # Once per hour (hardcode) @@ -160,6 +167,7 @@ def __init__( "@ticker": self._handle_ticker, "@kline": self._handle_kline, "@trade": self._handle_trade, + "@aggTrade": self._handle_agg_trade, "@depth@": self._handle_book_diff_update, "@depth5": self._handle_book_partial_update, "@depth10": self._handle_book_partial_update, @@ -172,6 +180,7 @@ def __init__( self._decoder_quote_msg = msgspec.json.Decoder(BinanceQuoteMsg) self._decoder_ticker_msg = msgspec.json.Decoder(BinanceTickerMsg) self._decoder_candlestick_msg = msgspec.json.Decoder(BinanceCandlestickMsg) + self._decoder_agg_trade_msg = msgspec.json.Decoder(BinanceAggregatedTradeMsg) async def _connect(self) -> None: # Connect HTTP client @@ -331,22 +340,22 @@ async def _subscribe_order_book( # noqa (too complex) depth=depth, speed=update_speed, ) + + while not self._ws_client.is_connected: + await asyncio.sleep(self._connect_websockets_interval) + + snapshot: OrderBookSnapshot = await self._http_market.request_order_book_snapshot( + instrument_id=instrument_id, + limit=depth, + ts_init=self._clock.timestamp_ns(), + ) + self._handle_data(snapshot) else: self._ws_client.subscribe_diff_book_depth( symbol=instrument_id.symbol.value, speed=update_speed, ) - while not self._ws_client.is_connected: - await asyncio.sleep(self._connect_websockets_interval) - - snapshot: OrderBookSnapshot = await self._http_market.request_order_book_snapshot( - instrument_id=instrument_id, - limit=depth, - ts_init=self._clock.timestamp_ns(), - ) - self._handle_data(snapshot) - book_buffer = self._book_buffer.pop(instrument_id, []) for deltas in book_buffer: if deltas.sequence <= snapshot.sequence: @@ -360,7 +369,10 @@ async def _subscribe_quote_ticks(self, instrument_id: InstrumentId) -> None: self._ws_client.subscribe_book_ticker(instrument_id.symbol.value) async def _subscribe_trade_ticks(self, instrument_id: InstrumentId) -> None: - self._ws_client.subscribe_trades(instrument_id.symbol.value) + if self._use_agg_trade_ticks: + self._ws_client.subscribe_agg_trades(instrument_id.symbol.value) + else: + self._ws_client.subscribe_trades(instrument_id.symbol.value) async def _subscribe_bars(self, bar_type: BarType) -> None: PyCondition.true(bar_type.is_externally_aggregated(), "aggregation_source is not EXTERNAL") @@ -460,17 +472,34 @@ async def _request_trade_ticks( if limit == 0 or limit > 1000: limit = 1000 - if from_datetime is not None or to_datetime is not None: - self._log.warning( - "Trade ticks have been requested with a from/to time range, " - f"however the request will be for the most recent {limit}.", + if not self._use_agg_trade_ticks: + if from_datetime is not None or to_datetime is not None: + self._log.warning( + "Trade ticks have been requested with a from/to time range, " + f"however the request will be for the most recent {limit}." + "Consider using aggregated trade ticks (`use_agg_trade_ticks`).", + ) + ticks = await self._http_market.request_trade_ticks( + instrument_id=instrument_id, + limit=limit, + ts_init=self._clock.timestamp_ns(), + ) + else: + # Convert from timestamps to milliseconds + start_time_ms = None + end_time_ms = None + if from_datetime: + start_time_ms = str(int(from_datetime.timestamp() * 1000)) + if to_datetime: + end_time_ms = str(int(to_datetime.timestamp() * 1000)) + ticks = await self._http_market.request_agg_trade_ticks( + instrument_id=instrument_id, + limit=limit, + start_time=start_time_ms, + end_time=end_time_ms, + ts_init=self._clock.timestamp_ns(), ) - ticks = await self._http_market.request_trade_ticks( - instrument_id=instrument_id, - limit=limit, - ts_init=self._clock.timestamp_ns(), - ) self._handle_trade_ticks(instrument_id, ticks, correlation_id) async def _request_bars( # noqa (too complex) @@ -624,3 +653,12 @@ def _handle_book_partial_update(self, raw: bytes) -> None: def _handle_trade(self, raw: bytes) -> None: raise NotImplementedError("Please implement trade handling in child class.") + + def _handle_agg_trade(self, raw: bytes) -> None: + msg = self._decoder_agg_trade_msg.decode(raw) + instrument_id: InstrumentId = self._get_cached_instrument_id(msg.data.s) + trade_tick: TradeTick = msg.data.parse_to_trade_tick( + instrument_id=instrument_id, + ts_init=self._clock.timestamp_ns(), + ) + self._handle_data(trade_tick) diff --git a/nautilus_trader/adapters/binance/common/execution.py b/nautilus_trader/adapters/binance/common/execution.py index 4c70302b4990..523318692135 100644 --- a/nautilus_trader/adapters/binance/common/execution.py +++ b/nautilus_trader/adapters/binance/common/execution.py @@ -131,7 +131,7 @@ def __init__( clock: LiveClock, logger: Logger, instrument_provider: InstrumentProvider, - account_type: BinanceAccountType = BinanceAccountType.FUTURES_USDT, + account_type: BinanceAccountType, base_url_ws: Optional[str] = None, clock_sync_interval_secs: int = 0, warn_gtd_to_gtc: bool = True, @@ -140,9 +140,9 @@ def __init__( loop=loop, client_id=ClientId(BINANCE_VENUE.value), venue=BINANCE_VENUE, - oms_type=OmsType.HEDGING, + oms_type=OmsType.HEDGING if account_type.is_futures else OmsType.NETTING, instrument_provider=instrument_provider, - account_type=AccountType.MARGIN, + account_type=AccountType.CASH if account_type.is_spot else AccountType.MARGIN, base_currency=None, msgbus=msgbus, cache=cache, diff --git a/nautilus_trader/adapters/binance/common/schemas/market.py b/nautilus_trader/adapters/binance/common/schemas/market.py index 775ba62f0401..2173f5bf0731 100644 --- a/nautilus_trader/adapters/binance/common/schemas/market.py +++ b/nautilus_trader/adapters/binance/common/schemas/market.py @@ -192,6 +192,22 @@ class BinanceAggTrade(msgspec.Struct, frozen=True): m: bool # Was the buyer the maker? M: Optional[bool] = None # SPOT/MARGIN only, was the trade the best price match? + def parse_to_trade_tick( + self, + instrument_id: InstrumentId, + ts_init: int, + ) -> TradeTick: + """Parse Binance trade to internal TradeTick""" + return TradeTick( + instrument_id=instrument_id, + price=Price.from_str(self.p), + size=Quantity.from_str(self.q), + aggressor_side=AggressorSide.SELLER if self.m else AggressorSide.BUYER, + trade_id=TradeId(str(self.a)), + ts_event=millis_to_nanos(self.T), + ts_init=ts_init, + ) + class BinanceKline(msgspec.Struct, array_like=True): """Array-like schema of single Binance kline.""" @@ -448,6 +464,21 @@ class BinanceAggregatedTradeData(msgspec.Struct, frozen=True): T: int # Trade time m: bool # Is the buyer the market maker? + def parse_to_trade_tick( + self, + instrument_id: InstrumentId, + ts_init: int, + ) -> TradeTick: + return TradeTick( + instrument_id=instrument_id, + price=Price.from_str(self.p), + size=Quantity.from_str(self.q), + aggressor_side=AggressorSide.SELLER if self.m else AggressorSide.BUYER, + trade_id=TradeId(str(self.a)), + ts_event=millis_to_nanos(self.T), + ts_init=ts_init, + ) + class BinanceAggregatedTradeMsg(msgspec.Struct, frozen=True): """WebSocket message.""" diff --git a/nautilus_trader/adapters/binance/config.py b/nautilus_trader/adapters/binance/config.py index 1a8fd0e1f891..4bfe1da4832c 100644 --- a/nautilus_trader/adapters/binance/config.py +++ b/nautilus_trader/adapters/binance/config.py @@ -44,6 +44,9 @@ class BinanceDataClientConfig(LiveDataClientConfig): If client is connecting to Binance US. testnet : bool, default False If the client is connecting to a Binance testnet. + use_agg_trade_ticks : bool, default False + Whether to use aggregated trade tick endpoints instead of raw trade ticks. + TradeId of ticks will be the Aggregate tradeId returned by Binance. """ api_key: Optional[str] = None @@ -53,6 +56,7 @@ class BinanceDataClientConfig(LiveDataClientConfig): base_url_ws: Optional[str] = None us: bool = False testnet: bool = False + use_agg_trade_ticks: bool = False class BinanceExecClientConfig(LiveExecClientConfig): diff --git a/nautilus_trader/adapters/binance/factories.py b/nautilus_trader/adapters/binance/factories.py index 89d09ac3d731..9a81e5c5dd68 100644 --- a/nautilus_trader/adapters/binance/factories.py +++ b/nautilus_trader/adapters/binance/factories.py @@ -269,6 +269,7 @@ def create( # type: ignore instrument_provider=provider, account_type=config.account_type, base_url_ws=config.base_url_ws or default_base_url_ws, + use_agg_trade_ticks=config.use_agg_trade_ticks, ) else: # Get instrument provider singleton @@ -291,6 +292,7 @@ def create( # type: ignore instrument_provider=provider, account_type=config.account_type, base_url_ws=config.base_url_ws or default_base_url_ws, + use_agg_trade_ticks=config.use_agg_trade_ticks, ) diff --git a/nautilus_trader/adapters/binance/futures/data.py b/nautilus_trader/adapters/binance/futures/data.py index 1eda8aceb218..07ffc5111ce8 100644 --- a/nautilus_trader/adapters/binance/futures/data.py +++ b/nautilus_trader/adapters/binance/futures/data.py @@ -63,6 +63,9 @@ class BinanceFuturesDataClient(BinanceCommonDataClient): The account type for the client. base_url_ws : str, optional The base URL for the WebSocket client. + use_agg_trade_ticks : bool, default False + Whether to use aggregated trade tick endpoints instead of raw trade ticks. + TradeId of ticks will be the Aggregate tradeId returned by Binance. """ def __init__( @@ -76,6 +79,7 @@ def __init__( instrument_provider: InstrumentProvider, account_type: BinanceAccountType = BinanceAccountType.FUTURES_USDT, base_url_ws: Optional[str] = None, + use_agg_trade_ticks: bool = False, ): if not account_type.is_futures: raise RuntimeError( # pragma: no cover (design-time error) @@ -101,6 +105,7 @@ def __init__( instrument_provider=instrument_provider, account_type=account_type, base_url_ws=base_url_ws, + use_agg_trade_ticks=use_agg_trade_ticks, ) # Register additional futures websocket handlers diff --git a/nautilus_trader/adapters/binance/http/market.py b/nautilus_trader/adapters/binance/http/market.py index 7b3ac1da47d8..51dcc3efcd6a 100644 --- a/nautilus_trader/adapters/binance/http/market.py +++ b/nautilus_trader/adapters/binance/http/market.py @@ -710,7 +710,7 @@ async def query_agg_trades( end_time: Optional[str] = None, from_id: Optional[str] = None, ) -> list[BinanceAggTrade]: - """Query trades for symbol.""" + """Query aggregated trades for symbol.""" return await self._endpoint_agg_trades._get( parameters=self._endpoint_agg_trades.GetParameters( symbol=BinanceSymbol(symbol), @@ -721,6 +721,82 @@ async def query_agg_trades( ), ) + async def request_agg_trade_ticks( + self, + instrument_id: InstrumentId, + ts_init: int, + limit: int = 1000, + start_time: Optional[str] = None, + end_time: Optional[str] = None, + from_id: Optional[str] = None, + ) -> list[TradeTick]: + """ + Request TradeTicks from Binance aggregated trades. + If start_time and end_time are both specified, will fetch *all* TradeTicks + in the interval, making multiple requests if necessary. + """ + ticks: list[TradeTick] = [] + next_start_time = start_time + + if from_id is not None and (start_time or end_time) is not None: + raise RuntimeError( + "Cannot specify both fromId and startTime or endTime.", + ) + + # Only split into separate requests if both start_time and end_time are specified + should_loop = (start_time is not None and end_time is not None) is True + max_interval = (1000 * 60 * 60) - 1 # 1ms under an hour, as specified in Futures docs. + last_id = 0 + interval_limited = False + + def _calculate_next_end_time(start_time: str, end_time: str): + next_interval = int(start_time) + max_interval + interval_limited = next_interval < int(end_time) + next_end_time = str(next_interval) if interval_limited is True else end_time + return next_end_time, interval_limited + + if should_loop: + next_end_time, interval_limited = _calculate_next_end_time(start_time, end_time) + else: + next_end_time = end_time + + while True: + response = await self.query_agg_trades( + instrument_id.symbol.value, + limit, + start_time=next_start_time, + end_time=next_end_time, + from_id=from_id, + ) + + for trade in response: + if not trade.a > last_id: + # Skip duplicate trades + continue + ticks.append( + trade.parse_to_trade_tick( + instrument_id=instrument_id, + ts_init=ts_init, + ), + ) + + if len(response) < limit and interval_limited is False: + # end loop regardless when limit is not hit + break + if not should_loop: + break + else: + last = response[-1] + last_id = last.a + next_start_time = str(last.T) + next_end_time, interval_limited = _calculate_next_end_time( + next_start_time, + end_time, + ) + continue + + return ticks + async def query_historical_trades( self, symbol: str, diff --git a/nautilus_trader/adapters/binance/spot/data.py b/nautilus_trader/adapters/binance/spot/data.py index 201f78c4b2fc..89e4b10b2880 100644 --- a/nautilus_trader/adapters/binance/spot/data.py +++ b/nautilus_trader/adapters/binance/spot/data.py @@ -60,6 +60,9 @@ class BinanceSpotDataClient(BinanceCommonDataClient): The account type for the client. base_url_ws : str, optional The base URL for the WebSocket client. + use_agg_trade_ticks : bool, default False + Whether to use aggregated trade tick endpoints instead of raw trade ticks. + TradeId of ticks will be the Aggregate tradeId returned by Binance. """ def __init__( @@ -73,6 +76,7 @@ def __init__( instrument_provider: InstrumentProvider, account_type: BinanceAccountType = BinanceAccountType.SPOT, base_url_ws: Optional[str] = None, + use_agg_trade_ticks: bool = False, ): if not account_type.is_spot_or_margin: raise RuntimeError( # pragma: no cover (design-time error) @@ -97,6 +101,7 @@ def __init__( instrument_provider=instrument_provider, account_type=account_type, base_url_ws=base_url_ws, + use_agg_trade_ticks=use_agg_trade_ticks, ) # Websocket msgspec decoders diff --git a/tests/integration_tests/adapters/binance/resources/ws_messages/ws_spot_agg_trade.json b/tests/integration_tests/adapters/binance/resources/ws_messages/ws_spot_agg_trade.json new file mode 100644 index 000000000000..e54e301e4873 --- /dev/null +++ b/tests/integration_tests/adapters/binance/resources/ws_messages/ws_spot_agg_trade.json @@ -0,0 +1,16 @@ +{ + "stream":"ethusdt@aggTrade", + "data":{ + "e":"aggTrade", + "E":1675759520848, + "s":"ETHUSDT", + "a":226532, + "p":"1632.46000000", + "q":"0.34305000", + "f":228423, + "l":228423, + "T":1675759520847, + "m":false, + "M":true + } +} \ No newline at end of file diff --git a/tests/integration_tests/adapters/binance/test_data_spot.py b/tests/integration_tests/adapters/binance/test_data_spot.py index 92a5b3199750..60e485fe9ac6 100644 --- a/tests/integration_tests/adapters/binance/test_data_spot.py +++ b/tests/integration_tests/adapters/binance/test_data_spot.py @@ -324,3 +324,37 @@ async def test_subscribe_trade_ticks(self, monkeypatch): ts_event=1639351062243000064, ts_init=handler[0].ts_init, ) + + @pytest.mark.asyncio + async def test_subscribe_agg_trade_ticks(self, monkeypatch): + handler = [] + self.msgbus.subscribe( + topic="data.trades.BINANCE.ETHUSDT", + handler=handler.append, + ) + + # Act + self.data_client._use_agg_trade_ticks = True + self.data_client.subscribe_trade_ticks(ETHUSDT_BINANCE.id) + self.data_client._use_agg_trade_ticks = False + + raw_trade = pkgutil.get_data( + package="tests.integration_tests.adapters.binance.resources.ws_messages", + resource="ws_spot_agg_trade.json", + ) + + # Assert + self.data_client._handle_ws_message(raw_trade) + await asyncio.sleep(1) + + assert self.data_engine.data_count == 1 + assert len(handler) == 1 # <-- handler received tick + assert handler[0] == TradeTick( + instrument_id=ETHUSDT_BINANCE.id, + price=Price.from_str("1632.46000000"), + size=Quantity.from_str("0.34305000"), + aggressor_side=AggressorSide.BUYER, + trade_id=TradeId("226532"), + ts_event=1675759520847, + ts_init=handler[0].ts_init, + ) From 99460fd9fea5cf8bd261e9fad9502656622b4fb5 Mon Sep 17 00:00:00 2001 From: Chris Sellers Date: Sat, 4 Feb 2023 23:18:44 +1100 Subject: [PATCH 24/81] Fix pyo3 docs links --- README.md | 2 +- docs/getting_started/installation.md | 2 +- docs/index.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 69bd86560281..96624507ff1f 100644 --- a/README.md +++ b/README.md @@ -112,7 +112,7 @@ eliminating many classes of bugs at compile-time. The project increasingly utilizes Rust for core performance-critical components. Python language binding is handled through Cython, with static libraries linked at compile-time before the wheel binaries are packaged, so a user does not need to have Rust installed to run NautilusTrader. In the future as more Rust code is introduced, -[PyO3](https://pyo3.rs/latest/) will be leveraged for easier Python bindings. +[PyO3](https://pyo3.rs/latest) will be leveraged for easier Python bindings. ## Architecture (data flow) diff --git a/docs/getting_started/installation.md b/docs/getting_started/installation.md index fe4a99ac3a5d..88244ebf3134 100644 --- a/docs/getting_started/installation.md +++ b/docs/getting_started/installation.md @@ -30,7 +30,7 @@ For MacBook Pro M1/M2, make sure your Python installed using pyenv is configured PYTHON_CONFIGURE_OPTS="--enable-shared" pyenv install -See https://pyo3.rs/v0.17.3/getting_started#virtualenvs. +See https://pyo3.rs/latest/getting_started#virtualenvs. It's possible to install from source using `pip` if you first install the build dependencies as specified in the `pyproject.toml`. However, we highly recommend installing using [poetry](https://python-poetry.org/) as below. diff --git a/docs/index.md b/docs/index.md index 73e937cb2d77..857fd0c0586a 100644 --- a/docs/index.md +++ b/docs/index.md @@ -88,7 +88,7 @@ eliminating many classes of bugs at compile-time. The project increasingly utilizes Rust for core performance-critical components. Python language binding is handled through Cython, with static libraries linked at compile-time before the wheel binaries are packaged, so a user does not need to have Rust installed to run NautilusTrader. In the future as more Rust code is introduced, -[PyO3](https://pyo3.rs/v0.15.1/) will be leveraged for easier Python bindings. +[PyO3](https://pyo3.rs/latest) will be leveraged for easier Python bindings. ## Architecture Quality Attributes From fb7483048ac6e17251b7395246d8ea67fa810a26 Mon Sep 17 00:00:00 2001 From: Chris Sellers Date: Wed, 8 Feb 2023 22:01:48 -0700 Subject: [PATCH 25/81] Remove redundant import --- nautilus_trader/common/factories.pyx | 1 - 1 file changed, 1 deletion(-) diff --git a/nautilus_trader/common/factories.pyx b/nautilus_trader/common/factories.pyx index b1f9ddbc9ae2..c5235e7f6994 100644 --- a/nautilus_trader/common/factories.pyx +++ b/nautilus_trader/common/factories.pyx @@ -18,7 +18,6 @@ from cpython.datetime cimport datetime from nautilus_trader.common.clock cimport Clock from nautilus_trader.common.generators cimport ClientOrderIdGenerator from nautilus_trader.common.generators cimport OrderListIdGenerator -from nautilus_trader.core.correctness cimport Condition from nautilus_trader.core.datetime cimport dt_to_unix_nanos from nautilus_trader.core.uuid cimport UUID4 from nautilus_trader.model.enums_c cimport ContingencyType From bca8b9714c72a28840d7541a7b816a63229ff1eb Mon Sep 17 00:00:00 2001 From: Chris Sellers Date: Wed, 8 Feb 2023 22:08:20 -0700 Subject: [PATCH 26/81] Update dependencies --- nautilus_core/Cargo.lock | 40 ++++---- poetry.lock | 196 +++++++++++++++++++-------------------- pyproject.toml | 6 +- 3 files changed, 121 insertions(+), 121 deletions(-) diff --git a/nautilus_core/Cargo.lock b/nautilus_core/Cargo.lock index 7ab8d39896de..8c9e42530959 100644 --- a/nautilus_core/Cargo.lock +++ b/nautilus_core/Cargo.lock @@ -467,9 +467,9 @@ dependencies = [ [[package]] name = "darling" -version = "0.14.2" +version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0dd3cd20dc6b5a876612a6e5accfe7f3dd883db6d07acfbf14c128f61550dfa" +checksum = "c0808e1bd8671fb44a113a14e13497557533369847788fa2ae912b6ebfce9fa8" dependencies = [ "darling_core", "darling_macro", @@ -477,9 +477,9 @@ dependencies = [ [[package]] name = "darling_core" -version = "0.14.2" +version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a784d2ccaf7c98501746bf0be29b2022ba41fd62a2e622af997a03e9f972859f" +checksum = "001d80444f28e193f30c2f293455da62dcf9a6b29918a4253152ae2b1de592cb" dependencies = [ "fnv", "ident_case", @@ -491,9 +491,9 @@ dependencies = [ [[package]] name = "darling_macro" -version = "0.14.2" +version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7618812407e9402654622dd402b0a89dff9ba93badd6540781526117b92aab7e" +checksum = "b36230598a2d5de7ec1c6f51f72d8a99a9208daff41de2084d06e3fd3ea56685" dependencies = [ "darling_core", "quote", @@ -1164,18 +1164,18 @@ checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068" [[package]] name = "proc-macro2" -version = "1.0.50" +version = "1.0.51" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ef7d57beacfaf2d8aee5937dab7b7f28de3cb8b1828479bb5de2a7106f2bae2" +checksum = "5d727cae5b39d21da60fa540906919ad737832fe0b1c165da3a34d6548c849d6" dependencies = [ "unicode-ident", ] [[package]] name = "pyo3" -version = "0.18.0" +version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccd4149c8c3975099622b4e1962dac27565cf5663b76452c3e2b66e0b6824277" +checksum = "06a3d8e8a46ab2738109347433cb7b96dffda2e4a218b03ef27090238886b147" dependencies = [ "cfg-if", "indoc", @@ -1190,9 +1190,9 @@ dependencies = [ [[package]] name = "pyo3-build-config" -version = "0.18.0" +version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cd09fe469834db21ee60e0051030339e5d361293d8cb5ec02facf7fdcf52dbf" +checksum = "75439f995d07ddfad42b192dfcf3bc66a7ecfd8b4a1f5f6f046aa5c2c5d7677d" dependencies = [ "once_cell", "target-lexicon", @@ -1200,9 +1200,9 @@ dependencies = [ [[package]] name = "pyo3-ffi" -version = "0.18.0" +version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c427c9a96b9c5b12156dbc11f76b14f49e9aae8905ca783ea87c249044ef137" +checksum = "839526a5c07a17ff44823679b68add4a58004de00512a95b6c1c98a6dcac0ee5" dependencies = [ "libc", "pyo3-build-config", @@ -1210,9 +1210,9 @@ dependencies = [ [[package]] name = "pyo3-macros" -version = "0.18.0" +version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16b822bbba9d60630a44d2109bc410489bb2f439b33e3a14ddeb8a40b378a7c4" +checksum = "bd44cf207476c6a9760c4653559be4f206efafb924d3e4cbf2721475fc0d6cc5" dependencies = [ "proc-macro2", "pyo3-macros-backend", @@ -1222,9 +1222,9 @@ dependencies = [ [[package]] name = "pyo3-macros-backend" -version = "0.18.0" +version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84ae898104f7c99db06231160770f3e40dad6eb9021daddc0fedfa3e41dff10a" +checksum = "dc1f43d8e30460f36350d18631ccf85ded64c059829208fe680904c65bcd0a4c" dependencies = [ "proc-macro2", "quote", @@ -1452,9 +1452,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.91" +version = "1.0.93" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877c235533714907a8c2464236f5c4b2a17262ef1bd71f38f35ea592c8da6883" +checksum = "cad406b69c91885b5107daf2c29572f6c8cdb3c66826821e286c533490c0bc76" dependencies = [ "itoa 1.0.5", "ryu", diff --git a/poetry.lock b/poetry.lock index 8f08d695acc8..8067d05ebd83 100644 --- a/poetry.lock +++ b/poetry.lock @@ -494,47 +494,47 @@ toml = ["tomli"] [[package]] name = "cryptography" -version = "39.0.0" +version = "39.0.1" description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." category = "dev" optional = false python-versions = ">=3.6" files = [ - {file = "cryptography-39.0.0-cp36-abi3-macosx_10_12_universal2.whl", hash = "sha256:c52a1a6f81e738d07f43dab57831c29e57d21c81a942f4602fac7ee21b27f288"}, - {file = "cryptography-39.0.0-cp36-abi3-macosx_10_12_x86_64.whl", hash = "sha256:80ee674c08aaef194bc4627b7f2956e5ba7ef29c3cc3ca488cf15854838a8f72"}, - {file = "cryptography-39.0.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:887cbc1ea60786e534b00ba8b04d1095f4272d380ebd5f7a7eb4cc274710fad9"}, - {file = "cryptography-39.0.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6f97109336df5c178ee7c9c711b264c502b905c2d2a29ace99ed761533a3460f"}, - {file = "cryptography-39.0.0-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1a6915075c6d3a5e1215eab5d99bcec0da26036ff2102a1038401d6ef5bef25b"}, - {file = "cryptography-39.0.0-cp36-abi3-manylinux_2_24_x86_64.whl", hash = "sha256:76c24dd4fd196a80f9f2f5405a778a8ca132f16b10af113474005635fe7e066c"}, - {file = "cryptography-39.0.0-cp36-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:bae6c7f4a36a25291b619ad064a30a07110a805d08dc89984f4f441f6c1f3f96"}, - {file = "cryptography-39.0.0-cp36-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:875aea1039d78557c7c6b4db2fe0e9d2413439f4676310a5f269dd342ca7a717"}, - {file = "cryptography-39.0.0-cp36-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:f6c0db08d81ead9576c4d94bbb27aed8d7a430fa27890f39084c2d0e2ec6b0df"}, - {file = "cryptography-39.0.0-cp36-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:f3ed2d864a2fa1666e749fe52fb8e23d8e06b8012e8bd8147c73797c506e86f1"}, - {file = "cryptography-39.0.0-cp36-abi3-win32.whl", hash = "sha256:f671c1bb0d6088e94d61d80c606d65baacc0d374e67bf895148883461cd848de"}, - {file = "cryptography-39.0.0-cp36-abi3-win_amd64.whl", hash = "sha256:e324de6972b151f99dc078defe8fb1b0a82c6498e37bff335f5bc6b1e3ab5a1e"}, - {file = "cryptography-39.0.0-pp38-pypy38_pp73-macosx_10_12_x86_64.whl", hash = "sha256:754978da4d0457e7ca176f58c57b1f9de6556591c19b25b8bcce3c77d314f5eb"}, - {file = "cryptography-39.0.0-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1ee1fd0de9851ff32dbbb9362a4d833b579b4a6cc96883e8e6d2ff2a6bc7104f"}, - {file = "cryptography-39.0.0-pp38-pypy38_pp73-manylinux_2_24_x86_64.whl", hash = "sha256:fec8b932f51ae245121c4671b4bbc030880f363354b2f0e0bd1366017d891458"}, - {file = "cryptography-39.0.0-pp38-pypy38_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:407cec680e811b4fc829de966f88a7c62a596faa250fc1a4b520a0355b9bc190"}, - {file = "cryptography-39.0.0-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:7dacfdeee048814563eaaec7c4743c8aea529fe3dd53127313a792f0dadc1773"}, - {file = "cryptography-39.0.0-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:ad04f413436b0781f20c52a661660f1e23bcd89a0e9bb1d6d20822d048cf2856"}, - {file = "cryptography-39.0.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:50386acb40fbabbceeb2986332f0287f50f29ccf1497bae31cf5c3e7b4f4b34f"}, - {file = "cryptography-39.0.0-pp39-pypy39_pp73-manylinux_2_24_x86_64.whl", hash = "sha256:e5d71c5d5bd5b5c3eebcf7c5c2bb332d62ec68921a8c593bea8c394911a005ce"}, - {file = "cryptography-39.0.0-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:844ad4d7c3850081dffba91cdd91950038ee4ac525c575509a42d3fc806b83c8"}, - {file = "cryptography-39.0.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:e0a05aee6a82d944f9b4edd6a001178787d1546ec7c6223ee9a848a7ade92e39"}, - {file = "cryptography-39.0.0.tar.gz", hash = "sha256:f964c7dcf7802d133e8dbd1565914fa0194f9d683d82411989889ecd701e8adf"}, + {file = "cryptography-39.0.1-cp36-abi3-macosx_10_12_universal2.whl", hash = "sha256:6687ef6d0a6497e2b58e7c5b852b53f62142cfa7cd1555795758934da363a965"}, + {file = "cryptography-39.0.1-cp36-abi3-macosx_10_12_x86_64.whl", hash = "sha256:706843b48f9a3f9b9911979761c91541e3d90db1ca905fd63fee540a217698bc"}, + {file = "cryptography-39.0.1-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:5d2d8b87a490bfcd407ed9d49093793d0f75198a35e6eb1a923ce1ee86c62b41"}, + {file = "cryptography-39.0.1-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:83e17b26de248c33f3acffb922748151d71827d6021d98c70e6c1a25ddd78505"}, + {file = "cryptography-39.0.1-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e124352fd3db36a9d4a21c1aa27fd5d051e621845cb87fb851c08f4f75ce8be6"}, + {file = "cryptography-39.0.1-cp36-abi3-manylinux_2_24_x86_64.whl", hash = "sha256:5aa67414fcdfa22cf052e640cb5ddc461924a045cacf325cd164e65312d99502"}, + {file = "cryptography-39.0.1-cp36-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:35f7c7d015d474f4011e859e93e789c87d21f6f4880ebdc29896a60403328f1f"}, + {file = "cryptography-39.0.1-cp36-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f24077a3b5298a5a06a8e0536e3ea9ec60e4c7ac486755e5fb6e6ea9b3500106"}, + {file = "cryptography-39.0.1-cp36-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:f0c64d1bd842ca2633e74a1a28033d139368ad959872533b1bab8c80e8240a0c"}, + {file = "cryptography-39.0.1-cp36-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:0f8da300b5c8af9f98111ffd512910bc792b4c77392a9523624680f7956a99d4"}, + {file = "cryptography-39.0.1-cp36-abi3-win32.whl", hash = "sha256:fe913f20024eb2cb2f323e42a64bdf2911bb9738a15dba7d3cce48151034e3a8"}, + {file = "cryptography-39.0.1-cp36-abi3-win_amd64.whl", hash = "sha256:ced4e447ae29ca194449a3f1ce132ded8fcab06971ef5f618605aacaa612beac"}, + {file = "cryptography-39.0.1-pp38-pypy38_pp73-macosx_10_12_x86_64.whl", hash = "sha256:807ce09d4434881ca3a7594733669bd834f5b2c6d5c7e36f8c00f691887042ad"}, + {file = "cryptography-39.0.1-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:96f1157a7c08b5b189b16b47bc9db2332269d6680a196341bf30046330d15388"}, + {file = "cryptography-39.0.1-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:e422abdec8b5fa8462aa016786680720d78bdce7a30c652b7fadf83a4ba35336"}, + {file = "cryptography-39.0.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:b0afd054cd42f3d213bf82c629efb1ee5f22eba35bf0eec88ea9ea7304f511a2"}, + {file = "cryptography-39.0.1-pp39-pypy39_pp73-manylinux_2_24_x86_64.whl", hash = "sha256:6f8ba7f0328b79f08bdacc3e4e66fb4d7aab0c3584e0bd41328dce5262e26b2e"}, + {file = "cryptography-39.0.1-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:ef8b72fa70b348724ff1218267e7f7375b8de4e8194d1636ee60510aae104cd0"}, + {file = "cryptography-39.0.1-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:aec5a6c9864be7df2240c382740fcf3b96928c46604eaa7f3091f58b878c0bb6"}, + {file = "cryptography-39.0.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:fdd188c8a6ef8769f148f88f859884507b954cc64db6b52f66ef199bb9ad660a"}, + {file = "cryptography-39.0.1.tar.gz", hash = "sha256:d1f6198ee6d9148405e49887803907fe8962a23e6c6f83ea7d98f1c0de375695"}, ] [package.dependencies] cffi = ">=1.12" [package.extras] -docs = ["sphinx (>=1.6.5,!=1.8.0,!=3.1.0,!=3.1.1,!=5.2.0,!=5.2.0.post0)", "sphinx-rtd-theme"] +docs = ["sphinx (>=5.3.0)", "sphinx-rtd-theme (>=1.1.1)"] docstest = ["pyenchant (>=1.6.11)", "sphinxcontrib-spelling (>=4.0.1)", "twine (>=1.12.0)"] -pep8test = ["black", "ruff"] +pep8test = ["black", "check-manifest", "mypy", "ruff", "types-pytz", "types-requests"] sdist = ["setuptools-rust (>=0.11.4)"] ssh = ["bcrypt (>=3.1.5)"] -test = ["hypothesis (>=1.11.4,!=3.79.2)", "iso8601", "pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-subtests", "pytest-xdist", "pytz"] +test = ["hypothesis (>=1.11.4,!=3.79.2)", "iso8601", "pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-shard (>=0.1.2)", "pytest-subtests", "pytest-xdist", "pytz"] +test-randomorder = ["pytest-randomly"] +tox = ["tox"] [[package]] name = "css-html-js-minify" @@ -1506,14 +1506,14 @@ reports = ["lxml"] [[package]] name = "mypy-extensions" -version = "0.4.3" -description = "Experimental type system extensions for programs checked with the mypy typechecker." +version = "1.0.0" +description = "Type system extensions for programs checked with the mypy type checker." category = "dev" optional = false -python-versions = "*" +python-versions = ">=3.5" files = [ - {file = "mypy_extensions-0.4.3-py2.py3-none-any.whl", hash = "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d"}, - {file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"}, + {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, + {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, ] [[package]] @@ -1572,40 +1572,40 @@ setuptools = "*" [[package]] name = "numpy" -version = "1.24.1" +version = "1.24.2" description = "Fundamental package for array computing in Python" category = "main" optional = false python-versions = ">=3.8" files = [ - {file = "numpy-1.24.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:179a7ef0889ab769cc03573b6217f54c8bd8e16cef80aad369e1e8185f994cd7"}, - {file = "numpy-1.24.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:b09804ff570b907da323b3d762e74432fb07955701b17b08ff1b5ebaa8cfe6a9"}, - {file = "numpy-1.24.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f1b739841821968798947d3afcefd386fa56da0caf97722a5de53e07c4ccedc7"}, - {file = "numpy-1.24.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e3463e6ac25313462e04aea3fb8a0a30fb906d5d300f58b3bc2c23da6a15398"}, - {file = "numpy-1.24.1-cp310-cp310-win32.whl", hash = "sha256:b31da69ed0c18be8b77bfce48d234e55d040793cebb25398e2a7d84199fbc7e2"}, - {file = "numpy-1.24.1-cp310-cp310-win_amd64.whl", hash = "sha256:b07b40f5fb4fa034120a5796288f24c1fe0e0580bbfff99897ba6267af42def2"}, - {file = "numpy-1.24.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:7094891dcf79ccc6bc2a1f30428fa5edb1e6fb955411ffff3401fb4ea93780a8"}, - {file = "numpy-1.24.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:28e418681372520c992805bb723e29d69d6b7aa411065f48216d8329d02ba032"}, - {file = "numpy-1.24.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e274f0f6c7efd0d577744f52032fdd24344f11c5ae668fe8d01aac0422611df1"}, - {file = "numpy-1.24.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0044f7d944ee882400890f9ae955220d29b33d809a038923d88e4e01d652acd9"}, - {file = "numpy-1.24.1-cp311-cp311-win32.whl", hash = "sha256:442feb5e5bada8408e8fcd43f3360b78683ff12a4444670a7d9e9824c1817d36"}, - {file = "numpy-1.24.1-cp311-cp311-win_amd64.whl", hash = "sha256:de92efa737875329b052982e37bd4371d52cabf469f83e7b8be9bb7752d67e51"}, - {file = "numpy-1.24.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:b162ac10ca38850510caf8ea33f89edcb7b0bb0dfa5592d59909419986b72407"}, - {file = "numpy-1.24.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:26089487086f2648944f17adaa1a97ca6aee57f513ba5f1c0b7ebdabbe2b9954"}, - {file = "numpy-1.24.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:caf65a396c0d1f9809596be2e444e3bd4190d86d5c1ce21f5fc4be60a3bc5b36"}, - {file = "numpy-1.24.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b0677a52f5d896e84414761531947c7a330d1adc07c3a4372262f25d84af7bf7"}, - {file = "numpy-1.24.1-cp38-cp38-win32.whl", hash = "sha256:dae46bed2cb79a58d6496ff6d8da1e3b95ba09afeca2e277628171ca99b99db1"}, - {file = "numpy-1.24.1-cp38-cp38-win_amd64.whl", hash = "sha256:6ec0c021cd9fe732e5bab6401adea5a409214ca5592cd92a114f7067febcba0c"}, - {file = "numpy-1.24.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:28bc9750ae1f75264ee0f10561709b1462d450a4808cd97c013046073ae64ab6"}, - {file = "numpy-1.24.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:84e789a085aabef2f36c0515f45e459f02f570c4b4c4c108ac1179c34d475ed7"}, - {file = "numpy-1.24.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8e669fbdcdd1e945691079c2cae335f3e3a56554e06bbd45d7609a6cf568c700"}, - {file = "numpy-1.24.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ef85cf1f693c88c1fd229ccd1055570cb41cdf4875873b7728b6301f12cd05bf"}, - {file = "numpy-1.24.1-cp39-cp39-win32.whl", hash = "sha256:87a118968fba001b248aac90e502c0b13606721b1343cdaddbc6e552e8dfb56f"}, - {file = "numpy-1.24.1-cp39-cp39-win_amd64.whl", hash = "sha256:ddc7ab52b322eb1e40521eb422c4e0a20716c271a306860979d450decbb51b8e"}, - {file = "numpy-1.24.1-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:ed5fb71d79e771ec930566fae9c02626b939e37271ec285e9efaf1b5d4370e7d"}, - {file = "numpy-1.24.1-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ad2925567f43643f51255220424c23d204024ed428afc5aad0f86f3ffc080086"}, - {file = "numpy-1.24.1-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:cfa1161c6ac8f92dea03d625c2d0c05e084668f4a06568b77a25a89111621566"}, - {file = "numpy-1.24.1.tar.gz", hash = "sha256:2386da9a471cc00a1f47845e27d916d5ec5346ae9696e01a8a34760858fe9dd2"}, + {file = "numpy-1.24.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:eef70b4fc1e872ebddc38cddacc87c19a3709c0e3e5d20bf3954c147b1dd941d"}, + {file = "numpy-1.24.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e8d2859428712785e8a8b7d2b3ef0a1d1565892367b32f915c4a4df44d0e64f5"}, + {file = "numpy-1.24.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6524630f71631be2dabe0c541e7675db82651eb998496bbe16bc4f77f0772253"}, + {file = "numpy-1.24.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a51725a815a6188c662fb66fb32077709a9ca38053f0274640293a14fdd22978"}, + {file = "numpy-1.24.2-cp310-cp310-win32.whl", hash = "sha256:2620e8592136e073bd12ee4536149380695fbe9ebeae845b81237f986479ffc9"}, + {file = "numpy-1.24.2-cp310-cp310-win_amd64.whl", hash = "sha256:97cf27e51fa078078c649a51d7ade3c92d9e709ba2bfb97493007103c741f1d0"}, + {file = "numpy-1.24.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:7de8fdde0003f4294655aa5d5f0a89c26b9f22c0a58790c38fae1ed392d44a5a"}, + {file = "numpy-1.24.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4173bde9fa2a005c2c6e2ea8ac1618e2ed2c1c6ec8a7657237854d42094123a0"}, + {file = "numpy-1.24.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4cecaed30dc14123020f77b03601559fff3e6cd0c048f8b5289f4eeabb0eb281"}, + {file = "numpy-1.24.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9a23f8440561a633204a67fb44617ce2a299beecf3295f0d13c495518908e910"}, + {file = "numpy-1.24.2-cp311-cp311-win32.whl", hash = "sha256:e428c4fbfa085f947b536706a2fc349245d7baa8334f0c5723c56a10595f9b95"}, + {file = "numpy-1.24.2-cp311-cp311-win_amd64.whl", hash = "sha256:557d42778a6869c2162deb40ad82612645e21d79e11c1dc62c6e82a2220ffb04"}, + {file = "numpy-1.24.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:d0a2db9d20117bf523dde15858398e7c0858aadca7c0f088ac0d6edd360e9ad2"}, + {file = "numpy-1.24.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:c72a6b2f4af1adfe193f7beb91ddf708ff867a3f977ef2ec53c0ffb8283ab9f5"}, + {file = "numpy-1.24.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c29e6bd0ec49a44d7690ecb623a8eac5ab8a923bce0bea6293953992edf3a76a"}, + {file = "numpy-1.24.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2eabd64ddb96a1239791da78fa5f4e1693ae2dadc82a76bc76a14cbb2b966e96"}, + {file = "numpy-1.24.2-cp38-cp38-win32.whl", hash = "sha256:e3ab5d32784e843fc0dd3ab6dcafc67ef806e6b6828dc6af2f689be0eb4d781d"}, + {file = "numpy-1.24.2-cp38-cp38-win_amd64.whl", hash = "sha256:76807b4063f0002c8532cfeac47a3068a69561e9c8715efdad3c642eb27c0756"}, + {file = "numpy-1.24.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:4199e7cfc307a778f72d293372736223e39ec9ac096ff0a2e64853b866a8e18a"}, + {file = "numpy-1.24.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:adbdce121896fd3a17a77ab0b0b5eedf05a9834a18699db6829a64e1dfccca7f"}, + {file = "numpy-1.24.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:889b2cc88b837d86eda1b17008ebeb679d82875022200c6e8e4ce6cf549b7acb"}, + {file = "numpy-1.24.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f64bb98ac59b3ea3bf74b02f13836eb2e24e48e0ab0145bbda646295769bd780"}, + {file = "numpy-1.24.2-cp39-cp39-win32.whl", hash = "sha256:63e45511ee4d9d976637d11e6c9864eae50e12dc9598f531c035265991910468"}, + {file = "numpy-1.24.2-cp39-cp39-win_amd64.whl", hash = "sha256:a77d3e1163a7770164404607b7ba3967fb49b24782a6ef85d9b5f54126cc39e5"}, + {file = "numpy-1.24.2-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:92011118955724465fb6853def593cf397b4a1367495e0b59a7e69d40c4eb71d"}, + {file = "numpy-1.24.2-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9006288bcf4895917d02583cf3411f98631275bc67cce355a7f39f8c14338fa"}, + {file = "numpy-1.24.2-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:150947adbdfeceec4e5926d956a06865c1c690f2fd902efede4ca6fe2e657c3f"}, + {file = "numpy-1.24.2.tar.gz", hash = "sha256:003a9f530e880cb2cd177cba1af7220b9aa42def9c4afc2a2fc3ee6be7eb2b22"}, ] [[package]] @@ -1702,19 +1702,19 @@ files = [ [[package]] name = "platformdirs" -version = "2.6.2" +version = "3.0.0" description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." category = "dev" optional = false python-versions = ">=3.7" files = [ - {file = "platformdirs-2.6.2-py3-none-any.whl", hash = "sha256:83c8f6d04389165de7c9b6f0c682439697887bca0aa2f1c87ef1826be3584490"}, - {file = "platformdirs-2.6.2.tar.gz", hash = "sha256:e1fea1fe471b9ff8332e229df3cb7de4f53eeea4998d3b6bfff542115e998bd2"}, + {file = "platformdirs-3.0.0-py3-none-any.whl", hash = "sha256:b1d5eb14f221506f50d6604a561f4c5786d9e80355219694a1b244bcd96f4567"}, + {file = "platformdirs-3.0.0.tar.gz", hash = "sha256:8a1228abb1ef82d788f74139988b137e78692984ec7b08eaa6c65f1723af28f9"}, ] [package.extras] -docs = ["furo (>=2022.12.7)", "proselint (>=0.13)", "sphinx (>=5.3)", "sphinx-autodoc-typehints (>=1.19.5)"] -test = ["appdirs (==1.4.4)", "covdefaults (>=2.2.2)", "pytest (>=7.2)", "pytest-cov (>=4)", "pytest-mock (>=3.10)"] +docs = ["furo (>=2022.12.7)", "proselint (>=0.13)", "sphinx (>=6.1.3)", "sphinx-autodoc-typehints (>=1.22,!=1.23.4)"] +test = ["appdirs (==1.4.4)", "covdefaults (>=2.2.2)", "pytest (>=7.2.1)", "pytest-cov (>=4)", "pytest-mock (>=3.10)"] [[package]] name = "pluggy" @@ -2066,14 +2066,14 @@ dev = ["pre-commit", "pytest-asyncio", "tox"] [[package]] name = "pytest-xdist" -version = "3.1.0" +version = "3.2.0" description = "pytest xdist plugin for distributed testing, most importantly across multiple CPUs" category = "dev" optional = false python-versions = ">=3.7" files = [ - {file = "pytest-xdist-3.1.0.tar.gz", hash = "sha256:40fdb8f3544921c5dfcd486ac080ce22870e71d82ced6d2e78fa97c2addd480c"}, - {file = "pytest_xdist-3.1.0-py3-none-any.whl", hash = "sha256:70a76f191d8a1d2d6be69fc440cdf85f3e4c03c08b520fd5dc5d338d6cf07d89"}, + {file = "pytest-xdist-3.2.0.tar.gz", hash = "sha256:fa10f95a2564cd91652f2d132725183c3b590d9fdcdec09d3677386ecf4c1ce9"}, + {file = "pytest_xdist-3.2.0-py3-none-any.whl", hash = "sha256:336098e3bbd8193276867cc87db8b22903c3927665dff9d1ac8684c02f597b68"}, ] [package.dependencies] @@ -2208,14 +2208,14 @@ files = [ [[package]] name = "redis" -version = "4.4.2" +version = "4.5.1" description = "Python client for Redis database and key-value store" category = "main" optional = true python-versions = ">=3.7" files = [ - {file = "redis-4.4.2-py3-none-any.whl", hash = "sha256:e6206448e2f8a432871d07d432c13ed6c2abcf6b74edb436c99752b1371be387"}, - {file = "redis-4.4.2.tar.gz", hash = "sha256:a010f6cb7378065040a02839c3f75c7e0fb37a87116fb4a95be82a95552776c7"}, + {file = "redis-4.5.1-py3-none-any.whl", hash = "sha256:5deb072d26e67d2be1712603bfb7947ec3431fb0eec9c578994052e33035af6d"}, + {file = "redis-4.5.1.tar.gz", hash = "sha256:1eec3741cda408d3a5f84b78d089c8b8d895f21b3b050988351e925faf202864"}, ] [package.dependencies] @@ -2249,14 +2249,14 @@ use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] [[package]] name = "setuptools" -version = "67.1.0" +version = "67.2.0" description = "Easily download, build, install, upgrade, and uninstall Python packages" category = "dev" optional = false python-versions = ">=3.7" files = [ - {file = "setuptools-67.1.0-py3-none-any.whl", hash = "sha256:a7687c12b444eaac951ea87a9627c4f904ac757e7abdc5aac32833234af90378"}, - {file = "setuptools-67.1.0.tar.gz", hash = "sha256:e261cdf010c11a41cb5cb5f1bf3338a7433832029f559a6a7614bd42a967c300"}, + {file = "setuptools-67.2.0-py3-none-any.whl", hash = "sha256:16ccf598aab3b506593c17378473978908a2734d7336755a8769b480906bec1c"}, + {file = "setuptools-67.2.0.tar.gz", hash = "sha256:b440ee5f7e607bb8c9de15259dba2583dd41a38879a7abc1d43a71c59524da48"}, ] [package.extras] @@ -2652,14 +2652,14 @@ types-pyOpenSSL = "*" [[package]] name = "types-requests" -version = "2.28.11.8" +version = "2.28.11.12" description = "Typing stubs for requests" category = "dev" optional = false python-versions = "*" files = [ - {file = "types-requests-2.28.11.8.tar.gz", hash = "sha256:e67424525f84adfbeab7268a159d3c633862dafae15c5b19547ce1b55954f0a3"}, - {file = "types_requests-2.28.11.8-py3-none-any.whl", hash = "sha256:61960554baca0008ae7e2db2bd3b322ca9a144d3e80ce270f5fb640817e40994"}, + {file = "types-requests-2.28.11.12.tar.gz", hash = "sha256:fd530aab3fc4f05ee36406af168f0836e6f00f1ee51a0b96b7311f82cb675230"}, + {file = "types_requests-2.28.11.12-py3-none-any.whl", hash = "sha256:dbc2933635860e553ffc59f5e264264981358baffe6342b925e3eb8261f866ee"}, ] [package.dependencies] @@ -2667,26 +2667,26 @@ types-urllib3 = "<1.27" [[package]] name = "types-toml" -version = "0.10.8.2" +version = "0.10.8.3" description = "Typing stubs for toml" category = "dev" optional = false python-versions = "*" files = [ - {file = "types-toml-0.10.8.2.tar.gz", hash = "sha256:51d428666b30e9cc047791f440d0f11a82205e789c40debbb86f3add7472cf3e"}, - {file = "types_toml-0.10.8.2-py3-none-any.whl", hash = "sha256:3cf6a09449527b087b6c800a9d6d2dd22faf15fd47006542da7c9c3d067a6ced"}, + {file = "types-toml-0.10.8.3.tar.gz", hash = "sha256:f37244eff4cd7eace9cb70d0bac54d3eba77973aa4ef26c271ac3d1c6503a48e"}, + {file = "types_toml-0.10.8.3-py3-none-any.whl", hash = "sha256:a2286a053aea6ab6ff814659272b1d4a05d86a1dd52b807a87b23511993b46c5"}, ] [[package]] name = "types-urllib3" -version = "1.26.25.4" +version = "1.26.25.5" description = "Typing stubs for urllib3" category = "dev" optional = false python-versions = "*" files = [ - {file = "types-urllib3-1.26.25.4.tar.gz", hash = "sha256:eec5556428eec862b1ac578fb69aab3877995a99ffec9e5a12cf7fbd0cc9daee"}, - {file = "types_urllib3-1.26.25.4-py3-none-any.whl", hash = "sha256:ed6b9e8a8be488796f72306889a06a3fc3cb1aa99af02ab8afb50144d7317e49"}, + {file = "types-urllib3-1.26.25.5.tar.gz", hash = "sha256:5630e578246d170d91ebe3901788cd28d53c4e044dc2e2488e3b0d55fb6895d8"}, + {file = "types_urllib3-1.26.25.5-py3-none-any.whl", hash = "sha256:e8f25c8bb85cde658c72ee931e56e7abd28803c26032441eea9ff4a4df2b0c31"}, ] [[package]] @@ -2792,35 +2792,35 @@ test = ["Cython (>=0.29.32,<0.30.0)", "aiohttp", "flake8 (>=3.9.2,<3.10.0)", "my [[package]] name = "virtualenv" -version = "20.17.1" +version = "20.19.0" description = "Virtual Python Environment builder" category = "dev" optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" files = [ - {file = "virtualenv-20.17.1-py3-none-any.whl", hash = "sha256:ce3b1684d6e1a20a3e5ed36795a97dfc6af29bc3970ca8dab93e11ac6094b3c4"}, - {file = "virtualenv-20.17.1.tar.gz", hash = "sha256:f8b927684efc6f1cc206c9db297a570ab9ad0e51c16fa9e45487d36d1905c058"}, + {file = "virtualenv-20.19.0-py3-none-any.whl", hash = "sha256:54eb59e7352b573aa04d53f80fc9736ed0ad5143af445a1e539aada6eb947dd1"}, + {file = "virtualenv-20.19.0.tar.gz", hash = "sha256:37a640ba82ed40b226599c522d411e4be5edb339a0c0de030c0dc7b646d61590"}, ] [package.dependencies] distlib = ">=0.3.6,<1" filelock = ">=3.4.1,<4" -platformdirs = ">=2.4,<3" +platformdirs = ">=2.4,<4" [package.extras] -docs = ["proselint (>=0.13)", "sphinx (>=5.3)", "sphinx-argparse (>=0.3.2)", "sphinx-rtd-theme (>=1)", "towncrier (>=22.8)"] -testing = ["coverage (>=6.2)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=21.3)", "pytest (>=7.0.1)", "pytest-env (>=0.6.2)", "pytest-freezegun (>=0.4.2)", "pytest-mock (>=3.6.1)", "pytest-randomly (>=3.10.3)", "pytest-timeout (>=2.1)"] +docs = ["furo (>=2022.12.7)", "proselint (>=0.13)", "sphinx (>=6.1.3)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=22.12)"] +test = ["covdefaults (>=2.2.2)", "coverage (>=7.1)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23)", "pytest (>=7.2.1)", "pytest-env (>=0.8.1)", "pytest-freezegun (>=0.4.2)", "pytest-mock (>=3.10)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)"] [[package]] name = "websocket-client" -version = "1.5.0" +version = "1.5.1" description = "WebSocket client for Python with low level API options" category = "main" optional = true python-versions = ">=3.7" files = [ - {file = "websocket-client-1.5.0.tar.gz", hash = "sha256:561ca949e5bbb5d33409a37235db55c279235c78ee407802f1d2314fff8a8536"}, - {file = "websocket_client-1.5.0-py3-none-any.whl", hash = "sha256:fb5d81b95d350f3a54838ebcb4c68a5353bbd1412ae8f068b1e5280faeb13074"}, + {file = "websocket-client-1.5.1.tar.gz", hash = "sha256:3f09e6d8230892547132177f575a4e3e73cfdf06526e20cc02aa1c3b47184d40"}, + {file = "websocket_client-1.5.1-py3-none-any.whl", hash = "sha256:cdf5877568b7e83aa7cf2244ab56a3213de587bbe0ce9d8b9600fc77b455d89e"}, ] [package.extras] @@ -2933,14 +2933,14 @@ multidict = ">=4.0" [[package]] name = "zipp" -version = "3.12.0" +version = "3.12.1" description = "Backport of pathlib-compatible object wrapper for zip files" category = "dev" optional = false python-versions = ">=3.7" files = [ - {file = "zipp-3.12.0-py3-none-any.whl", hash = "sha256:9eb0a4c5feab9b08871db0d672745b53450d7f26992fd1e4653aa43345e97b86"}, - {file = "zipp-3.12.0.tar.gz", hash = "sha256:73efd63936398aac78fd92b6f4865190119d6c91b531532e798977ea8dd402eb"}, + {file = "zipp-3.12.1-py3-none-any.whl", hash = "sha256:6c4fe274b8f85ec73c37a8e4e3fa00df9fb9335da96fb789e3b96b318e5097b3"}, + {file = "zipp-3.12.1.tar.gz", hash = "sha256:a3cac813d40993596b39ea9e93a18e8a2076d5c378b8bc88ec32ab264e04ad02"}, ] [package.extras] @@ -2956,4 +2956,4 @@ redis = ["hiredis", "redis"] [metadata] lock-version = "2.0" python-versions = ">=3.9,<3.12" -content-hash = "b6ac21bc828534d5836b9f67adf47e93a4c55dd8ba817dab9b966df8779fe9be" +content-hash = "bcd8eec1af1a7f246c71f4df01ff7444cb90805d41bc3dafa201838eaa5fcbb7" diff --git a/pyproject.toml b/pyproject.toml index 944c467ec185..5041ea14465f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -54,7 +54,7 @@ click = "^8.1.3" frozendict = "^2.3.4" fsspec = ">=2023.1.0" msgspec = "^0.12.0" -numpy = "^1.24.1" +numpy = "^1.24.2" pandas = "^1.5.3" psutil = "^5.9.4" pyarrow = "^10.0.1" @@ -65,7 +65,7 @@ tqdm = "^4.64.1" uvloop = {version = "^0.17.0", markers = "sys_platform != 'win32'"} hiredis = {version = "^2.2.1", optional = true} ib_insync = {version = "^0.9.81", optional = true} -redis = {version = "^4.4.2", optional = true} +redis = {version = "^4.5.1", optional = true} docker = {version = "^6.0.1", optional = true} betfair_parser = {version = "==0.1.11", optional = true} @@ -100,7 +100,7 @@ pytest-asyncio = "^0.20.2" pytest-benchmark = "^4.0.0" pytest-cov = "4.0.0" pytest-mock = "^3.10.0" -pytest-xdist = { version = "^3.1.0", extras = ["psutil"] } +pytest-xdist = { version = "^3.2.0", extras = ["psutil"] } [tool.poetry.group.docs] optional = true From 6b46f6cf0a235e40f175d2f7cd5e67e0f64e6c8d Mon Sep 17 00:00:00 2001 From: Chris Sellers Date: Wed, 8 Feb 2023 22:27:52 -0700 Subject: [PATCH 27/81] Upgrade mypy --- .pre-commit-config.yaml | 2 +- .../adapters/binance/futures/providers.py | 5 +- .../adapters/binance/spot/providers.py | 4 +- poetry.lock | 60 +++++++++---------- pyproject.toml | 2 +- 5 files changed, 34 insertions(+), 39 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5b5fa26e2338..4a7cd8e7e494 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -106,7 +106,7 @@ repos: exclude: "docs/_pygments/monokai.py" - repo: https://github.com/pre-commit/mirrors-mypy - rev: v0.991 + rev: v1.0.0 hooks: - id: mypy args: [ diff --git a/nautilus_trader/adapters/binance/futures/providers.py b/nautilus_trader/adapters/binance/futures/providers.py index 0a2163d82fd0..35e089af32ba 100644 --- a/nautilus_trader/adapters/binance/futures/providers.py +++ b/nautilus_trader/adapters/binance/futures/providers.py @@ -241,8 +241,8 @@ def _parse_instrument( # noqa (C901 too complex) PyCondition.in_range(float(tick_size), PRICE_MIN, PRICE_MAX, "tick_size") PyCondition.in_range(float(step_size), QUANTITY_MIN, QUANTITY_MAX, "step_size") - price_precision = abs(Decimal(tick_size).as_tuple().exponent) - size_precision = abs(Decimal(step_size).as_tuple().exponent) + price_precision = abs(int(Decimal(tick_size).as_tuple().exponent)) + size_precision = abs(int(Decimal(step_size).as_tuple().exponent)) price_increment = Price.from_str(tick_size) size_increment = Quantity.from_str(step_size) max_quantity = Quantity(float(lot_size_filter.maxQty), precision=size_precision) @@ -254,7 +254,6 @@ def _parse_instrument( # noqa (C901 too complex) min_price = Price(float(price_filter.minPrice), precision=price_precision) # Futures commissions - maker_fee = Decimal(0) taker_fee = Decimal(0) if fee: diff --git a/nautilus_trader/adapters/binance/spot/providers.py b/nautilus_trader/adapters/binance/spot/providers.py index 3b71d3a3d6fb..90e20ed24fc4 100644 --- a/nautilus_trader/adapters/binance/spot/providers.py +++ b/nautilus_trader/adapters/binance/spot/providers.py @@ -221,8 +221,8 @@ def _parse_instrument( PyCondition.in_range(float(tick_size), PRICE_MIN, PRICE_MAX, "tick_size") PyCondition.in_range(float(step_size), QUANTITY_MIN, QUANTITY_MAX, "step_size") - price_precision = abs(Decimal(tick_size).as_tuple().exponent) - size_precision = abs(Decimal(step_size).as_tuple().exponent) + price_precision = abs(int(Decimal(tick_size).as_tuple().exponent)) + size_precision = abs(int(Decimal(step_size).as_tuple().exponent)) price_increment = Price.from_str(tick_size) size_increment = Quantity.from_str(step_size) lot_size = Quantity.from_str(step_size) diff --git a/poetry.lock b/poetry.lock index 8067d05ebd83..602bfd0c0ca8 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1455,42 +1455,38 @@ files = [ [[package]] name = "mypy" -version = "0.991" +version = "1.0.0" description = "Optional static typing for Python" category = "dev" optional = false python-versions = ">=3.7" files = [ - {file = "mypy-0.991-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7d17e0a9707d0772f4a7b878f04b4fd11f6f5bcb9b3813975a9b13c9332153ab"}, - {file = "mypy-0.991-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0714258640194d75677e86c786e80ccf294972cc76885d3ebbb560f11db0003d"}, - {file = "mypy-0.991-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0c8f3be99e8a8bd403caa8c03be619544bc2c77a7093685dcf308c6b109426c6"}, - {file = "mypy-0.991-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc9ec663ed6c8f15f4ae9d3c04c989b744436c16d26580eaa760ae9dd5d662eb"}, - {file = "mypy-0.991-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:4307270436fd7694b41f913eb09210faff27ea4979ecbcd849e57d2da2f65305"}, - {file = "mypy-0.991-cp310-cp310-win_amd64.whl", hash = "sha256:901c2c269c616e6cb0998b33d4adbb4a6af0ac4ce5cd078afd7bc95830e62c1c"}, - {file = "mypy-0.991-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:d13674f3fb73805ba0c45eb6c0c3053d218aa1f7abead6e446d474529aafc372"}, - {file = "mypy-0.991-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1c8cd4fb70e8584ca1ed5805cbc7c017a3d1a29fb450621089ffed3e99d1857f"}, - {file = "mypy-0.991-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:209ee89fbb0deed518605edddd234af80506aec932ad28d73c08f1400ef80a33"}, - {file = "mypy-0.991-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:37bd02ebf9d10e05b00d71302d2c2e6ca333e6c2a8584a98c00e038db8121f05"}, - {file = "mypy-0.991-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:26efb2fcc6b67e4d5a55561f39176821d2adf88f2745ddc72751b7890f3194ad"}, - {file = "mypy-0.991-cp311-cp311-win_amd64.whl", hash = "sha256:3a700330b567114b673cf8ee7388e949f843b356a73b5ab22dd7cff4742a5297"}, - {file = "mypy-0.991-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:1f7d1a520373e2272b10796c3ff721ea1a0712288cafaa95931e66aa15798813"}, - {file = "mypy-0.991-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:641411733b127c3e0dab94c45af15fea99e4468f99ac88b39efb1ad677da5711"}, - {file = "mypy-0.991-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:3d80e36b7d7a9259b740be6d8d906221789b0d836201af4234093cae89ced0cd"}, - {file = "mypy-0.991-cp37-cp37m-win_amd64.whl", hash = "sha256:e62ebaad93be3ad1a828a11e90f0e76f15449371ffeecca4a0a0b9adc99abcef"}, - {file = "mypy-0.991-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:b86ce2c1866a748c0f6faca5232059f881cda6dda2a893b9a8373353cfe3715a"}, - {file = "mypy-0.991-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:ac6e503823143464538efda0e8e356d871557ef60ccd38f8824a4257acc18d93"}, - {file = "mypy-0.991-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:0cca5adf694af539aeaa6ac633a7afe9bbd760df9d31be55ab780b77ab5ae8bf"}, - {file = "mypy-0.991-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a12c56bf73cdab116df96e4ff39610b92a348cc99a1307e1da3c3768bbb5b135"}, - {file = "mypy-0.991-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:652b651d42f155033a1967739788c436491b577b6a44e4c39fb340d0ee7f0d70"}, - {file = "mypy-0.991-cp38-cp38-win_amd64.whl", hash = "sha256:4175593dc25d9da12f7de8de873a33f9b2b8bdb4e827a7cae952e5b1a342e243"}, - {file = "mypy-0.991-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:98e781cd35c0acf33eb0295e8b9c55cdbef64fcb35f6d3aa2186f289bed6e80d"}, - {file = "mypy-0.991-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6d7464bac72a85cb3491c7e92b5b62f3dcccb8af26826257760a552a5e244aa5"}, - {file = "mypy-0.991-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c9166b3f81a10cdf9b49f2d594b21b31adadb3d5e9db9b834866c3258b695be3"}, - {file = "mypy-0.991-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b8472f736a5bfb159a5e36740847808f6f5b659960115ff29c7cecec1741c648"}, - {file = "mypy-0.991-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5e80e758243b97b618cdf22004beb09e8a2de1af481382e4d84bc52152d1c476"}, - {file = "mypy-0.991-cp39-cp39-win_amd64.whl", hash = "sha256:74e259b5c19f70d35fcc1ad3d56499065c601dfe94ff67ae48b85596b9ec1461"}, - {file = "mypy-0.991-py3-none-any.whl", hash = "sha256:de32edc9b0a7e67c2775e574cb061a537660e51210fbf6006b0b36ea695ae9bb"}, - {file = "mypy-0.991.tar.gz", hash = "sha256:3c0165ba8f354a6d9881809ef29f1a9318a236a6d81c690094c5df32107bde06"}, + {file = "mypy-1.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e0626db16705ab9f7fa6c249c017c887baf20738ce7f9129da162bb3075fc1af"}, + {file = "mypy-1.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1ace23f6bb4aec4604b86c4843276e8fa548d667dbbd0cb83a3ae14b18b2db6c"}, + {file = "mypy-1.0.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:87edfaf344c9401942883fad030909116aa77b0fa7e6e8e1c5407e14549afe9a"}, + {file = "mypy-1.0.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:0ab090d9240d6b4e99e1fa998c2d0aa5b29fc0fb06bd30e7ad6183c95fa07593"}, + {file = "mypy-1.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:7cc2c01dfc5a3cbddfa6c13f530ef3b95292f926329929001d45e124342cd6b7"}, + {file = "mypy-1.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:14d776869a3e6c89c17eb943100f7868f677703c8a4e00b3803918f86aafbc52"}, + {file = "mypy-1.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bb2782a036d9eb6b5a6efcdda0986774bf798beef86a62da86cb73e2a10b423d"}, + {file = "mypy-1.0.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5cfca124f0ac6707747544c127880893ad72a656e136adc935c8600740b21ff5"}, + {file = "mypy-1.0.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:8845125d0b7c57838a10fd8925b0f5f709d0e08568ce587cc862aacce453e3dd"}, + {file = "mypy-1.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:01b1b9e1ed40544ef486fa8ac022232ccc57109f379611633ede8e71630d07d2"}, + {file = "mypy-1.0.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:c7cf862aef988b5fbaa17764ad1d21b4831436701c7d2b653156a9497d92c83c"}, + {file = "mypy-1.0.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5cd187d92b6939617f1168a4fe68f68add749902c010e66fe574c165c742ed88"}, + {file = "mypy-1.0.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:4e5175026618c178dfba6188228b845b64131034ab3ba52acaffa8f6c361f805"}, + {file = "mypy-1.0.0-cp37-cp37m-win_amd64.whl", hash = "sha256:2f6ac8c87e046dc18c7d1d7f6653a66787a4555085b056fe2d599f1f1a2a2d21"}, + {file = "mypy-1.0.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:7306edca1c6f1b5fa0bc9aa645e6ac8393014fa82d0fa180d0ebc990ebe15964"}, + {file = "mypy-1.0.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:3cfad08f16a9c6611e6143485a93de0e1e13f48cfb90bcad7d5fde1c0cec3d36"}, + {file = "mypy-1.0.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:67cced7f15654710386e5c10b96608f1ee3d5c94ca1da5a2aad5889793a824c1"}, + {file = "mypy-1.0.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:a86b794e8a56ada65c573183756eac8ac5b8d3d59daf9d5ebd72ecdbb7867a43"}, + {file = "mypy-1.0.0-cp38-cp38-win_amd64.whl", hash = "sha256:50979d5efff8d4135d9db293c6cb2c42260e70fb010cbc697b1311a4d7a39ddb"}, + {file = "mypy-1.0.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3ae4c7a99e5153496243146a3baf33b9beff714464ca386b5f62daad601d87af"}, + {file = "mypy-1.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:5e398652d005a198a7f3c132426b33c6b85d98aa7dc852137a2a3be8890c4072"}, + {file = "mypy-1.0.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:be78077064d016bc1b639c2cbcc5be945b47b4261a4f4b7d8923f6c69c5c9457"}, + {file = "mypy-1.0.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:92024447a339400ea00ac228369cd242e988dd775640755fa4ac0c126e49bb74"}, + {file = "mypy-1.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:fe523fcbd52c05040c7bee370d66fee8373c5972171e4fbc323153433198592d"}, + {file = "mypy-1.0.0-py3-none-any.whl", hash = "sha256:2efa963bdddb27cb4a0d42545cd137a8d2b883bd181bbc4525b568ef6eca258f"}, + {file = "mypy-1.0.0.tar.gz", hash = "sha256:f34495079c8d9da05b183f9f7daec2878280c2ad7cc81da686ef0b484cea2ecf"}, ] [package.dependencies] @@ -2956,4 +2952,4 @@ redis = ["hiredis", "redis"] [metadata] lock-version = "2.0" python-versions = ">=3.9,<3.12" -content-hash = "bcd8eec1af1a7f246c71f4df01ff7444cb90805d41bc3dafa201838eaa5fcbb7" +content-hash = "b104aba4eef8c70200339217a2e63123114055ed71d1cf00706d9cca11e1456b" diff --git a/pyproject.toml b/pyproject.toml index 5041ea14465f..d57e95229c1f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -82,7 +82,7 @@ optional = true black = "^23.1.0" flake8 = "^6.0.0" isort = "^5.12.0" -mypy = "^0.991" +mypy = "^1.0.0" pre-commit = "^3.0.4" pyproject-flake8 = "^6.0.0" types-pytz = "^2022.6.0" From c4654fcf1ff09d6edfc784c11a0276409cd8c4eb Mon Sep 17 00:00:00 2001 From: Chris Sellers Date: Wed, 8 Feb 2023 22:33:06 -0700 Subject: [PATCH 28/81] Update release notes --- RELEASES.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/RELEASES.md b/RELEASES.md index 4ce3b56d4a2a..e906ab046d0e 100644 --- a/RELEASES.md +++ b/RELEASES.md @@ -1,3 +1,19 @@ +# NautilusTrader 1.169.0 Beta + +Released on TBD (UTC). + +### Breaking Changes +None + +### Enhancements +- Added Binance aggregated trades functionality with `use_agg_trade_ticks`, thanks @poshcoe +- Implemented optimized logger using Rust MPSC channel and separate thread +- Expose and improve `MatchingEngine` public API for custom functionality + +### Fixes +None + +--- # NautilusTrader 1.168.0 Beta Released on 29th January 2023 (UTC). From e1e8457da6f72ea8979d651b085fc94ab4f69a24 Mon Sep 17 00:00:00 2001 From: Chris Sellers Date: Wed, 8 Feb 2023 22:45:41 -0700 Subject: [PATCH 29/81] Improve OrderFactory for bracket orders --- RELEASES.md | 4 +++- nautilus_trader/common/factories.pxd | 4 ++-- nautilus_trader/common/factories.pyx | 16 ++++++++-------- .../test_backtest_exchange_contingencies.py | 2 +- tests/unit_tests/model/test_model_orders.py | 8 +++++++- 5 files changed, 21 insertions(+), 13 deletions(-) diff --git a/RELEASES.md b/RELEASES.md index e906ab046d0e..e733a2b9f91f 100644 --- a/RELEASES.md +++ b/RELEASES.md @@ -3,7 +3,8 @@ Released on TBD (UTC). ### Breaking Changes -None +- Renamed `OrderFactory.bracket` param `post_only_entry` -> `entry_post_only` (consistency with other params) +- Renamed `OrderFactory.bracket` param `post_only_tp` -> `tp_post_only` (consistency with other params) ### Enhancements - Added Binance aggregated trades functionality with `use_agg_trade_ticks`, thanks @poshcoe @@ -14,6 +15,7 @@ None None --- + # NautilusTrader 1.168.0 Beta Released on 29th January 2023 (UTC). diff --git a/nautilus_trader/common/factories.pxd b/nautilus_trader/common/factories.pxd index bc43a0b6390c..035e5e795b27 100644 --- a/nautilus_trader/common/factories.pxd +++ b/nautilus_trader/common/factories.pxd @@ -206,8 +206,8 @@ cdef class OrderFactory: OrderType tp_order_type=*, TimeInForce time_in_force=*, datetime expire_time=*, - bint post_only_entry=*, - bint post_only_tp=*, + bint entry_post_only=*, + bint tp_post_only=*, TriggerType emulation_trigger=*, ContingencyType contingency_type=*, ) diff --git a/nautilus_trader/common/factories.pyx b/nautilus_trader/common/factories.pyx index c5235e7f6994..ba6333e0f8db 100644 --- a/nautilus_trader/common/factories.pyx +++ b/nautilus_trader/common/factories.pyx @@ -892,8 +892,8 @@ cdef class OrderFactory: OrderType tp_order_type = OrderType.LIMIT, TimeInForce time_in_force = TimeInForce.GTC, datetime expire_time = None, - bint post_only_entry = False, - bint post_only_tp = True, + bint entry_post_only = False, + bint tp_post_only = True, TriggerType emulation_trigger = TriggerType.NO_TRIGGER, ContingencyType contingency_type = ContingencyType.OUO, ): @@ -930,9 +930,9 @@ cdef class OrderFactory: The entry orders time in force. expire_time : datetime, optional The order expiration (for ``GTD`` orders). - post_only_entry : bool, default False + entry_post_only : bool, default False If the entry order will only provide liquidity (make a market). - post_only_tp : bool, default False + tp_post_only : bool, default False If the take-profit order will only provide liquidity (make a market). emulation_trigger : TriggerType, default ``NO_TRIGGER`` The emulation trigger type for the entry, as well as the TP and SL bracket orders. @@ -982,7 +982,7 @@ cdef class OrderFactory: ts_init=self._clock.timestamp_ns(), time_in_force=time_in_force, expire_time_ns=0 if expire_time is None else dt_to_unix_nanos(expire_time), - post_only=post_only_entry, + post_only=entry_post_only, emulation_trigger=emulation_trigger, contingency_type=ContingencyType.OTO, order_list_id=order_list_id, @@ -1026,7 +1026,7 @@ cdef class OrderFactory: ts_init=self._clock.timestamp_ns(), time_in_force=time_in_force, expire_time_ns=0 if expire_time is None else dt_to_unix_nanos(expire_time), - post_only=post_only_entry, + post_only=entry_post_only, emulation_trigger=emulation_trigger, contingency_type=ContingencyType.OTO, order_list_id=order_list_id, @@ -1052,7 +1052,7 @@ cdef class OrderFactory: init_id=UUID4(), ts_init=self._clock.timestamp_ns(), time_in_force=TimeInForce.GTC, - post_only=post_only_tp, + post_only=tp_post_only, reduce_only=True, display_qty=None, emulation_trigger=emulation_trigger, @@ -1076,7 +1076,7 @@ cdef class OrderFactory: init_id=UUID4(), ts_init=self._clock.timestamp_ns(), time_in_force=TimeInForce.GTC, - post_only=post_only_tp, + post_only=tp_post_only, reduce_only=True, display_qty=None, emulation_trigger=emulation_trigger, diff --git a/tests/unit_tests/backtest/test_backtest_exchange_contingencies.py b/tests/unit_tests/backtest/test_backtest_exchange_contingencies.py index b87693e3494b..ee8a2f1ee10f 100644 --- a/tests/unit_tests/backtest/test_backtest_exchange_contingencies.py +++ b/tests/unit_tests/backtest/test_backtest_exchange_contingencies.py @@ -374,7 +374,7 @@ def test_reject_bracket_entry_then_rejects_sl_and_tp(self): entry_price=ETHUSDT_PERP_BINANCE.make_price(3050.0), # <-- in the market sl_trigger_price=ETHUSDT_PERP_BINANCE.make_price(3150.0), tp_price=ETHUSDT_PERP_BINANCE.make_price(3000.0), - post_only_entry=True, # <-- will reject placed into the market + entry_post_only=True, # <-- will reject placed into the market entry_order_type=OrderType.LIMIT, ) diff --git a/tests/unit_tests/model/test_model_orders.py b/tests/unit_tests/model/test_model_orders.py index d95352e643c5..6162adf50c3f 100644 --- a/tests/unit_tests/model/test_model_orders.py +++ b/tests/unit_tests/model/test_model_orders.py @@ -1307,8 +1307,11 @@ def test_bracket_limit_entry_order_list(self): entry_price=Price.from_str("1.00000"), sl_trigger_price=Price.from_str("0.99990"), tp_price=Price.from_str("1.00010"), + tp_trigger_price=Price.from_str("1.00010"), time_in_force=TimeInForce.GTC, entry_order_type=OrderType.LIMIT, + tp_order_type=OrderType.LIMIT_IF_TOUCHED, + tp_post_only=False, ) # Assert @@ -1317,7 +1320,7 @@ def test_bracket_limit_entry_order_list(self): assert len(bracket.orders) == 3 assert bracket.orders[0].order_type == OrderType.LIMIT assert bracket.orders[1].order_type == OrderType.STOP_MARKET - assert bracket.orders[2].order_type == OrderType.LIMIT + assert bracket.orders[2].order_type == OrderType.LIMIT_IF_TOUCHED assert bracket.orders[0].instrument_id == AUDUSD_SIM.id assert bracket.orders[1].instrument_id == AUDUSD_SIM.id assert bracket.orders[2].instrument_id == AUDUSD_SIM.id @@ -1336,6 +1339,9 @@ def test_bracket_limit_entry_order_list(self): assert bracket.orders[2].time_in_force == TimeInForce.GTC assert bracket.orders[1].expire_time is None assert bracket.orders[2].expire_time is None + assert bracket.orders[0].is_post_only is False + assert bracket.orders[1].is_post_only is False + assert bracket.orders[2].is_post_only is False assert bracket.orders[0].contingency_type == ContingencyType.OTO assert bracket.orders[1].contingency_type == ContingencyType.OUO assert bracket.orders[2].contingency_type == ContingencyType.OUO From bbf94c0bd71859b75e608cce2c542b5ac765852a Mon Sep 17 00:00:00 2001 From: Chris Sellers Date: Wed, 8 Feb 2023 23:11:28 -0700 Subject: [PATCH 30/81] Upgrade msgspec and refine configs --- RELEASES.md | 1 + nautilus_trader/config/common.py | 2 +- poetry.lock | 71 ++++++++++--------- pyproject.toml | 2 +- .../interactive_brokers/test_providers.py | 1 + .../integration_tests/live/test_live_node.py | 4 +- .../unit_tests/persistence/test_streaming.py | 1 + 7 files changed, 45 insertions(+), 37 deletions(-) diff --git a/RELEASES.md b/RELEASES.md index e733a2b9f91f..4851469c2465 100644 --- a/RELEASES.md +++ b/RELEASES.md @@ -3,6 +3,7 @@ Released on TBD (UTC). ### Breaking Changes +- `NautilusConfig` objects now _pseudo-immutable_ from new msgspec 0.13.0 - Renamed `OrderFactory.bracket` param `post_only_entry` -> `entry_post_only` (consistency with other params) - Renamed `OrderFactory.bracket` param `post_only_tp` -> `tp_post_only` (consistency with other params) diff --git a/nautilus_trader/config/common.py b/nautilus_trader/config/common.py index ac2d587ae9b3..4cbeee9ede9d 100644 --- a/nautilus_trader/config/common.py +++ b/nautilus_trader/config/common.py @@ -33,7 +33,7 @@ def resolve_path(path: str): return cls -class NautilusConfig(msgspec.Struct, kw_only=True): +class NautilusConfig(msgspec.Struct, kw_only=True, frozen=True): """ The base class for all Nautilus configuration objects. """ diff --git a/poetry.lock b/poetry.lock index 602bfd0c0ca8..24593fe6d9b9 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1332,43 +1332,50 @@ files = [ [[package]] name = "msgspec" -version = "0.12.0" -description = "A fast and friendly JSON/MessagePack library, with optional schema validation" +version = "0.13.0" +description = "A fast serialization and validation library, with builtin support for JSON, MessagePack, YAML, and TOML." category = "main" optional = false python-versions = ">=3.8" files = [ - {file = "msgspec-0.12.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:3b3b193fc6e5399040f2c657f2fe77962b8d39bddb9923d4e4850e2e8111ef83"}, - {file = "msgspec-0.12.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6b90c8aa5b029f8fb8f9a4e71429cb37b4110382731058f7c4dfa125a005c459"}, - {file = "msgspec-0.12.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:78cbcabfa413edc281f0f9bb652b42a3092cb289c31dc4489e7d896e615581fb"}, - {file = "msgspec-0.12.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:be9e4eeea7f47c0a7c522afb4697d9618cb38e81e52130c9b15ad5279a69d153"}, - {file = "msgspec-0.12.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e581459710a53d240ad579bb7fbe2b64767003c3d06254f17c0cd106fab03b20"}, - {file = "msgspec-0.12.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:237ebaeb409269a001ba29dbb3f9fe760db63bc82d013b989733e6ec59ef2cf4"}, - {file = "msgspec-0.12.0-cp310-cp310-win_amd64.whl", hash = "sha256:e010bab6128d1700d7bf70cbe7ce33a54cfeedf15c11f015712dcc0c062ca571"}, - {file = "msgspec-0.12.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ebe1cd8c42e85dbf59ede8ef1e4f8f73897664a3a3341f16a7616bb31fe21f2c"}, - {file = "msgspec-0.12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:72b55df12dbcd89f636165bc1b76ac174917e7756105496b685a7970f5e9d70c"}, - {file = "msgspec-0.12.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bffa75be17ec2d4953c8068cbe6cd9b064dd0403ec6b04dc45d0dfdd9ca2cf36"}, - {file = "msgspec-0.12.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ad43ccaf17deee41ed84dacc6619d2ccd3847fdebe9fc5f2b887bbf4b938724f"}, - {file = "msgspec-0.12.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:f3177bd78b5a4e1663ee9279889d89b574acf619aa29aee84f86c00ca259016d"}, - {file = "msgspec-0.12.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:98bc70226b858218920a25b85906e61ada41898b8f2fc1f41af31d9628353e04"}, - {file = "msgspec-0.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:c9e3adf5f7a8aa6a1359ebe9e738d6b7b25389c942b1d7f8849981ff62ed3d8e"}, - {file = "msgspec-0.12.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1d5a6a08fa1bd2b4e29b076c84ae6159a33f4256b88d6c6c55df9de04e225a5a"}, - {file = "msgspec-0.12.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:b25a98e7f99dcb86ffec7b462222703fe87bc6e299be31d1a68a657dc7317498"}, - {file = "msgspec-0.12.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce9bda08bb1f9372d7da112cd697993f238fc22fbc72accd1dfb50eb22b68c23"}, - {file = "msgspec-0.12.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c905ba72400a0593c6244691d78e450036b8f54a05b9544740e47ed35f739af"}, - {file = "msgspec-0.12.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:04aff2b6404d54637170235983c67a231326a2b73a96a93f63c903f4a3e5701a"}, - {file = "msgspec-0.12.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:8e784500de56c89db90f0b5c8043999dd128260aa4fd111fb3b65566140b7830"}, - {file = "msgspec-0.12.0-cp38-cp38-win_amd64.whl", hash = "sha256:318583cfad415d5c6bbb9e87a8a998de353146b64ac202c90a3d9396a5ea6b97"}, - {file = "msgspec-0.12.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:769e6d442969c0238c65b078b4962af19f4c1d875a4dc93267ed6cad4d887b47"}, - {file = "msgspec-0.12.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f71332e8969436ebc8bc3bb455d5c47a65ccaf236f7267e369959f2fcaf88bf3"}, - {file = "msgspec-0.12.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:faf7510ff87d086e21503f8504ca0550161fdfb1a025d9060a90a3e58d727be4"}, - {file = "msgspec-0.12.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2886e45bef04db383649e30fba56f2124c84ce6455deff6689e7dc9dc4926329"}, - {file = "msgspec-0.12.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:7c281bd01931456cf01d553bdce315cf148bfa3565be01390f12800c39f75797"}, - {file = "msgspec-0.12.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:54c061e764f80c915fd86db8bfe48c88fc8c6047649fc8a5900a03dda745e600"}, - {file = "msgspec-0.12.0-cp39-cp39-win_amd64.whl", hash = "sha256:cc7f0555b4cb8c74cca6d13040b3fd9e7adafc0fff273203a13064453c28d32f"}, - {file = "msgspec-0.12.0.tar.gz", hash = "sha256:d8fe529a2414a1a5d3ccb1e875b164cc4f56614750c7b27c85006808f5658489"}, + {file = "msgspec-0.13.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8bbf33b1c2a25793af463c463c30ec6dbe4305130ea7bcb12d85bd92feb252af"}, + {file = "msgspec-0.13.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f54aaad28cd0f6983187d5280d289536a668910a6c431162324bc2576f5b4831"}, + {file = "msgspec-0.13.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6a66cf73a4392196720193d35f0e0fd1613872371c928017c4363f5a7c150e96"}, + {file = "msgspec-0.13.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:52e74cf0a0907553acd9946e3c1416b47e1d568eb004049820d9055644ad0478"}, + {file = "msgspec-0.13.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:b0cf6084a4eb9d906f9292d1863e3d12c0d624551fb79c1f12f910fbb1e818e7"}, + {file = "msgspec-0.13.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:46b89036786e5907855602645b71b87269e43e0094541946ba983a36d62f1b4e"}, + {file = "msgspec-0.13.0-cp310-cp310-win_amd64.whl", hash = "sha256:c53799555c5ac056473cc35557d8b9ef045e24507f73b5256ce8abe00136142f"}, + {file = "msgspec-0.13.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:084c3aedaf3467c11b7afc4fbe1ac709030519706454c24b2e11e55212adba24"}, + {file = "msgspec-0.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3b3fb5a25999761cc55d3f3c1ae598a449898bf9251702ba2e27bca2f5b56ad5"}, + {file = "msgspec-0.13.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5e48a3b9a290dd8197bd6cd6331eb2d2b9e136ec725dd77881728c334435f537"}, + {file = "msgspec-0.13.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:837584e46611cc56509e23d688b5d263bee6a3e42d83c31857b9f5cbd0c44b91"}, + {file = "msgspec-0.13.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:dbaaf643c0c4d5372eaec722392defca336c9fef1f221d7c46b7e76d48482f1d"}, + {file = "msgspec-0.13.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:94fc2c21c1eaf5b92964b40bfabd51b65e29f6756376d328474d6b2e703f8e51"}, + {file = "msgspec-0.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:70051d3efa22ab8770b3cb0180447e684efdc5d22f1e8ceca38bc0127d4d8052"}, + {file = "msgspec-0.13.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:698659567159fe87fa3b513307c9a095a05add3cbcff3d5c8669f4a386f1b08d"}, + {file = "msgspec-0.13.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7e3ef9b42215642f53f9e07caf037432568025bf2924a907a69b6afe0a70caf2"}, + {file = "msgspec-0.13.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fbcd283629a5c5b3e03591381adfc623104ada08bfa50733c43f756c1229ab8c"}, + {file = "msgspec-0.13.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:610ba7e84ad84147e996252b8b736b5bda214d6117f6158cdb3bae1135bd5e90"}, + {file = "msgspec-0.13.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:5d8ec7b202c24f013ff71ba451e879b47d0ebb2ad6fa195d662bf9b58651a60d"}, + {file = "msgspec-0.13.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:9a408bb35bcbe46c02e76eda300fb55e7f8d6b31ce52cf143248b6d241cf82cd"}, + {file = "msgspec-0.13.0-cp38-cp38-win_amd64.whl", hash = "sha256:f5d51fe5eef8c6b4545cb9a06a83034c02e6158077fd134d372cff1db2b812ac"}, + {file = "msgspec-0.13.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6957112ca69d6b7cecf6e02aaac5da7702e92e9b261fac692264124f3b5cba65"}, + {file = "msgspec-0.13.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:7657bbc8e1f60076cdf1ab48df828894b7222cad410838880f5e302221e1d874"}, + {file = "msgspec-0.13.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f70197352ec5cdb7bbd9955fa890962b56908770ba62c70ac24cb36a4e178b4"}, + {file = "msgspec-0.13.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c4257d3f1ed2b3e7dd35e9229f0ef59f4c8a6adf881f62b918dce65c730e5b32"}, + {file = "msgspec-0.13.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0605914d4afc009aeafb18777414cb91edfd138ac8445dfc29c62e63eb703a00"}, + {file = "msgspec-0.13.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:c5fc20dc8d1c1d8f6ea7e064b8f638b04dd98e5f590471c5dfe3aa8fd7d88129"}, + {file = "msgspec-0.13.0-cp39-cp39-win_amd64.whl", hash = "sha256:68c5bf57a5cdd5791f360585a9b850e2758a27c2e6530ed64715b7cbcee9993e"}, + {file = "msgspec-0.13.0.tar.gz", hash = "sha256:c89bbc5b82f1370165593ed28c8ce9dfa71869666ed8ba3af900b3465d379545"}, ] +[package.extras] +dev = ["coverage", "furo", "gcovr", "ipython", "msgpack", "mypy", "pre-commit", "pyright", "pytest", "pyyaml", "sphinx", "sphinx-copybutton", "sphinx-design", "tomli", "tomli-w"] +doc = ["furo", "ipython", "sphinx", "sphinx-copybutton", "sphinx-design"] +test = ["msgpack", "mypy", "pyright", "pytest", "pyyaml", "tomli", "tomli-w"] +toml = ["tomli", "tomli-w"] +yaml = ["pyyaml"] + [[package]] name = "multidict" version = "6.0.4" @@ -2952,4 +2959,4 @@ redis = ["hiredis", "redis"] [metadata] lock-version = "2.0" python-versions = ">=3.9,<3.12" -content-hash = "b104aba4eef8c70200339217a2e63123114055ed71d1cf00706d9cca11e1456b" +content-hash = "d6539ff50eefb5ee7c2ccde23462fa4dcc0feb48c8716f643ff6e4f331ca06a7" diff --git a/pyproject.toml b/pyproject.toml index d57e95229c1f..3f1e4b594877 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,7 +53,7 @@ aiohttp = "^3.8.3" click = "^8.1.3" frozendict = "^2.3.4" fsspec = ">=2023.1.0" -msgspec = "^0.12.0" +msgspec = "^0.13.0" numpy = "^1.24.2" pandas = "^1.5.3" psutil = "^5.9.4" diff --git a/tests/integration_tests/adapters/interactive_brokers/test_providers.py b/tests/integration_tests/adapters/interactive_brokers/test_providers.py index fd77fd47830b..81f2afa2b09a 100644 --- a/tests/integration_tests/adapters/interactive_brokers/test_providers.py +++ b/tests/integration_tests/adapters/interactive_brokers/test_providers.py @@ -252,6 +252,7 @@ async def test_instrument_filter_callable_none(self, mocker): # Assert assert len(self.provider.get_all()) == 1 + @pytest.mark.skip(reason="Configs now immutable, limx0 to fix") @pytest.mark.asyncio async def test_instrument_filter_callable_option_filter(self, mocker): # Arrange diff --git a/tests/integration_tests/live/test_live_node.py b/tests/integration_tests/live/test_live_node.py index 33e260a29398..549734f81268 100644 --- a/tests/integration_tests/live/test_live_node.py +++ b/tests/integration_tests/live/test_live_node.py @@ -34,7 +34,6 @@ from nautilus_trader.backtest.data.providers import TestInstrumentProvider from nautilus_trader.config import CacheDatabaseConfig from nautilus_trader.config import TradingNodeConfig -from nautilus_trader.core.uuid import UUID4 from nautilus_trader.live.node import TradingNode from nautilus_trader.model.identifiers import StrategyId @@ -181,9 +180,8 @@ def test_setting_instance_id(self, monkeypatch): config = TradingNodeConfig.parse(RAW_CONFIG) # Act - config.instance_id = UUID4().value node = TradingNode(config) - assert node.kernel.instance_id.value == config.instance_id + assert len(node.kernel.instance_id.value) == 36 class TestTradingNodeOperation: diff --git a/tests/unit_tests/persistence/test_streaming.py b/tests/unit_tests/persistence/test_streaming.py index 7b676c2ef666..8d6ad8c20c56 100644 --- a/tests/unit_tests/persistence/test_streaming.py +++ b/tests/unit_tests/persistence/test_streaming.py @@ -67,6 +67,7 @@ def _load_data_into_catalog(self): ) assert len(data) == 2535 + @pytest.mark.skip(reason="Configs now immutable, ghill2 to fix") @pytest.mark.skipif(sys.platform == "win32", reason="Currently flaky on Windows") def test_feather_writer(self): # Arrange From 433f0c193f203c21e20088934edc839eff2348a4 Mon Sep 17 00:00:00 2001 From: ghill2 Date: Thu, 9 Feb 2023 15:29:19 +0000 Subject: [PATCH 31/81] Fix StreamingConfig (#994) --- .../integration_tests/adapters/betfair/test_kit.py | 13 +++++++++++-- tests/unit_tests/persistence/test_streaming.py | 9 ++++++--- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/tests/integration_tests/adapters/betfair/test_kit.py b/tests/integration_tests/adapters/betfair/test_kit.py index a27d1c4d0207..fbe85e851cb3 100644 --- a/tests/integration_tests/adapters/betfair/test_kit.py +++ b/tests/integration_tests/adapters/betfair/test_kit.py @@ -214,10 +214,15 @@ def betfair_venue_config() -> BacktestVenueConfig: ) @staticmethod - def streaming_config(catalog_path: str, catalog_fs_protocol: str = "memory") -> StreamingConfig: + def streaming_config( + catalog_path: str, + catalog_fs_protocol: str = "memory", + flush_interval_ms: int = None, + ) -> StreamingConfig: return StreamingConfig( catalog_path=catalog_path, fs_protocol=catalog_fs_protocol, + flush_interval_ms=flush_interval_ms, ) @staticmethod @@ -228,12 +233,16 @@ def betfair_backtest_run_config( persist=True, add_strategy=True, bypass_risk=False, + flush_interval_ms: int = None, ) -> BacktestRunConfig: engine_config = BacktestEngineConfig( log_level="INFO", bypass_logging=True, risk_engine=RiskEngineConfig(bypass=bypass_risk), - streaming=BetfairTestStubs.streaming_config(catalog_path=catalog_path) + streaming=BetfairTestStubs.streaming_config( + catalog_path=catalog_path, + flush_interval_ms=flush_interval_ms, + ) if persist else None, strategies=[ diff --git a/tests/unit_tests/persistence/test_streaming.py b/tests/unit_tests/persistence/test_streaming.py index 8d6ad8c20c56..fa18fb1206cd 100644 --- a/tests/unit_tests/persistence/test_streaming.py +++ b/tests/unit_tests/persistence/test_streaming.py @@ -67,17 +67,20 @@ def _load_data_into_catalog(self): ) assert len(data) == 2535 - @pytest.mark.skip(reason="Configs now immutable, ghill2 to fix") @pytest.mark.skipif(sys.platform == "win32", reason="Currently flaky on Windows") def test_feather_writer(self): # Arrange instrument = self.catalog.instruments(as_nautilus=True)[0] + + catalog_path = "/.nautilus/catalog" + run_config = BetfairTestStubs.betfair_backtest_run_config( - catalog_path="/.nautilus/catalog", + catalog_path=catalog_path, catalog_fs_protocol="memory", instrument_id=instrument.id.value, + flush_interval_ms=5000, ) - run_config.engine.streaming.flush_interval_ms = 5000 + node = BacktestNode(configs=[run_config]) # Act From 0a58dba1676598df0565c66b0b1940624b534879 Mon Sep 17 00:00:00 2001 From: Chris Sellers Date: Thu, 9 Feb 2023 20:45:11 -0700 Subject: [PATCH 32/81] Skip Betfair providers tests in CI for now --- .../integration_tests/adapters/betfair/test_betfair_providers.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/integration_tests/adapters/betfair/test_betfair_providers.py b/tests/integration_tests/adapters/betfair/test_betfair_providers.py index 5e4457182bd0..01036d9401b7 100644 --- a/tests/integration_tests/adapters/betfair/test_betfair_providers.py +++ b/tests/integration_tests/adapters/betfair/test_betfair_providers.py @@ -36,6 +36,7 @@ from tests.integration_tests.adapters.betfair.test_kit import BetfairTestStubs +@pytest.mark.skip(reason="Flaky in CI") @pytest.mark.skipif(sys.platform == "win32", reason="Failing on windows") class TestBetfairInstrumentProvider: def setup(self): From 104bcde7bb18a06cc35d1b70056dd1d2c5f985a8 Mon Sep 17 00:00:00 2001 From: Chris Sellers Date: Sun, 12 Feb 2023 08:49:26 -0700 Subject: [PATCH 33/81] Update dependencies --- README.md | 6 +-- nautilus_core/Cargo.lock | 20 ++++----- nautilus_core/Cargo.toml | 2 +- nautilus_core/rust-toolchain.toml | 2 +- poetry.lock | 74 +++++++++++++++---------------- pyproject.toml | 2 +- 6 files changed, 53 insertions(+), 53 deletions(-) diff --git a/README.md b/README.md index 96624507ff1f..9a55ebf09fa9 100644 --- a/README.md +++ b/README.md @@ -15,9 +15,9 @@ | Platform | Rust | Python | |:------------------|:----------|:-------| -| Linux (x86\_64) | `1.67.0+` | `3.9+` | -| macOS (x86\_64) | `1.67.0+` | `3.9+` | -| Windows (x86\_64) | `1.67.0+` | `3.9+` | +| Linux (x86\_64) | `1.67.1+` | `3.9+` | +| macOS (x86\_64) | `1.67.1+` | `3.9+` | +| Windows (x86\_64) | `1.67.1+` | `3.9+` | - **Website:** https://nautilustrader.io - **Docs:** https://docs.nautilustrader.io diff --git a/nautilus_core/Cargo.lock b/nautilus_core/Cargo.lock index 8c9e42530959..a062998ecb67 100644 --- a/nautilus_core/Cargo.lock +++ b/nautilus_core/Cargo.lock @@ -423,9 +423,9 @@ dependencies = [ [[package]] name = "cxx" -version = "1.0.89" +version = "1.0.90" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc831ee6a32dd495436e317595e639a587aa9907bef96fe6e6abc290ab6204e9" +checksum = "90d59d9acd2a682b4e40605a242f6670eaa58c5957471cbf85e8aa6a0b97a5e8" dependencies = [ "cc", "cxxbridge-flags", @@ -435,9 +435,9 @@ dependencies = [ [[package]] name = "cxx-build" -version = "1.0.89" +version = "1.0.90" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94331d54f1b1a8895cd81049f7eaaaef9d05a7dcb4d1fd08bf3ff0806246789d" +checksum = "ebfa40bda659dd5c864e65f4c9a2b0aff19bea56b017b9b77c73d3766a453a38" dependencies = [ "cc", "codespan-reporting", @@ -450,15 +450,15 @@ dependencies = [ [[package]] name = "cxxbridge-flags" -version = "1.0.89" +version = "1.0.90" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48dcd35ba14ca9b40d6e4b4b39961f23d835dbb8eed74565ded361d93e1feb8a" +checksum = "457ce6757c5c70dc6ecdbda6925b958aae7f959bda7d8fb9bde889e34a09dc03" [[package]] name = "cxxbridge-macro" -version = "1.0.89" +version = "1.0.90" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81bbeb29798b407ccd82a3324ade1a7286e0d29851475990b612670f6f5124d2" +checksum = "ebf883b7aacd7b2aeb2a7b338648ee19f57c140d4ee8e52c68979c6b2f7f2263" dependencies = [ "proc-macro2", "quote", @@ -1544,9 +1544,9 @@ dependencies = [ [[package]] name = "target-lexicon" -version = "0.12.5" +version = "0.12.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9410d0f6853b1d94f0e519fb95df60f29d2c1eff2d921ffdf01a4c8a3b54f12d" +checksum = "8ae9980cab1db3fceee2f6c6f643d5d8de2997c58ee8d25fb0cc8a9e9e7348e5" [[package]] name = "tempfile" diff --git a/nautilus_core/Cargo.toml b/nautilus_core/Cargo.toml index 57b5c5ffd600..615d82f59ec6 100644 --- a/nautilus_core/Cargo.toml +++ b/nautilus_core/Cargo.toml @@ -8,7 +8,7 @@ members = [ ] [workspace.package] -rust-version = "1.67.0" +rust-version = "1.67.1" version = "0.2.0" edition = "2021" authors = ["Nautech Systems "] diff --git a/nautilus_core/rust-toolchain.toml b/nautilus_core/rust-toolchain.toml index c8890a7c7d3e..31445fb68022 100644 --- a/nautilus_core/rust-toolchain.toml +++ b/nautilus_core/rust-toolchain.toml @@ -1,3 +1,3 @@ [toolchain] -version = "1.67.0" +version = "1.67.1" channel = "stable" diff --git a/poetry.lock b/poetry.lock index 24593fe6d9b9..0935b91502de 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1332,41 +1332,41 @@ files = [ [[package]] name = "msgspec" -version = "0.13.0" +version = "0.13.1" description = "A fast serialization and validation library, with builtin support for JSON, MessagePack, YAML, and TOML." category = "main" optional = false python-versions = ">=3.8" files = [ - {file = "msgspec-0.13.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8bbf33b1c2a25793af463c463c30ec6dbe4305130ea7bcb12d85bd92feb252af"}, - {file = "msgspec-0.13.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f54aaad28cd0f6983187d5280d289536a668910a6c431162324bc2576f5b4831"}, - {file = "msgspec-0.13.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6a66cf73a4392196720193d35f0e0fd1613872371c928017c4363f5a7c150e96"}, - {file = "msgspec-0.13.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:52e74cf0a0907553acd9946e3c1416b47e1d568eb004049820d9055644ad0478"}, - {file = "msgspec-0.13.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:b0cf6084a4eb9d906f9292d1863e3d12c0d624551fb79c1f12f910fbb1e818e7"}, - {file = "msgspec-0.13.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:46b89036786e5907855602645b71b87269e43e0094541946ba983a36d62f1b4e"}, - {file = "msgspec-0.13.0-cp310-cp310-win_amd64.whl", hash = "sha256:c53799555c5ac056473cc35557d8b9ef045e24507f73b5256ce8abe00136142f"}, - {file = "msgspec-0.13.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:084c3aedaf3467c11b7afc4fbe1ac709030519706454c24b2e11e55212adba24"}, - {file = "msgspec-0.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3b3fb5a25999761cc55d3f3c1ae598a449898bf9251702ba2e27bca2f5b56ad5"}, - {file = "msgspec-0.13.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5e48a3b9a290dd8197bd6cd6331eb2d2b9e136ec725dd77881728c334435f537"}, - {file = "msgspec-0.13.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:837584e46611cc56509e23d688b5d263bee6a3e42d83c31857b9f5cbd0c44b91"}, - {file = "msgspec-0.13.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:dbaaf643c0c4d5372eaec722392defca336c9fef1f221d7c46b7e76d48482f1d"}, - {file = "msgspec-0.13.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:94fc2c21c1eaf5b92964b40bfabd51b65e29f6756376d328474d6b2e703f8e51"}, - {file = "msgspec-0.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:70051d3efa22ab8770b3cb0180447e684efdc5d22f1e8ceca38bc0127d4d8052"}, - {file = "msgspec-0.13.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:698659567159fe87fa3b513307c9a095a05add3cbcff3d5c8669f4a386f1b08d"}, - {file = "msgspec-0.13.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7e3ef9b42215642f53f9e07caf037432568025bf2924a907a69b6afe0a70caf2"}, - {file = "msgspec-0.13.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fbcd283629a5c5b3e03591381adfc623104ada08bfa50733c43f756c1229ab8c"}, - {file = "msgspec-0.13.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:610ba7e84ad84147e996252b8b736b5bda214d6117f6158cdb3bae1135bd5e90"}, - {file = "msgspec-0.13.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:5d8ec7b202c24f013ff71ba451e879b47d0ebb2ad6fa195d662bf9b58651a60d"}, - {file = "msgspec-0.13.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:9a408bb35bcbe46c02e76eda300fb55e7f8d6b31ce52cf143248b6d241cf82cd"}, - {file = "msgspec-0.13.0-cp38-cp38-win_amd64.whl", hash = "sha256:f5d51fe5eef8c6b4545cb9a06a83034c02e6158077fd134d372cff1db2b812ac"}, - {file = "msgspec-0.13.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6957112ca69d6b7cecf6e02aaac5da7702e92e9b261fac692264124f3b5cba65"}, - {file = "msgspec-0.13.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:7657bbc8e1f60076cdf1ab48df828894b7222cad410838880f5e302221e1d874"}, - {file = "msgspec-0.13.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f70197352ec5cdb7bbd9955fa890962b56908770ba62c70ac24cb36a4e178b4"}, - {file = "msgspec-0.13.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c4257d3f1ed2b3e7dd35e9229f0ef59f4c8a6adf881f62b918dce65c730e5b32"}, - {file = "msgspec-0.13.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0605914d4afc009aeafb18777414cb91edfd138ac8445dfc29c62e63eb703a00"}, - {file = "msgspec-0.13.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:c5fc20dc8d1c1d8f6ea7e064b8f638b04dd98e5f590471c5dfe3aa8fd7d88129"}, - {file = "msgspec-0.13.0-cp39-cp39-win_amd64.whl", hash = "sha256:68c5bf57a5cdd5791f360585a9b850e2758a27c2e6530ed64715b7cbcee9993e"}, - {file = "msgspec-0.13.0.tar.gz", hash = "sha256:c89bbc5b82f1370165593ed28c8ce9dfa71869666ed8ba3af900b3465d379545"}, + {file = "msgspec-0.13.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a07a50afb119728e4969ab58602985549b8b9af6cf565873fad28365eb7dbf7b"}, + {file = "msgspec-0.13.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:014e6e0b01946528f609102c5624749501f63ebdeba386855948185288bc299d"}, + {file = "msgspec-0.13.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ea1d6de944dd2d369186ce9f3d98119980427fd8fba0788d819d775fe0440442"}, + {file = "msgspec-0.13.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:037ff80d956bb13fc8c221a386f7ba97f6e55d0dc84f4bfe92226df570140802"}, + {file = "msgspec-0.13.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:8f0679fbafb889e4165bf49c2afd342716dcee2fe13de3777f1613c09740f304"}, + {file = "msgspec-0.13.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:99d239343d2b78ad139277067e727ad573445eec6b7ba113e4b987e15728d9d4"}, + {file = "msgspec-0.13.1-cp310-cp310-win_amd64.whl", hash = "sha256:88466f804b817151cbff9e53d698f86a9280b0d00896302abb528be01c46701a"}, + {file = "msgspec-0.13.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:494de96dc6ad90c2f3ba96130591ade518dfd019be767e6030c73ea3e9eb6df9"}, + {file = "msgspec-0.13.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b8853fdf1c44cb874c786133ce1062b6d9a484e0fa48f7174035eb506f462042"}, + {file = "msgspec-0.13.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ac902ccdfa4574498342d06f4c49bfa60317724737985b2fc954e8c8eb009910"}, + {file = "msgspec-0.13.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7b0e594996a042fb6dcd9839094b6ef8768e7707bf1567e4d61cbf07c1d12f24"}, + {file = "msgspec-0.13.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:2d32dedfeb5646ac339eb4602886861756f050c6307e2177b7c68f5084519069"}, + {file = "msgspec-0.13.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:de920ff863aceef8304a1c5c3855f089782429f6fdbfea1321061f37e4ba418a"}, + {file = "msgspec-0.13.1-cp311-cp311-win_amd64.whl", hash = "sha256:36b919dad8cbf98d1e2b3d829bf26fbe64205a08ee1caa6988581e5850bcb6b1"}, + {file = "msgspec-0.13.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:30c80380bfe3a03b2e968f08bdbec8a9fc1619cd2555cc8334774c475296faaa"}, + {file = "msgspec-0.13.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:0e12db829b670159780d4483248c5099b99cc487f3c134f2d3e6ce59fa18ce67"}, + {file = "msgspec-0.13.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa7febe37689642a1a2ccdeecf3a8b820ea043cd142438bd0a88714c982555a0"}, + {file = "msgspec-0.13.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:317ec8e80cf919c42b6defb375e12ad49af649238327d0abaed980c4d72a7bea"}, + {file = "msgspec-0.13.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:d51cdd579b632e3686c14eee9948aeba056ea969b85b60e09aa9811cea3524cd"}, + {file = "msgspec-0.13.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:348415004e907c856e22884485c9f91dc8f39317ce365b0881a08ac35f234afe"}, + {file = "msgspec-0.13.1-cp38-cp38-win_amd64.whl", hash = "sha256:b80b58d8fa70f881701a3bff50661327b4f5a50441d33386fac95a94eb3cad1d"}, + {file = "msgspec-0.13.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:96f7898b4433a2570636cf0659ac7cedd1b753f0a108fa0ff82a813a830c5a98"}, + {file = "msgspec-0.13.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:fae7c7e6f7715cebc34ce616b078dc2c021651115915d0c2ea507d9c9074e34d"}, + {file = "msgspec-0.13.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:050af5e263eece6a16c07ba651ab83208acccfccc5ab8b47fa6cf99c135f01a9"}, + {file = "msgspec-0.13.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e143da1561b6a766c2fccdb9c487dfcf1d56c8c1b9c8c8c5c3e322f02fbb8c11"}, + {file = "msgspec-0.13.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:59e572b78da8264b2ce3dbb65bec6a4a65cb355046b0d5fc16df24626e889947"}, + {file = "msgspec-0.13.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:bf92cbef573127966cccce71eb9368a6ce36c847ee128e048c7c04724daae855"}, + {file = "msgspec-0.13.1-cp39-cp39-win_amd64.whl", hash = "sha256:358da53e870d8bdb890b27c2cd9a670bc17c1d4330908d3fb6e00dcdc8d9c54c"}, + {file = "msgspec-0.13.1.tar.gz", hash = "sha256:b252174d33c7d7885e5efe8009147f09da4bf6655b86dba9128a2a4d4c1fb6ee"}, ] [package.extras] @@ -2639,14 +2639,14 @@ files = [ [[package]] name = "types-redis" -version = "4.4.0.6" +version = "4.5.1.0" description = "Typing stubs for redis" category = "dev" optional = false python-versions = "*" files = [ - {file = "types-redis-4.4.0.6.tar.gz", hash = "sha256:57f8b3706afe47ef36496d70a97a3783560e6cb19e157be12985dbb31de1d853"}, - {file = "types_redis-4.4.0.6-py3-none-any.whl", hash = "sha256:8b40d6bf3a54352d4cb2aa7d01294c572a39d40a9d289b96bdf490b51d3a42d2"}, + {file = "types-redis-4.5.1.0.tar.gz", hash = "sha256:6f6fb1cfeee3708112dec3609a042774f96f2cfcb4709d267c11f51a6976da0a"}, + {file = "types_redis-4.5.1.0-py3-none-any.whl", hash = "sha256:dac6ea398c57a53213b70727be7c8e3a788ded3c3880e94bf74e85c22aa63c7e"}, ] [package.dependencies] @@ -2936,14 +2936,14 @@ multidict = ">=4.0" [[package]] name = "zipp" -version = "3.12.1" +version = "3.13.0" description = "Backport of pathlib-compatible object wrapper for zip files" category = "dev" optional = false python-versions = ">=3.7" files = [ - {file = "zipp-3.12.1-py3-none-any.whl", hash = "sha256:6c4fe274b8f85ec73c37a8e4e3fa00df9fb9335da96fb789e3b96b318e5097b3"}, - {file = "zipp-3.12.1.tar.gz", hash = "sha256:a3cac813d40993596b39ea9e93a18e8a2076d5c378b8bc88ec32ab264e04ad02"}, + {file = "zipp-3.13.0-py3-none-any.whl", hash = "sha256:e8b2a36ea17df80ffe9e2c4fda3f693c3dad6df1697d3cd3af232db680950b0b"}, + {file = "zipp-3.13.0.tar.gz", hash = "sha256:23f70e964bc11a34cef175bc90ba2914e1e4545ea1e3e2f67c079671883f9cb6"}, ] [package.extras] @@ -2959,4 +2959,4 @@ redis = ["hiredis", "redis"] [metadata] lock-version = "2.0" python-versions = ">=3.9,<3.12" -content-hash = "d6539ff50eefb5ee7c2ccde23462fa4dcc0feb48c8716f643ff6e4f331ca06a7" +content-hash = "6f506b82f59ea2dd1ad9a9ea8ae7a6919b8cda450802b026dc15dc28ae5e44f5" diff --git a/pyproject.toml b/pyproject.toml index 3f1e4b594877..df700e888c37 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,7 +53,7 @@ aiohttp = "^3.8.3" click = "^8.1.3" frozendict = "^2.3.4" fsspec = ">=2023.1.0" -msgspec = "^0.13.0" +msgspec = "^0.13.1" numpy = "^1.24.2" pandas = "^1.5.3" psutil = "^5.9.4" From b7948efa7af55b14290e5c671969c6c0913c810a Mon Sep 17 00:00:00 2001 From: Chris Sellers Date: Sun, 12 Feb 2023 08:58:02 -0700 Subject: [PATCH 34/81] Upgrade types-redis --- poetry.lock | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/poetry.lock b/poetry.lock index 0935b91502de..6aad0f64e9df 100644 --- a/poetry.lock +++ b/poetry.lock @@ -2959,4 +2959,4 @@ redis = ["hiredis", "redis"] [metadata] lock-version = "2.0" python-versions = ">=3.9,<3.12" -content-hash = "6f506b82f59ea2dd1ad9a9ea8ae7a6919b8cda450802b026dc15dc28ae5e44f5" +content-hash = "0a403f9f02355b959655c29c4cc8310f882857daee230503923f185b29b7ada2" diff --git a/pyproject.toml b/pyproject.toml index df700e888c37..4f1a78a5578e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -86,7 +86,7 @@ mypy = "^1.0.0" pre-commit = "^3.0.4" pyproject-flake8 = "^6.0.0" types-pytz = "^2022.6.0" -types-redis = "^4.3.21" +types-redis = "^4.5.1" types-requests = "^2.28.11" types-toml = "^0.10.8" From 3a9da769689637e0142f8bcc67c48ba459c08a1e Mon Sep 17 00:00:00 2001 From: Chris Sellers Date: Sun, 12 Feb 2023 09:07:13 -0700 Subject: [PATCH 35/81] Remove redundant import --- nautilus_trader/infrastructure/cache.pyx | 1 - 1 file changed, 1 deletion(-) diff --git a/nautilus_trader/infrastructure/cache.pyx b/nautilus_trader/infrastructure/cache.pyx index c71b13ec8df7..0ccba648fda6 100644 --- a/nautilus_trader/infrastructure/cache.pyx +++ b/nautilus_trader/infrastructure/cache.pyx @@ -13,7 +13,6 @@ # limitations under the License. # ------------------------------------------------------------------------------------------------- -import pickle import warnings from typing import Optional From 31e47d43f2cf50cb0f2effc1f39dc53edc7ad729 Mon Sep 17 00:00:00 2001 From: Chris Sellers Date: Sun, 12 Feb 2023 09:07:54 -0700 Subject: [PATCH 36/81] Relax order of events assertion - This handles transformed emulated orders --- nautilus_trader/model/orders/base.pyx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/nautilus_trader/model/orders/base.pyx b/nautilus_trader/model/orders/base.pyx index 29bb7671a1b6..52d0f0f8e940 100644 --- a/nautilus_trader/model/orders/base.pyx +++ b/nautilus_trader/model/orders/base.pyx @@ -806,9 +806,7 @@ cdef class Order: Condition.equal(self.venue_order_id, event.venue_order_id, "self.venue_order_id", "event.venue_order_id") # Handle event (FSM can raise InvalidStateTrigger) - if isinstance(event, OrderInitialized): - Condition.true(not self._events, "`OrderInitialized` should be the first order event") - elif isinstance(event, OrderDenied): + if isinstance(event, OrderDenied): self._fsm.trigger(OrderStatus.DENIED) self._denied(event) elif isinstance(event, OrderSubmitted): From 24b9236aeeb8badc7fe543f393c1ca88e5ce04da Mon Sep 17 00:00:00 2001 From: Chris Sellers Date: Sun, 12 Feb 2023 09:39:43 -0700 Subject: [PATCH 37/81] Remove redundant account calc warning --- nautilus_trader/accounting/manager.pyx | 6 ------ 1 file changed, 6 deletions(-) diff --git a/nautilus_trader/accounting/manager.pyx b/nautilus_trader/accounting/manager.pyx index 3a7814924baf..fbdc17822ab2 100644 --- a/nautilus_trader/accounting/manager.pyx +++ b/nautilus_trader/accounting/manager.pyx @@ -200,9 +200,6 @@ cdef class AccountsManager: assert order.is_open_c() if not order.has_price_c() and not order.has_trigger_price_c(): - self._log.warning( - "Cannot update account without initial trigger price.", - ) continue # Calculate balance locked @@ -299,9 +296,6 @@ cdef class AccountsManager: assert order.is_open_c() if not order.has_price_c() and not order.has_trigger_price_c(): - self._log.warning( - "Cannot update account without initial trigger price.", - ) continue # Calculate initial margin From 733b5bdd1a31fdb4915b9d1e748c60afc005cda1 Mon Sep 17 00:00:00 2001 From: Chris Sellers Date: Sun, 12 Feb 2023 10:19:07 -0700 Subject: [PATCH 38/81] Binance cleanup --- .../adapters/binance/common/data.py | 10 ++-- .../adapters/binance/futures/http/wallet.py | 2 +- .../adapters/binance/futures/providers.py | 10 ++-- .../adapters/binance/futures/schemas/user.py | 8 +-- .../adapters/binance/http/account.py | 33 +++++------- .../adapters/binance/http/error.py | 12 ++--- .../adapters/binance/http/market.py | 16 ++---- nautilus_trader/adapters/binance/http/user.py | 1 - .../adapters/binance/spot/http/account.py | 7 +-- .../adapters/binance/spot/schemas/user.py | 2 +- .../adapters/binance/spot/schemas/wallet.py | 2 +- ...ttp_futures_testnet_instrument_provider.py | 53 +++++++++++++++++++ .../sandbox_http_spot_instrument_provider.py | 2 +- 13 files changed, 92 insertions(+), 66 deletions(-) create mode 100644 tests/integration_tests/adapters/binance/sandbox/sandbox_http_futures_testnet_instrument_provider.py diff --git a/nautilus_trader/adapters/binance/common/data.py b/nautilus_trader/adapters/binance/common/data.py index d8aa6484ea08..467f76d9b6f4 100644 --- a/nautilus_trader/adapters/binance/common/data.py +++ b/nautilus_trader/adapters/binance/common/data.py @@ -161,7 +161,7 @@ def __init__( self._log.info(f"Base URL HTTP {self._http_client.base_url}.", LogColor.BLUE) self._log.info(f"Base URL WebSocket {base_url_ws}.", LogColor.BLUE) - # Register common websocket message handlers + # Register common WebSocket message handlers self._ws_handlers = { "@bookTicker": self._handle_book_ticker, "@ticker": self._handle_ticker, @@ -174,7 +174,7 @@ def __init__( "@depth20": self._handle_book_partial_update, } - # Websocket msgspec decoders + # WebSocket msgspec decoders self._decoder_data_msg_wrapper = msgspec.json.Decoder(BinanceDataMsgWrapper) self._decoder_order_book_msg = msgspec.json.Decoder(BinanceOrderBookMsg) self._decoder_quote_msg = msgspec.json.Decoder(BinanceQuoteMsg) @@ -476,7 +476,7 @@ async def _request_trade_ticks( if from_datetime is not None or to_datetime is not None: self._log.warning( "Trade ticks have been requested with a from/to time range, " - f"however the request will be for the most recent {limit}." + f"however the request will be for the most recent {limit}. " "Consider using aggregated trade ticks (`use_agg_trade_ticks`).", ) ticks = await self._http_market.request_trade_ticks( @@ -529,8 +529,8 @@ async def _request_bars( # noqa (too complex) resolution = self._enum_parser.parse_internal_bar_agg(bar_type.spec.aggregation) if not self._binance_account_type.is_spot_or_margin and resolution == "s": self._log.error( - f"Cannot request {bar_type}.", - "Second interval bars are not aggregated by Binance Futures.", + f"Cannot request {bar_type}: ", + "second interval bars are not aggregated by Binance Futures.", ) try: interval = BinanceKlineInterval(f"{bar_type.spec.step}{resolution}") diff --git a/nautilus_trader/adapters/binance/futures/http/wallet.py b/nautilus_trader/adapters/binance/futures/http/wallet.py index 38d8914118bc..90b94a16490e 100644 --- a/nautilus_trader/adapters/binance/futures/http/wallet.py +++ b/nautilus_trader/adapters/binance/futures/http/wallet.py @@ -57,7 +57,7 @@ def __init__( class GetParameters(msgspec.Struct, omit_defaults=True, frozen=True): """ - GET parameters for fetching commission rate + GET parameters for fetching commission rate. Parameters ---------- diff --git a/nautilus_trader/adapters/binance/futures/providers.py b/nautilus_trader/adapters/binance/futures/providers.py index 35e089af32ba..fc233dea34a5 100644 --- a/nautilus_trader/adapters/binance/futures/providers.py +++ b/nautilus_trader/adapters/binance/futures/providers.py @@ -148,9 +148,8 @@ async def load_ids_async( } for symbol in symbols: - if self._client.base_url.__contains__("testnet.binancefuture.com"): - fee = None - else: + fee: Optional[BinanceFuturesCommissionRate] = None + if not self._client.base_url.__contains__("testnet.binancefuture.com"): try: # Get current commission rates for the symbol fee = await self._http_wallet.query_futures_commission_rate(symbol) @@ -181,9 +180,8 @@ async def load_async(self, instrument_id: InstrumentId, filters: Optional[dict] info.symbol: info for info in exchange_info.symbols } - if self._client.base_url.__contains__("testnet.binancefuture.com"): - fee = None - else: + fee: Optional[BinanceFuturesCommissionRate] = None + if not self._client.base_url.__contains__("testnet.binancefuture.com"): try: # Get current commission rates for the symbol fee = await self._http_wallet.query_futures_commission_rate(symbol) diff --git a/nautilus_trader/adapters/binance/futures/schemas/user.py b/nautilus_trader/adapters/binance/futures/schemas/user.py index fe4868e2f273..050858fa36fe 100644 --- a/nautilus_trader/adapters/binance/futures/schemas/user.py +++ b/nautilus_trader/adapters/binance/futures/schemas/user.py @@ -55,17 +55,13 @@ class BinanceFuturesUserMsgData(msgspec.Struct, frozen=True): - """ - Inner struct for execution WebSocket messages from `Binance` - """ + """Inner struct for execution WebSocket messages from `Binance`.""" e: BinanceFuturesEventType class BinanceFuturesUserMsgWrapper(msgspec.Struct, frozen=True): - """ - Provides a wrapper for execution WebSocket messages from `Binance`. - """ + """Provides a wrapper for execution WebSocket messages from `Binance`.""" stream: str data: BinanceFuturesUserMsgData diff --git a/nautilus_trader/adapters/binance/http/account.py b/nautilus_trader/adapters/binance/http/account.py index 50c8b4619952..999ff248d9b6 100644 --- a/nautilus_trader/adapters/binance/http/account.py +++ b/nautilus_trader/adapters/binance/http/account.py @@ -35,7 +35,7 @@ class BinanceOrderHttp(BinanceHttpEndpoint): """ - Endpoint for managing orders + Endpoint for managing orders. `GET /api/v3/order` `GET /api/v3/order/test` @@ -55,7 +55,6 @@ class BinanceOrderHttp(BinanceHttpEndpoint): https://binance-docs.github.io/apidocs/spot/en/#new-order-trade https://binance-docs.github.io/apidocs/futures/en/#new-order-trade https://binance-docs.github.io/apidocs/delivery/en/#new-order-trade - """ def __init__( @@ -96,8 +95,9 @@ class GetDeleteParameters(msgspec.Struct, omit_defaults=True, frozen=True): recvWindow : str, optional the millisecond timeout window. - NOTE: Either orderId or origClientOrderId must be sent. - + Warnings + -------- + Either orderId or origClientOrderId must be sent. """ symbol: BinanceSymbol @@ -108,7 +108,7 @@ class GetDeleteParameters(msgspec.Struct, omit_defaults=True, frozen=True): class PostParameters(msgspec.Struct, omit_defaults=True, frozen=True): """ - Order creation POST endpoint parameters + Order creation POST endpoint parameters. Parameters ---------- @@ -188,7 +188,6 @@ class PostParameters(msgspec.Struct, omit_defaults=True, frozen=True): recvWindow : str, optional The response receive window in milliseconds for the request. Cannot exceed 60000. - """ symbol: BinanceSymbol @@ -243,7 +242,6 @@ class BinanceAllOrdersHttp(BinanceHttpEndpoint): https://binance-docs.github.io/apidocs/spot/en/#all-orders-user_data https://binance-docs.github.io/apidocs/futures/en/#all-orders-user_data https://binance-docs.github.io/apidocs/delivery/en/#all-orders-user_data - """ def __init__( @@ -264,7 +262,7 @@ def __init__( class GetParameters(msgspec.Struct, omit_defaults=True, frozen=True): """ - Parameters of allOrders GET request + Parameters of allOrders GET request. Parameters ---------- @@ -284,7 +282,6 @@ class GetParameters(msgspec.Struct, omit_defaults=True, frozen=True): Default 500, max 1000 recvWindow : str, optional The response receive window for the request (cannot be greater than 60000). - """ symbol: BinanceSymbol @@ -319,7 +316,6 @@ class BinanceOpenOrdersHttp(BinanceHttpEndpoint): https://binance-docs.github.io/apidocs/spot/en/#current-open-orders-user_data https://binance-docs.github.io/apidocs/futures/en/#current-all-open-orders-user_data https://binance-docs.github.io/apidocs/futures/en/#current-all-open-orders-user_data - """ def __init__( @@ -342,7 +338,7 @@ def __init__( class GetParameters(msgspec.Struct, omit_defaults=True, frozen=True): """ - Parameters of openOrders GET request + Parameters of openOrders GET request. Parameters ---------- @@ -352,7 +348,6 @@ class GetParameters(msgspec.Struct, omit_defaults=True, frozen=True): The symbol of the orders recvWindow : str, optional The response receive window for the request (cannot be greater than 60000). - """ timestamp: str @@ -367,7 +362,7 @@ async def _get(self, parameters: GetParameters) -> list[BinanceOrder]: class BinanceUserTradesHttp(BinanceHttpEndpoint): """ - Endpoint of trades for a specific account and symbol + Endpoint of trades for a specific account and symbol. `GET /api/v3/myTrades` `GET /fapi/v1/userTrades` @@ -378,7 +373,6 @@ class BinanceUserTradesHttp(BinanceHttpEndpoint): https://binance-docs.github.io/apidocs/spot/en/#account-trade-list-user_data https://binance-docs.github.io/apidocs/futures/en/#account-trade-list-user_data https://binance-docs.github.io/apidocs/delivery/en/#account-trade-list-user_data - """ def __init__( @@ -398,7 +392,7 @@ def __init__( class GetParameters(msgspec.Struct, omit_defaults=True, frozen=True): """ - Parameters of allOrders GET request + Parameters of allOrders GET request. Parameters ---------- @@ -420,7 +414,6 @@ class GetParameters(msgspec.Struct, omit_defaults=True, frozen=True): Default 500, max 1000 recvWindow : str, optional The response receive window for the request (cannot be greater than 60000). - """ symbol: BinanceSymbol @@ -485,7 +478,7 @@ def __init__( self._endpoint_user_trades = BinanceUserTradesHttp(client, user_trades_url) def _timestamp(self) -> str: - """Create Binance timestamp from internal clock""" + """Create Binance timestamp from internal clock.""" return str(self._clock.timestamp_ms()) async def query_order( @@ -495,7 +488,7 @@ async def query_order( orig_client_order_id: Optional[str] = None, recv_window: Optional[str] = None, ) -> BinanceOrder: - """Check an order status""" + """Check an order status.""" if order_id is None and orig_client_order_id is None: raise RuntimeError( "Either orderId or origClientOrderId must be sent.", @@ -526,7 +519,7 @@ async def cancel_order( orig_client_order_id: Optional[str] = None, recv_window: Optional[str] = None, ) -> BinanceOrder: - """Cancel an active order""" + """Cancel an active order.""" if order_id is None and orig_client_order_id is None: raise RuntimeError( "Either orderId or origClientOrderId must be sent.", @@ -566,7 +559,7 @@ async def new_order( new_order_resp_type: Optional[BinanceNewOrderRespType] = None, recv_window: Optional[str] = None, ) -> BinanceOrder: - """Send in a new order to Binance""" + """Send in a new order to Binance.""" binance_order = await self._endpoint_order._post( parameters=self._endpoint_order.PostParameters( symbol=BinanceSymbol(symbol), diff --git a/nautilus_trader/adapters/binance/http/error.py b/nautilus_trader/adapters/binance/http/error.py index 3394263be60b..32929af9bdc8 100644 --- a/nautilus_trader/adapters/binance/http/error.py +++ b/nautilus_trader/adapters/binance/http/error.py @@ -15,9 +15,7 @@ class BinanceError(Exception): - """ - The base class for all `Binance` specific errors. - """ + """The base class for all `Binance` specific errors.""" def __init__(self, status, message, headers): self.status = status @@ -26,18 +24,14 @@ def __init__(self, status, message, headers): class BinanceServerError(BinanceError): - """ - Represents an `Binance` specific 500 series HTTP error. - """ + """Represents an `Binance` specific 500 series HTTP error.""" def __init__(self, status, message, headers): super().__init__(status, message, headers) class BinanceClientError(BinanceError): - """ - Represents an `Binance` specific 400 series HTTP error. - """ + """Represents an `Binance` specific 400 series HTTP error.""" def __init__(self, status, message, headers): super().__init__(status, message, headers) diff --git a/nautilus_trader/adapters/binance/http/market.py b/nautilus_trader/adapters/binance/http/market.py index 51dcc3efcd6a..62ab8bcaf424 100644 --- a/nautilus_trader/adapters/binance/http/market.py +++ b/nautilus_trader/adapters/binance/http/market.py @@ -176,13 +176,6 @@ class BinanceTradesHttp(BinanceHttpEndpoint): `GET /fapi/v1/trades` `GET /dapi/v1/trades` - Parameters - ---------- - symbol : str - The trading pair. - limit : int, optional - The limit for the response. Default 500; max 1000. - References ---------- https://binance-docs.github.io/apidocs/spot/en/#recent-trades-list @@ -359,7 +352,6 @@ class BinanceKlinesHttp(BinanceHttpEndpoint): https://binance-docs.github.io/apidocs/spot/en/#kline-candlestick-data https://binance-docs.github.io/apidocs/futures/en/#kline-candlestick-data https://binance-docs.github.io/apidocs/delivery/en/#kline-candlestick-data - """ def __init__( @@ -410,7 +402,7 @@ async def _get(self, parameters: GetParameters) -> list[BinanceKline]: class BinanceTicker24hrHttp(BinanceHttpEndpoint): """ - Endpoint of 24 hour rolling window price change statistics. + Endpoint of 24-hour rolling window price change statistics. `GET /api/v3/ticker/24hr` `GET /fapi/v1/ticker/24hr` @@ -447,7 +439,7 @@ def __init__( class GetParameters(msgspec.Struct, omit_defaults=True, frozen=True): """ - GET parameters for 24hr ticker + GET parameters for 24hr ticker. Parameters ---------- @@ -514,11 +506,11 @@ class GetParameters(msgspec.Struct, omit_defaults=True, frozen=True): Parameters ---------- symbol : BinanceSymbol - The trading pair. When given, endpoint will return a single BinanceTickerPrice + The trading pair. When given, endpoint will return a single BinanceTickerPrice. When omitted, endpoint will return a list of BinanceTickerPrice for all trading pairs. symbols : str SPOT/MARGIN only! - List of trading pairs. When given, endpoint will return a list of BinanceTickerPrice + List of trading pairs. When given, endpoint will return a list of BinanceTickerPrice. """ symbol: Optional[BinanceSymbol] = None diff --git a/nautilus_trader/adapters/binance/http/user.py b/nautilus_trader/adapters/binance/http/user.py index 8b3d7ac1029b..ff8d27f37d23 100644 --- a/nautilus_trader/adapters/binance/http/user.py +++ b/nautilus_trader/adapters/binance/http/user.py @@ -55,7 +55,6 @@ class BinanceListenKeyHttp(BinanceHttpEndpoint): https://binance-docs.github.io/apidocs/spot/en/#listen-key-margin https://binance-docs.github.io/apidocs/futures/en/#start-user-data-stream-user_stream https://binance-docs.github.io/apidocs/delivery/en/#start-user-data-stream-user_stream - """ def __init__( diff --git a/nautilus_trader/adapters/binance/spot/http/account.py b/nautilus_trader/adapters/binance/spot/http/account.py index c220321ebe6a..d6ffc3f2c263 100644 --- a/nautilus_trader/adapters/binance/spot/http/account.py +++ b/nautilus_trader/adapters/binance/spot/http/account.py @@ -346,7 +346,9 @@ class GetParameters(msgspec.Struct, omit_defaults=True, frozen=True): recvWindow : str, optional The response receive window for the request (cannot be greater than 60000). - NOTE: If fromId is specified, neither startTime endTime can be provided. + Warnings + -------- + If fromId is specified, neither startTime endTime can be provided. """ timestamp: str @@ -495,7 +497,6 @@ class GetParameters(msgspec.Struct, omit_defaults=True, frozen=True): The millisecond timestamp of the request. recvWindow : str, optional The response receive window for the request (cannot be greater than 60000). - """ timestamp: str @@ -518,7 +519,7 @@ class BinanceSpotAccountHttpAPI(BinanceAccountHttpAPI): clock : LiveClock, The clock for the API client. account_type : BinanceAccountType - The Binance account type, used to select the endpoint prefix + The Binance account type, used to select the endpoint prefix. """ def __init__( diff --git a/nautilus_trader/adapters/binance/spot/schemas/user.py b/nautilus_trader/adapters/binance/spot/schemas/user.py index 2e89d950e9a7..f968b79b6e0a 100644 --- a/nautilus_trader/adapters/binance/spot/schemas/user.py +++ b/nautilus_trader/adapters/binance/spot/schemas/user.py @@ -99,7 +99,7 @@ def parse_to_account_balances(self) -> list[AccountBalance]: return [balance.parse_to_account_balance() for balance in self.B] def handle_account_update(self, exec_client: BinanceCommonExecutionClient): - """Handle BinanceSpotAccountUpdateMsg as payload of outboundAccountPosition""" + """Handle BinanceSpotAccountUpdateMsg as payload of outboundAccountPosition.""" exec_client.generate_account_state( balances=self.parse_to_account_balances(), margins=[], diff --git a/nautilus_trader/adapters/binance/spot/schemas/wallet.py b/nautilus_trader/adapters/binance/spot/schemas/wallet.py index 2412b2200e38..08efafd73481 100644 --- a/nautilus_trader/adapters/binance/spot/schemas/wallet.py +++ b/nautilus_trader/adapters/binance/spot/schemas/wallet.py @@ -22,7 +22,7 @@ class BinanceSpotTradeFee(msgspec.Struct, frozen=True): - """Schema of a single `Binance Spot/Margin` tradeFee""" + """Schema of a single `Binance Spot/Margin` tradeFee.""" symbol: str makerCommission: str diff --git a/tests/integration_tests/adapters/binance/sandbox/sandbox_http_futures_testnet_instrument_provider.py b/tests/integration_tests/adapters/binance/sandbox/sandbox_http_futures_testnet_instrument_provider.py new file mode 100644 index 000000000000..34c05587c6b4 --- /dev/null +++ b/tests/integration_tests/adapters/binance/sandbox/sandbox_http_futures_testnet_instrument_provider.py @@ -0,0 +1,53 @@ +# ------------------------------------------------------------------------------------------------- +# Copyright (C) 2015-2023 Nautech Systems Pty Ltd. All rights reserved. +# https://nautechsystems.io +# +# Licensed under the GNU Lesser General Public License Version 3.0 (the "License"); +# You may not use this file except in compliance with the License. +# You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ------------------------------------------------------------------------------------------------- + +import asyncio +import os + +import pytest + +from nautilus_trader.adapters.binance.common.enums import BinanceAccountType +from nautilus_trader.adapters.binance.factories import get_cached_binance_http_client +from nautilus_trader.adapters.binance.futures.providers import BinanceFuturesInstrumentProvider +from nautilus_trader.common.clock import LiveClock +from nautilus_trader.common.logging import Logger + + +@pytest.mark.asyncio +async def test_binance_futures_testnet_instrument_provider(): + loop = asyncio.get_event_loop() + clock = LiveClock() + + client = get_cached_binance_http_client( + loop=loop, + clock=clock, + logger=Logger(clock=clock), + account_type=BinanceAccountType.FUTURES_USDT, + key=os.getenv("BINANCE_FUTURES_TESTNET_API_KEY"), + secret=os.getenv("BINANCE_FUTURES_TESTNET_API_SECRET"), + ) + await client.connect() + + provider = BinanceFuturesInstrumentProvider( + client=client, + clock=clock, + logger=Logger(clock=clock), + ) + + await provider.load_all_async() + + print(provider.count) + + await client.disconnect() diff --git a/tests/integration_tests/adapters/binance/sandbox/sandbox_http_spot_instrument_provider.py b/tests/integration_tests/adapters/binance/sandbox/sandbox_http_spot_instrument_provider.py index 0e812e438a63..311ce1ba566a 100644 --- a/tests/integration_tests/adapters/binance/sandbox/sandbox_http_spot_instrument_provider.py +++ b/tests/integration_tests/adapters/binance/sandbox/sandbox_http_spot_instrument_provider.py @@ -26,7 +26,7 @@ @pytest.mark.asyncio -async def test_binance_spot_market_http_client(): +async def test_binance_spot_instrument_provider(): clock = LiveClock() client = get_cached_binance_http_client( From 361cc86c2b19a78fe1794f06055f74760e3fbdd7 Mon Sep 17 00:00:00 2001 From: Chris Sellers Date: Sun, 12 Feb 2023 17:11:18 -0700 Subject: [PATCH 39/81] Improve time bars config and options --- RELEASES.md | 2 + nautilus_trader/config/common.py | 8 +++- nautilus_trader/data/aggregation.pxd | 8 ++-- nautilus_trader/data/aggregation.pyx | 46 ++++++++++++++----- nautilus_trader/data/engine.pxd | 3 +- nautilus_trader/data/engine.pyx | 6 ++- .../unit_tests/data/test_data_aggregation.py | 42 +++++++++++++++-- 7 files changed, 92 insertions(+), 23 deletions(-) diff --git a/RELEASES.md b/RELEASES.md index 4851469c2465..01972c1661cd 100644 --- a/RELEASES.md +++ b/RELEASES.md @@ -6,9 +6,11 @@ Released on TBD (UTC). - `NautilusConfig` objects now _pseudo-immutable_ from new msgspec 0.13.0 - Renamed `OrderFactory.bracket` param `post_only_entry` -> `entry_post_only` (consistency with other params) - Renamed `OrderFactory.bracket` param `post_only_tp` -> `tp_post_only` (consistency with other params) +- Renamed `build_time_bars_with_no_updates` -> `time_bars_build_with_no_updates` (consistency with new param) ### Enhancements - Added Binance aggregated trades functionality with `use_agg_trade_ticks`, thanks @poshcoe +- Added `time_bars_timestamp_on_close` option for configurable bar timestamping (True by default) - Implemented optimized logger using Rust MPSC channel and separate thread - Expose and improve `MatchingEngine` public API for custom functionality diff --git a/nautilus_trader/config/common.py b/nautilus_trader/config/common.py index 4cbeee9ede9d..90e3d7c03600 100644 --- a/nautilus_trader/config/common.py +++ b/nautilus_trader/config/common.py @@ -196,15 +196,19 @@ class DataEngineConfig(NautilusConfig): Parameters ---------- - build_time_bars_with_no_updates : bool, default True + time_bars_build_with_no_updates : bool, default True If time bar aggregators will build and emit bars with no new market updates. + time_bars_timestamp_on_close : bool, default True + If time bar aggregators will timestamp `ts_event` on bar close. + If False then will timestamp on bar open. validate_data_sequence : bool, default False If data objects timestamp sequencing will be validated and handled. debug : bool, default False If debug mode is active (will provide extra debug logging). """ - build_time_bars_with_no_updates: bool = True + time_bars_build_with_no_updates: bool = True + time_bars_timestamp_on_close: bool = True validate_data_sequence: bool = False debug: bool = False diff --git a/nautilus_trader/data/aggregation.pxd b/nautilus_trader/data/aggregation.pxd index 9aea5c89d416..e3857924091c 100644 --- a/nautilus_trader/data/aggregation.pxd +++ b/nautilus_trader/data/aggregation.pxd @@ -55,7 +55,7 @@ cdef class BarBuilder: cpdef void update(self, Price price, Quantity size, uint64_t ts_event) except * cpdef void reset(self) except * cpdef Bar build_now(self) - cpdef Bar build(self, uint64_t ts_event) + cpdef Bar build(self, uint64_t ts_event, uint64_t ts_init) cdef class BarAggregator: @@ -70,7 +70,7 @@ cdef class BarAggregator: cpdef void handle_trade_tick(self, TradeTick tick) except * cdef void _apply_update(self, Price price, Quantity size, uint64_t ts_event) except * cdef void _build_now_and_send(self) except * - cdef void _build_and_send(self, uint64_t ts_event) except * + cdef void _build_and_send(self, uint64_t ts_event, uint64_t ts_init) except * cdef class TickBarAggregator(BarAggregator): @@ -90,10 +90,12 @@ cdef class ValueBarAggregator(BarAggregator): cdef class TimeBarAggregator(BarAggregator): cdef Clock _clock cdef bint _build_on_next_tick + cdef uint64_t _stored_open_ns cdef uint64_t _stored_close_ns cdef tuple _cached_update cdef str _timer_name - cdef bint _build_bars_with_no_updates + cdef bint _build_with_no_updates + cdef bint _timestamp_on_close cdef readonly timedelta interval """The aggregators time interval.\n\n:returns: `timedelta`""" diff --git a/nautilus_trader/data/aggregation.pyx b/nautilus_trader/data/aggregation.pyx index 1c16f6df5988..bd22187a8eb8 100644 --- a/nautilus_trader/data/aggregation.pyx +++ b/nautilus_trader/data/aggregation.pyx @@ -25,6 +25,7 @@ from nautilus_trader.common.logging cimport Logger from nautilus_trader.common.logging cimport LoggerAdapter from nautilus_trader.common.timer cimport TimeEvent from nautilus_trader.core.correctness cimport Condition +from nautilus_trader.core.datetime cimport dt_to_unix_nanos from nautilus_trader.core.rust.core cimport millis_to_nanos from nautilus_trader.core.rust.core cimport secs_to_nanos from nautilus_trader.model.data.bar cimport Bar @@ -182,16 +183,18 @@ cdef class BarBuilder: Bar """ - return self.build(self.ts_last) + return self.build(self.ts_last, self.ts_last) - cpdef Bar build(self, uint64_t ts_event): + cpdef Bar build(self, uint64_t ts_event, uint64_t ts_init): """ Return the aggregated bar with the given closing timestamp, and reset. Parameters ---------- ts_event : uint64_t - The UNIX timestamp (nanoseconds) of the bar close. + The UNIX timestamp (nanoseconds) for the bar event. + ts_init : uint64_t + The UNIX timestamp (nanoseconds) for the bar initialization. Returns ------- @@ -212,7 +215,7 @@ cdef class BarBuilder: close=self._close, volume=Quantity(self.volume, self.size_precision), ts_event=ts_event, - ts_init=ts_event, + ts_init=ts_init, ) self._last_close = self._close @@ -304,8 +307,8 @@ cdef class BarAggregator: cdef Bar bar = self._builder.build_now() self._handler(bar) - cdef void _build_and_send(self, uint64_t ts_event) except *: - cdef Bar bar = self._builder.build(ts_event) + cdef void _build_and_send(self, uint64_t ts_event, uint64_t ts_init) except *: + cdef Bar bar = self._builder.build(ts_event=ts_event, ts_init=ts_init) self._handler(bar) @@ -526,8 +529,11 @@ cdef class TimeBarAggregator(BarAggregator): The clock for the aggregator. logger : Logger The logger for the aggregator. - build_bars_with_no_updates : bool, default True + build_with_no_updates : bool, default True If build and emit bars with no new market updates. + timestamp_on_close : bool, default True + If timestamp `ts_event` will be bar close. + If False then timestamp will be bar open. Raises ------ @@ -541,7 +547,8 @@ cdef class TimeBarAggregator(BarAggregator): handler not None: Callable[[Bar], None], Clock clock not None, Logger logger not None, - bint build_bars_with_no_updates = True, + bint build_with_no_updates = True, + bint timestamp_on_close = True, ): super().__init__( instrument=instrument, @@ -557,9 +564,11 @@ cdef class TimeBarAggregator(BarAggregator): self._set_build_timer() self.next_close_ns = self._clock.next_time_ns(self._timer_name) self._build_on_next_tick = False + self._stored_open_ns = dt_to_unix_nanos(self.get_start_time()) self._stored_close_ns = 0 self._cached_update = None - self._build_bars_with_no_updates = build_bars_with_no_updates + self._build_with_no_updates = build_with_no_updates + self._timestamp_on_close = timestamp_on_close def __str__(self): return f"{type(self).__name__}(interval_ns={self.interval_ns}, next_close_ns={self.next_close_ns})" @@ -705,7 +714,12 @@ cdef class TimeBarAggregator(BarAggregator): cdef void _apply_update(self, Price price, Quantity size, uint64_t ts_event) except *: self._builder.update(price, size, ts_event) if self._build_on_next_tick: # (fast C-level check) - self._build_and_send(self._stored_close_ns) + ts_init = ts_event + ts_event = self._stored_close_ns + if not self._timestamp_on_close: + # Timestamp on open + ts_event = self._stored_open_ns + self._build_and_send(ts_event=ts_event, ts_init=ts_init) # Reset flag and clear stored close self._build_on_next_tick = False self._stored_close_ns = 0 @@ -717,10 +731,18 @@ cdef class TimeBarAggregator(BarAggregator): self._stored_close_ns = self.next_close_ns return - if not self._build_bars_with_no_updates and self._builder.count == 0: + if not self._build_with_no_updates and self._builder.count == 0: return # Do not build and emit bar - self._build_and_send(ts_event=event.ts_event) + cdef uint64_t ts_init = event.ts_event + cdef uint64_t ts_event = event.ts_event + if not self._timestamp_on_close: + # Timestamp on open + ts_event = self._stored_open_ns + self._build_and_send(ts_event=ts_event, ts_init=ts_init) + + # Close time becomes the next open time + self._stored_open_ns = event.ts_event # On receiving this event, timer should now have a new `next_time_ns` self.next_close_ns = self._clock.next_time_ns(self._timer_name) diff --git a/nautilus_trader/data/engine.pxd b/nautilus_trader/data/engine.pxd index 8f9178d3af0b..1a17414ccd2e 100644 --- a/nautilus_trader/data/engine.pxd +++ b/nautilus_trader/data/engine.pxd @@ -48,7 +48,8 @@ cdef class DataEngine(Component): cdef dict _routing_map cdef dict _order_book_intervals cdef dict _bar_aggregators - cdef bint _build_time_bars_with_no_updates + cdef bint _time_bars_build_with_no_updates + cdef bint _time_bars_timestamp_on_close cdef bint _validate_data_sequence cdef readonly bint debug diff --git a/nautilus_trader/data/engine.pyx b/nautilus_trader/data/engine.pyx index c64ed467768d..08d7837b54df 100644 --- a/nautilus_trader/data/engine.pyx +++ b/nautilus_trader/data/engine.pyx @@ -126,7 +126,8 @@ cdef class DataEngine(Component): # Settings self.debug = config.debug - self._build_time_bars_with_no_updates = config.build_time_bars_with_no_updates + self._time_bars_build_with_no_updates = config.time_bars_build_with_no_updates + self._time_bars_timestamp_on_close = config.time_bars_timestamp_on_close self._validate_data_sequence = config.validate_data_sequence # Counters @@ -1330,7 +1331,8 @@ cdef class DataEngine(Component): handler=self.process, clock=self._clock, logger=self._log.get_logger(), - build_bars_with_no_updates=self._build_time_bars_with_no_updates, + build_with_no_updates=self._time_bars_build_with_no_updates, + timestamp_on_close=self._time_bars_timestamp_on_close, ) elif bar_type.spec.aggregation == BarAggregation.TICK: aggregator = TickBarAggregator( diff --git a/tests/unit_tests/data/test_data_aggregation.py b/tests/unit_tests/data/test_data_aggregation.py index 15062b3ccdc1..d3bf91838699 100644 --- a/tests/unit_tests/data/test_data_aggregation.py +++ b/tests/unit_tests/data/test_data_aggregation.py @@ -144,7 +144,7 @@ def test_set_partial_when_already_set_does_not_update(self): builder.set_partial(partial_bar1) builder.set_partial(partial_bar2) - bar = builder.build(4_000_000_000) + bar = builder.build(4_000_000_000, 4_000_000_000) # Assert assert bar.open == Price.from_str("1.00001") @@ -1307,7 +1307,7 @@ def test_aggregation_for_same_sec_and_minute_intervals(self, step, aggregation): assert handler[0].ts_event == 1610064002000000000 assert handler[0].ts_init == 1610064002000000000 - def test_do_not_build_bars_with_no_updates(self): + def test_do_not_build_with_no_updates(self): # Arrange path = os.path.join(TEST_DATA_DIR, "binance-btcusdt-quotes.parquet") df_ticks = ParquetTickDataLoader.load(path) @@ -1328,7 +1328,7 @@ def test_do_not_build_bars_with_no_updates(self): bar_store.append, clock, Logger(clock), - build_bars_with_no_updates=False, # <-- set this True and test will fail + build_with_no_updates=False, # <-- set this True and test will fail ) aggregator.handle_quote_tick(ticks[0]) @@ -1338,3 +1338,39 @@ def test_do_not_build_bars_with_no_updates(self): # Assert assert len(bar_store) == 1 # <-- only 1 bar even after 5 minutes + + def test_timestamp_on_close_false_timestamps_ts_event_as_open(self): + # Arrange + path = os.path.join(TEST_DATA_DIR, "binance-btcusdt-quotes.parquet") + df_ticks = ParquetTickDataLoader.load(path) + + wrangler = QuoteTickDataWrangler(BTCUSDT_BINANCE) + ticks = wrangler.process(df_ticks) + + clock = TestClock() + bar_store = [] + instrument_id = TestIdStubs.audusd_id() + bar_spec = BarSpecification(1, BarAggregation.MINUTE, PriceType.MID) + bar_type = BarType(instrument_id, bar_spec) + + # Act + aggregator = TimeBarAggregator( + AUDUSD_SIM, + bar_type, + bar_store.append, + clock, + Logger(clock), + timestamp_on_close=False, # <-- set this True and test will fail + ) + aggregator.handle_quote_tick(ticks[0]) + + events = clock.advance_time(dt_to_unix_nanos(UNIX_EPOCH + timedelta(minutes=2))) + for event in events: + event.handle() + + # Assert + assert len(bar_store) == 2 + assert bar_store[0].ts_event == 0 # <-- bar open + assert bar_store[0].ts_init == 60_000_000_000 # <-- bar close + assert bar_store[1].ts_event == 60_000_000_000 # <-- bar open + assert bar_store[1].ts_init == 120_000_000_000 # <-- bar close From b50f296d8feb3dad9e91b3be65b8764b751945f0 Mon Sep 17 00:00:00 2001 From: Chris Sellers Date: Sun, 12 Feb 2023 18:40:52 -0800 Subject: [PATCH 40/81] Fix clippy warnings --- nautilus_core/common/src/timer.rs | 1 + nautilus_core/core/src/cvec.rs | 12 ++++++------ nautilus_core/core/src/datetime.rs | 1 + nautilus_core/core/src/string.rs | 3 +++ nautilus_core/core/src/uuid.rs | 7 +++++-- nautilus_core/model/src/data/tick.rs | 2 ++ nautilus_core/model/src/identifiers/account_id.rs | 1 + nautilus_core/model/src/identifiers/client_id.rs | 1 + .../model/src/identifiers/client_order_id.rs | 1 + nautilus_core/model/src/identifiers/component_id.rs | 1 + .../model/src/identifiers/exec_algorithm_id.rs | 1 + nautilus_core/model/src/identifiers/instrument_id.rs | 1 + nautilus_core/model/src/identifiers/order_list_id.rs | 1 + nautilus_core/model/src/identifiers/position_id.rs | 1 + nautilus_core/model/src/identifiers/strategy_id.rs | 1 + nautilus_core/model/src/identifiers/symbol.rs | 1 + nautilus_core/model/src/identifiers/trade_id.rs | 1 + nautilus_core/model/src/identifiers/trader_id.rs | 1 + nautilus_core/model/src/identifiers/venue.rs | 1 + .../model/src/identifiers/venue_order_id.rs | 1 + nautilus_core/model/src/orderbook/book.rs | 1 + nautilus_core/model/src/orderbook/ladder.rs | 2 ++ nautilus_core/model/src/orderbook/level.rs | 1 + nautilus_core/model/src/orderbook/order.rs | 1 + nautilus_core/model/src/types/currency.rs | 1 + nautilus_core/model/src/types/money.rs | 1 + nautilus_core/model/src/types/price.rs | 1 + nautilus_core/model/src/types/quantity.rs | 1 + nautilus_core/persistence/src/parquet/reader.rs | 1 + nautilus_core/persistence/src/parquet/writer.rs | 2 ++ nautilus_trader/core/includes/core.h | 4 +++- nautilus_trader/core/rust/core.pxd | 4 +++- 32 files changed, 50 insertions(+), 10 deletions(-) diff --git a/nautilus_core/common/src/timer.rs b/nautilus_core/common/src/timer.rs index 6298359a8af4..2028fe037efa 100644 --- a/nautilus_core/common/src/timer.rs +++ b/nautilus_core/common/src/timer.rs @@ -122,6 +122,7 @@ pub struct TestTimer { } impl TestTimer { + #[must_use] pub fn new( name: String, interval_ns: u64, diff --git a/nautilus_core/core/src/cvec.rs b/nautilus_core/core/src/cvec.rs index 43b49eb12e34..b34af4c6e5fb 100644 --- a/nautilus_core/core/src/cvec.rs +++ b/nautilus_core/core/src/cvec.rs @@ -15,7 +15,7 @@ use std::{ffi::c_void, ptr::null}; -/// CVec is a C compatible struct that stores an opaque pointer to a block of +/// `CVec` is a C compatible struct that stores an opaque pointer to a block of /// memory, it's length and the capacity of the vector it was allocated from. /// /// NOTE: Changing the values here may lead to undefined behaviour when the @@ -57,12 +57,12 @@ impl CVec { impl From> for CVec { fn from(data: Vec) -> Self { if data.is_empty() { - CVec::empty() + Self::empty() } else { let len = data.len(); let cap = data.capacity(); - CVec { - ptr: &mut data.leak()[0] as *mut T as *mut c_void, + Self { + ptr: (&mut data.leak()[0] as *mut T).cast::(), len, cap, } @@ -77,7 +77,7 @@ impl From> for CVec { pub extern "C" fn cvec_drop(cvec: CVec) { let CVec { ptr, len, cap } = cvec; let data: Vec = unsafe { Vec::from_raw_parts(ptr.cast::(), len, cap) }; - drop(data) // Memory freed here + drop(data); // Memory freed here } #[no_mangle] @@ -154,6 +154,6 @@ mod tests { fn empty_vec_should_give_null_ptr() { let data: Vec = vec![]; let cvec: CVec = data.into(); - assert_eq!(cvec.ptr as *mut u64, null() as *const u64 as *mut u64); + assert_eq!(cvec.ptr.cast::(), null().cast::() as *mut u64); } } diff --git a/nautilus_core/core/src/datetime.rs b/nautilus_core/core/src/datetime.rs index cec1999cd26b..3b8fd79a5add 100644 --- a/nautilus_core/core/src/datetime.rs +++ b/nautilus_core/core/src/datetime.rs @@ -73,6 +73,7 @@ pub extern "C" fn nanos_to_micros(nanos: u64) -> u64 { } #[inline] +#[must_use] pub fn unix_nanos_to_iso8601(timestamp_ns: u64) -> String { let dt = DateTime::::from(UNIX_EPOCH + Duration::from_nanos(timestamp_ns)); dt.to_rfc3339_opts(SecondsFormat::Nanos, true) diff --git a/nautilus_core/core/src/string.rs b/nautilus_core/core/src/string.rs index 983cf09b760d..7dd4b5ac46f7 100644 --- a/nautilus_core/core/src/string.rs +++ b/nautilus_core/core/src/string.rs @@ -24,6 +24,7 @@ use pyo3::{ffi, FromPyPointer, Python}; /// - Assumes `ptr` is borrowed from a valid Python UTF-8 `str`. /// # Panics /// - If `ptr` is null. +#[must_use] pub unsafe fn pystr_to_string(ptr: *mut ffi::PyObject) -> String { assert!(!ptr.is_null(), "`ptr` was NULL"); Python::with_gil(|py| PyString::from_borrowed_ptr(py, ptr).to_string()) @@ -35,6 +36,7 @@ pub unsafe fn pystr_to_string(ptr: *mut ffi::PyObject) -> String { /// - Assumes `ptr` is a valid C string pointer. /// # Panics /// - If `ptr` is null. +#[must_use] pub unsafe fn cstr_to_string(ptr: *const c_char) -> String { assert!(!ptr.is_null(), "`ptr` was NULL"); CStr::from_ptr(ptr) @@ -44,6 +46,7 @@ pub unsafe fn cstr_to_string(ptr: *const c_char) -> String { } /// Create a C string pointer to newly allocated memory from a [&str]. +#[must_use] pub fn string_to_cstr(s: &str) -> *const c_char { CString::new(s).expect("CString::new failed").into_raw() } diff --git a/nautilus_core/core/src/uuid.rs b/nautilus_core/core/src/uuid.rs index cd8c0d2ae2ac..ccb2dd1f05b5 100644 --- a/nautilus_core/core/src/uuid.rs +++ b/nautilus_core/core/src/uuid.rs @@ -32,9 +32,10 @@ pub struct UUID4 { } impl UUID4 { + #[must_use] pub fn new() -> Self { let uuid = Uuid::new_v4(); - UUID4 { + Self { value: Box::new(Rc::new(uuid.to_string())), } } @@ -83,6 +84,8 @@ pub extern "C" fn uuid4_free(uuid4: UUID4) { /// /// # Safety /// - Assumes `ptr` is a valid C string pointer. +/// # Panics +/// - If `ptr` cannot be cast to a valid C string. #[no_mangle] pub unsafe extern "C" fn uuid4_from_cstr(ptr: *const c_char) -> UUID4 { UUID4::from( @@ -131,7 +134,7 @@ mod tests { #[test] fn test_uuid4_default() { - let uuid: UUID4 = Default::default(); + let uuid: UUID4 = UUID4::default(); let uuid_string = uuid.value.to_string(); let uuid_parsed = Uuid::parse_str(&uuid_string).expect("Uuid::parse_str failed"); assert_eq!(uuid_parsed.get_version().unwrap(), uuid::Version::Random); diff --git a/nautilus_core/model/src/data/tick.rs b/nautilus_core/model/src/data/tick.rs index f4dde5bcd95f..a63d8ecfd937 100644 --- a/nautilus_core/model/src/data/tick.rs +++ b/nautilus_core/model/src/data/tick.rs @@ -40,6 +40,7 @@ pub struct QuoteTick { } impl QuoteTick { + #[must_use] pub fn new( instrument_id: InstrumentId, bid: Price, @@ -97,6 +98,7 @@ pub struct TradeTick { } impl TradeTick { + #[must_use] pub fn new( instrument_id: InstrumentId, price: Price, diff --git a/nautilus_core/model/src/identifiers/account_id.rs b/nautilus_core/model/src/identifiers/account_id.rs index 41cd74fc4805..b97fb7e97aff 100644 --- a/nautilus_core/model/src/identifiers/account_id.rs +++ b/nautilus_core/model/src/identifiers/account_id.rs @@ -37,6 +37,7 @@ impl Display for AccountId { } impl AccountId { + #[must_use] pub fn new(s: &str) -> AccountId { correctness::valid_string(s, "`AccountId` value"); correctness::string_contains(s, "-", "`TraderId` value"); diff --git a/nautilus_core/model/src/identifiers/client_id.rs b/nautilus_core/model/src/identifiers/client_id.rs index 0465bf1e4734..4addffc32117 100644 --- a/nautilus_core/model/src/identifiers/client_id.rs +++ b/nautilus_core/model/src/identifiers/client_id.rs @@ -37,6 +37,7 @@ impl Display for ClientId { } impl ClientId { + #[must_use] pub fn new(s: &str) -> ClientId { correctness::valid_string(s, "`ClientId` value"); diff --git a/nautilus_core/model/src/identifiers/client_order_id.rs b/nautilus_core/model/src/identifiers/client_order_id.rs index db2d3f101bb2..a6d2e0f33c77 100644 --- a/nautilus_core/model/src/identifiers/client_order_id.rs +++ b/nautilus_core/model/src/identifiers/client_order_id.rs @@ -37,6 +37,7 @@ impl Display for ClientOrderId { } impl ClientOrderId { + #[must_use] pub fn new(s: &str) -> ClientOrderId { correctness::valid_string(s, "`ClientOrderId` value"); diff --git a/nautilus_core/model/src/identifiers/component_id.rs b/nautilus_core/model/src/identifiers/component_id.rs index 5f8ed9df7757..9d6064e48f2f 100644 --- a/nautilus_core/model/src/identifiers/component_id.rs +++ b/nautilus_core/model/src/identifiers/component_id.rs @@ -37,6 +37,7 @@ impl Display for ComponentId { } impl ComponentId { + #[must_use] pub fn new(s: &str) -> ComponentId { correctness::valid_string(s, "`ComponentId` value"); diff --git a/nautilus_core/model/src/identifiers/exec_algorithm_id.rs b/nautilus_core/model/src/identifiers/exec_algorithm_id.rs index 71d5bb1ca033..9464aff93a6e 100644 --- a/nautilus_core/model/src/identifiers/exec_algorithm_id.rs +++ b/nautilus_core/model/src/identifiers/exec_algorithm_id.rs @@ -37,6 +37,7 @@ impl Display for ExecAlgorithmId { } impl ExecAlgorithmId { + #[must_use] pub fn new(s: &str) -> ExecAlgorithmId { correctness::valid_string(s, "`ExecAlgorithmId` value"); diff --git a/nautilus_core/model/src/identifiers/instrument_id.rs b/nautilus_core/model/src/identifiers/instrument_id.rs index 8f60cb6a16d3..96a9da654561 100644 --- a/nautilus_core/model/src/identifiers/instrument_id.rs +++ b/nautilus_core/model/src/identifiers/instrument_id.rs @@ -48,6 +48,7 @@ impl Display for InstrumentId { } impl InstrumentId { + #[must_use] pub fn new(symbol: Symbol, venue: Venue) -> InstrumentId { InstrumentId { symbol, venue } } diff --git a/nautilus_core/model/src/identifiers/order_list_id.rs b/nautilus_core/model/src/identifiers/order_list_id.rs index a6d0927fb59f..43b804bdfce9 100644 --- a/nautilus_core/model/src/identifiers/order_list_id.rs +++ b/nautilus_core/model/src/identifiers/order_list_id.rs @@ -37,6 +37,7 @@ impl Display for OrderListId { } impl OrderListId { + #[must_use] pub fn new(s: &str) -> OrderListId { correctness::valid_string(s, "`OrderListId` value"); diff --git a/nautilus_core/model/src/identifiers/position_id.rs b/nautilus_core/model/src/identifiers/position_id.rs index 7e05d3ce4afa..370d7874110c 100644 --- a/nautilus_core/model/src/identifiers/position_id.rs +++ b/nautilus_core/model/src/identifiers/position_id.rs @@ -37,6 +37,7 @@ impl Display for PositionId { } impl PositionId { + #[must_use] pub fn new(s: &str) -> PositionId { correctness::valid_string(s, "`PositionId` value"); diff --git a/nautilus_core/model/src/identifiers/strategy_id.rs b/nautilus_core/model/src/identifiers/strategy_id.rs index 9a757c71c3c9..a3b00c5b7b58 100644 --- a/nautilus_core/model/src/identifiers/strategy_id.rs +++ b/nautilus_core/model/src/identifiers/strategy_id.rs @@ -35,6 +35,7 @@ impl Display for StrategyId { } impl StrategyId { + #[must_use] pub fn new(s: &str) -> StrategyId { correctness::valid_string(s, "`StrategyId` value"); if s != "EXTERNAL" { diff --git a/nautilus_core/model/src/identifiers/symbol.rs b/nautilus_core/model/src/identifiers/symbol.rs index 22c01bdf18a1..ad479d1a6ae0 100644 --- a/nautilus_core/model/src/identifiers/symbol.rs +++ b/nautilus_core/model/src/identifiers/symbol.rs @@ -37,6 +37,7 @@ impl Display for Symbol { } impl Symbol { + #[must_use] pub fn new(s: &str) -> Symbol { correctness::valid_string(s, "`Symbol` value"); diff --git a/nautilus_core/model/src/identifiers/trade_id.rs b/nautilus_core/model/src/identifiers/trade_id.rs index 37d974f5d288..d7d1805a8bc9 100644 --- a/nautilus_core/model/src/identifiers/trade_id.rs +++ b/nautilus_core/model/src/identifiers/trade_id.rs @@ -37,6 +37,7 @@ impl Display for TradeId { } impl TradeId { + #[must_use] pub fn new(s: &str) -> TradeId { correctness::valid_string(s, "`TradeId` value"); diff --git a/nautilus_core/model/src/identifiers/trader_id.rs b/nautilus_core/model/src/identifiers/trader_id.rs index da6c53ea9850..f921baf48ccb 100644 --- a/nautilus_core/model/src/identifiers/trader_id.rs +++ b/nautilus_core/model/src/identifiers/trader_id.rs @@ -35,6 +35,7 @@ impl Display for TraderId { } impl TraderId { + #[must_use] pub fn new(s: &str) -> TraderId { correctness::valid_string(s, "`TraderId` value"); correctness::string_contains(s, "-", "`TraderId` value"); diff --git a/nautilus_core/model/src/identifiers/venue.rs b/nautilus_core/model/src/identifiers/venue.rs index 2dada22c45a2..a220a0730ed0 100644 --- a/nautilus_core/model/src/identifiers/venue.rs +++ b/nautilus_core/model/src/identifiers/venue.rs @@ -37,6 +37,7 @@ impl Display for Venue { } impl Venue { + #[must_use] pub fn new(s: &str) -> Venue { correctness::valid_string(s, "`Venue` value"); diff --git a/nautilus_core/model/src/identifiers/venue_order_id.rs b/nautilus_core/model/src/identifiers/venue_order_id.rs index 10464399340f..004fa40322e4 100644 --- a/nautilus_core/model/src/identifiers/venue_order_id.rs +++ b/nautilus_core/model/src/identifiers/venue_order_id.rs @@ -37,6 +37,7 @@ impl Display for VenueOrderId { } impl VenueOrderId { + #[must_use] pub fn new(s: &str) -> VenueOrderId { correctness::valid_string(s, "`VenueOrderId` value"); diff --git a/nautilus_core/model/src/orderbook/book.rs b/nautilus_core/model/src/orderbook/book.rs index 45199f41e031..ee47a4167d75 100644 --- a/nautilus_core/model/src/orderbook/book.rs +++ b/nautilus_core/model/src/orderbook/book.rs @@ -29,6 +29,7 @@ pub struct OrderBook { } impl OrderBook { + #[must_use] pub fn new(instrument_id: InstrumentId, book_level: BookType) -> Self { OrderBook { bids: Ladder::new(OrderSide::Buy), diff --git a/nautilus_core/model/src/orderbook/ladder.rs b/nautilus_core/model/src/orderbook/ladder.rs index 798fee44cff9..c67da9505fdf 100644 --- a/nautilus_core/model/src/orderbook/ladder.rs +++ b/nautilus_core/model/src/orderbook/ladder.rs @@ -29,6 +29,7 @@ pub struct BookPrice { } impl BookPrice { + #[must_use] pub fn new(value: Price, side: OrderSide) -> Self { BookPrice { value, side } } @@ -69,6 +70,7 @@ pub struct Ladder { } impl Ladder { + #[must_use] pub fn new(side: OrderSide) -> Self { Ladder { side, diff --git a/nautilus_core/model/src/orderbook/level.rs b/nautilus_core/model/src/orderbook/level.rs index 0c8c87169873..309f0df7773f 100644 --- a/nautilus_core/model/src/orderbook/level.rs +++ b/nautilus_core/model/src/orderbook/level.rs @@ -27,6 +27,7 @@ pub struct Level { } impl Level { + #[must_use] pub fn new(price: BookPrice) -> Self { Level { price, diff --git a/nautilus_core/model/src/orderbook/order.rs b/nautilus_core/model/src/orderbook/order.rs index faf3461dab1b..75448402983e 100644 --- a/nautilus_core/model/src/orderbook/order.rs +++ b/nautilus_core/model/src/orderbook/order.rs @@ -28,6 +28,7 @@ pub struct BookOrder { } impl BookOrder { + #[must_use] pub fn new(price: Price, size: Quantity, side: OrderSide, order_id: u64) -> Self { BookOrder { price, diff --git a/nautilus_core/model/src/types/currency.rs b/nautilus_core/model/src/types/currency.rs index 93801d80cc2e..2ba896feafb0 100644 --- a/nautilus_core/model/src/types/currency.rs +++ b/nautilus_core/model/src/types/currency.rs @@ -35,6 +35,7 @@ pub struct Currency { } impl Currency { + #[must_use] pub fn new( code: &str, precision: u8, diff --git a/nautilus_core/model/src/types/money.rs b/nautilus_core/model/src/types/money.rs index a2e35ec72ca1..95050684ecb0 100644 --- a/nautilus_core/model/src/types/money.rs +++ b/nautilus_core/model/src/types/money.rs @@ -34,6 +34,7 @@ pub struct Money { } impl Money { + #[must_use] pub fn new(amount: f64, currency: Currency) -> Money { correctness::f64_in_range_inclusive(amount, MONEY_MIN, MONEY_MAX, "`Money` amount"); diff --git a/nautilus_core/model/src/types/price.rs b/nautilus_core/model/src/types/price.rs index 3968f89dce06..515fc27a59df 100644 --- a/nautilus_core/model/src/types/price.rs +++ b/nautilus_core/model/src/types/price.rs @@ -34,6 +34,7 @@ pub struct Price { } impl Price { + #[must_use] pub fn new(value: f64, precision: u8) -> Self { correctness::f64_in_range_inclusive(value, PRICE_MIN, PRICE_MAX, "`Price` value"); diff --git a/nautilus_core/model/src/types/quantity.rs b/nautilus_core/model/src/types/quantity.rs index eb47b5087e93..0bf826c35fb8 100644 --- a/nautilus_core/model/src/types/quantity.rs +++ b/nautilus_core/model/src/types/quantity.rs @@ -34,6 +34,7 @@ pub struct Quantity { } impl Quantity { + #[must_use] pub fn new(value: f64, precision: u8) -> Self { correctness::f64_in_range_inclusive(value, QUANTITY_MIN, QUANTITY_MAX, "`Quantity` value"); diff --git a/nautilus_core/persistence/src/parquet/reader.rs b/nautilus_core/persistence/src/parquet/reader.rs index 318998d8b685..24d332ebda24 100644 --- a/nautilus_core/persistence/src/parquet/reader.rs +++ b/nautilus_core/persistence/src/parquet/reader.rs @@ -153,6 +153,7 @@ impl ParquetReader where R: Read + Seek, { + #[must_use] pub fn new(mut reader: R, chunk_size: usize, filter_arg: GroupFilterArg) -> Self { let metadata = read::read_metadata(&mut reader).expect("Unable to read metadata"); let schema = read::infer_schema(&metadata).expect("Unable to infer schema"); diff --git a/nautilus_core/persistence/src/parquet/writer.rs b/nautilus_core/persistence/src/parquet/writer.rs index 73f9bfea6240..e043415ab5d3 100644 --- a/nautilus_core/persistence/src/parquet/writer.rs +++ b/nautilus_core/persistence/src/parquet/writer.rs @@ -41,6 +41,7 @@ where A: EncodeToChunk + 'a + Sized, W: Write, { + #[must_use] pub fn new(w: W, schema: Schema) -> ParquetWriter { let options = WriteOptions { write_statistics: true, @@ -59,6 +60,7 @@ where } } + #[must_use] pub fn new_buffer_writer(schema: Schema) -> ParquetWriter> { ParquetWriter::new(Vec::new(), schema) } diff --git a/nautilus_trader/core/includes/core.h b/nautilus_trader/core/includes/core.h index 839e5ef6ad7f..1a435fb597ce 100644 --- a/nautilus_trader/core/includes/core.h +++ b/nautilus_trader/core/includes/core.h @@ -8,7 +8,7 @@ typedef struct Rc_String Rc_String; /** - * CVec is a C compatible struct that stores an opaque pointer to a block of + * `CVec` is a C compatible struct that stores an opaque pointer to a block of * memory, it's length and the capacity of the vector it was allocated from. * * NOTE: Changing the values here may lead to undefined behaviour when the @@ -125,6 +125,8 @@ void uuid4_free(struct UUID4_t uuid4); * * # Safety * - Assumes `ptr` is a valid C string pointer. + * # Panics + * - If `ptr` cannot be cast to a valid C string. */ struct UUID4_t uuid4_from_cstr(const char *ptr); diff --git a/nautilus_trader/core/rust/core.pxd b/nautilus_trader/core/rust/core.pxd index ef37e45db925..16bd7b1e9298 100644 --- a/nautilus_trader/core/rust/core.pxd +++ b/nautilus_trader/core/rust/core.pxd @@ -7,7 +7,7 @@ cdef extern from "../includes/core.h": cdef struct Rc_String: pass - # CVec is a C compatible struct that stores an opaque pointer to a block of + # `CVec` is a C compatible struct that stores an opaque pointer to a block of # memory, it's length and the capacity of the vector it was allocated from. # # NOTE: Changing the values here may lead to undefined behaviour when the @@ -88,6 +88,8 @@ cdef extern from "../includes/core.h": # # # Safety # - Assumes `ptr` is a valid C string pointer. + # # Panics + # - If `ptr` cannot be cast to a valid C string. UUID4_t uuid4_from_cstr(const char *ptr); const char *uuid4_to_cstr(const UUID4_t *uuid); From 5de8a3f756f5a31a0b57326fecc921088bbbade8 Mon Sep 17 00:00:00 2001 From: Chris Sellers Date: Sun, 12 Feb 2023 19:03:23 -0800 Subject: [PATCH 41/81] Improve config validation --- nautilus_trader/system/kernel.py | 23 ++++++++--------------- 1 file changed, 8 insertions(+), 15 deletions(-) diff --git a/nautilus_trader/system/kernel.py b/nautilus_trader/system/kernel.py index 54d96df45a3c..ac0b43e66230 100644 --- a/nautilus_trader/system/kernel.py +++ b/nautilus_trader/system/kernel.py @@ -176,21 +176,14 @@ def __init__( # noqa (too complex) PyCondition.valid_string(name, "name") PyCondition.type(cache_config, CacheConfig, "cache_config") PyCondition.type(cache_database_config, CacheDatabaseConfig, "cache_database_config") - PyCondition.true( - isinstance(data_config, (DataEngineConfig, LiveDataEngineConfig)), - "data_config was unrecognized type", - ex_type=TypeError, - ) - PyCondition.true( - isinstance(risk_config, (RiskEngineConfig, LiveRiskEngineConfig)), - "risk_config was unrecognized type", - ex_type=TypeError, - ) - PyCondition.true( - isinstance(exec_config, (ExecEngineConfig, LiveExecEngineConfig)), - "exec_config was unrecognized type", - ex_type=TypeError, - ) + if environment == Environment.BACKTEST: + PyCondition.type(data_config, DataEngineConfig, "data_config") + PyCondition.type(risk_config, RiskEngineConfig, "risk_config") + PyCondition.type(exec_config, ExecEngineConfig, "exec_config") + else: + PyCondition.type(data_config, LiveDataEngineConfig, "data_config") + PyCondition.type(risk_config, LiveRiskEngineConfig, "risk_config") + PyCondition.type(exec_config, LiveExecEngineConfig, "exec_config") PyCondition.type_or_none(streaming_config, StreamingConfig, "streaming_config") self._environment = environment From 40f88f602bb1e205f53c0949c120f17a8bd3225a Mon Sep 17 00:00:00 2001 From: Chris Sellers Date: Sun, 12 Feb 2023 19:08:00 -0800 Subject: [PATCH 42/81] Fix clippy warning --- nautilus_core/core/src/cvec.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nautilus_core/core/src/cvec.rs b/nautilus_core/core/src/cvec.rs index b34af4c6e5fb..2d35825a87c1 100644 --- a/nautilus_core/core/src/cvec.rs +++ b/nautilus_core/core/src/cvec.rs @@ -154,6 +154,6 @@ mod tests { fn empty_vec_should_give_null_ptr() { let data: Vec = vec![]; let cvec: CVec = data.into(); - assert_eq!(cvec.ptr.cast::(), null().cast::() as *mut u64); + assert_eq!(cvec.ptr as *mut u64, null() as *const u64 as *mut u64); } } From bf33b09915f7da31c03cb5c4509341115febf42c Mon Sep 17 00:00:00 2001 From: Bradley McElroy Date: Mon, 13 Feb 2023 15:29:22 +1100 Subject: [PATCH 43/81] Couple of IB fixes (#999) --- examples/live/interactive_brokers_book_imbalance.py | 2 ++ examples/live/interactive_brokers_example.py | 6 ++++-- .../adapters/interactive_brokers/factories.py | 12 ++++++------ 3 files changed, 12 insertions(+), 8 deletions(-) diff --git a/examples/live/interactive_brokers_book_imbalance.py b/examples/live/interactive_brokers_book_imbalance.py index 9e10433edd8a..cd354eb2e1d5 100644 --- a/examples/live/interactive_brokers_book_imbalance.py +++ b/examples/live/interactive_brokers_book_imbalance.py @@ -60,6 +60,7 @@ "IB": InteractiveBrokersDataClientConfig( instrument_provider=provider_config, read_only_api=False, + start_gateway=False, ), }, exec_clients={ @@ -67,6 +68,7 @@ routing=RoutingConfig(default=True, venues={"IDEALPRO"}), instrument_provider=provider_config, read_only_api=False, + start_gateway=False, ), }, timeout_connection=90.0, diff --git a/examples/live/interactive_brokers_example.py b/examples/live/interactive_brokers_example.py index a9dce570ab00..fa0704d25cb0 100644 --- a/examples/live/interactive_brokers_example.py +++ b/examples/live/interactive_brokers_example.py @@ -57,15 +57,17 @@ log_level="DEBUG", data_clients={ "IB": InteractiveBrokersDataClientConfig( - gateway_host="127.0.0.1", instrument_provider=InstrumentProviderConfig( load_all=True, filters=msgspec.json.encode(instrument_filters), ), + start_gateway=False, ), }, exec_clients={ - "IB": InteractiveBrokersExecClientConfig(), + "IB": InteractiveBrokersExecClientConfig( + start_gateway=False, + ), }, timeout_connection=90.0, timeout_reconciliation=5.0, diff --git a/nautilus_trader/adapters/interactive_brokers/factories.py b/nautilus_trader/adapters/interactive_brokers/factories.py index ef35204528a4..11d2a8f0bab4 100644 --- a/nautilus_trader/adapters/interactive_brokers/factories.py +++ b/nautilus_trader/adapters/interactive_brokers/factories.py @@ -103,7 +103,7 @@ def get_cached_ib_client( port = port or GATEWAY.port port = port or InteractiveBrokersGateway.PORTS[trading_mode] - client_key: tuple = (host, port) + client_key: tuple = (host, port, client_id) if client_key not in IB_INSYNC_CLIENTS: client = ib_insync.IB() @@ -112,7 +112,7 @@ def get_cached_ib_client( try: client.connect(host=host, port=port, timeout=6, clientId=client_id) break - except (TimeoutError, AttributeError, asyncio.TimeoutError): + except (TimeoutError, AttributeError, asyncio.TimeoutError, ConnectionRefusedError): continue else: raise TimeoutError(f"Failed to connect to gateway in {timeout}s") @@ -190,8 +190,8 @@ def create( # type: ignore """ client = get_cached_ib_client( - username=config.username, - password=config.password, + username=config.username or os.environ["TWS_USERNAME"], + password=config.password or os.environ["TWS_PASSWORD"], host=config.gateway_host, port=config.gateway_port, trading_mode=config.trading_mode, @@ -262,8 +262,8 @@ def create( # type: ignore """ client = get_cached_ib_client( - username=config.username, - password=config.password, + username=config.username or os.environ["TWS_USERNAME"], + password=config.password or os.environ["TWS_PASSWORD"], host=config.gateway_host, port=config.gateway_port, client_id=config.client_id, From 53d05541c8d19b02e01e87db578ee42c5245896e Mon Sep 17 00:00:00 2001 From: Chris Sellers Date: Tue, 14 Feb 2023 10:04:40 +1100 Subject: [PATCH 44/81] Update dependencies --- poetry.lock | 469 +++++++++++++++++++++++++++++-------------------- pyproject.toml | 4 +- 2 files changed, 278 insertions(+), 195 deletions(-) diff --git a/poetry.lock b/poetry.lock index 6aad0f64e9df..8f9994f296ac 100644 --- a/poetry.lock +++ b/poetry.lock @@ -17,106 +17,106 @@ pycares = ">=4.0.0" [[package]] name = "aiohttp" -version = "3.8.3" +version = "3.8.4" description = "Async http client/server framework (asyncio)" category = "main" optional = false python-versions = ">=3.6" files = [ - {file = "aiohttp-3.8.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ba71c9b4dcbb16212f334126cc3d8beb6af377f6703d9dc2d9fb3874fd667ee9"}, - {file = "aiohttp-3.8.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d24b8bb40d5c61ef2d9b6a8f4528c2f17f1c5d2d31fed62ec860f6006142e83e"}, - {file = "aiohttp-3.8.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f88df3a83cf9df566f171adba39d5bd52814ac0b94778d2448652fc77f9eb491"}, - {file = "aiohttp-3.8.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b97decbb3372d4b69e4d4c8117f44632551c692bb1361b356a02b97b69e18a62"}, - {file = "aiohttp-3.8.3-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:309aa21c1d54b8ef0723181d430347d7452daaff93e8e2363db8e75c72c2fb2d"}, - {file = "aiohttp-3.8.3-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ad5383a67514e8e76906a06741febd9126fc7c7ff0f599d6fcce3e82b80d026f"}, - {file = "aiohttp-3.8.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:20acae4f268317bb975671e375493dbdbc67cddb5f6c71eebdb85b34444ac46b"}, - {file = "aiohttp-3.8.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:05a3c31c6d7cd08c149e50dc7aa2568317f5844acd745621983380597f027a18"}, - {file = "aiohttp-3.8.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:d6f76310355e9fae637c3162936e9504b4767d5c52ca268331e2756e54fd4ca5"}, - {file = "aiohttp-3.8.3-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:256deb4b29fe5e47893fa32e1de2d73c3afe7407738bd3c63829874661d4822d"}, - {file = "aiohttp-3.8.3-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:5c59fcd80b9049b49acd29bd3598cada4afc8d8d69bd4160cd613246912535d7"}, - {file = "aiohttp-3.8.3-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:059a91e88f2c00fe40aed9031b3606c3f311414f86a90d696dd982e7aec48142"}, - {file = "aiohttp-3.8.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:2feebbb6074cdbd1ac276dbd737b40e890a1361b3cc30b74ac2f5e24aab41f7b"}, - {file = "aiohttp-3.8.3-cp310-cp310-win32.whl", hash = "sha256:5bf651afd22d5f0c4be16cf39d0482ea494f5c88f03e75e5fef3a85177fecdeb"}, - {file = "aiohttp-3.8.3-cp310-cp310-win_amd64.whl", hash = "sha256:653acc3880459f82a65e27bd6526e47ddf19e643457d36a2250b85b41a564715"}, - {file = "aiohttp-3.8.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:86fc24e58ecb32aee09f864cb11bb91bc4c1086615001647dbfc4dc8c32f4008"}, - {file = "aiohttp-3.8.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:75e14eac916f024305db517e00a9252714fce0abcb10ad327fb6dcdc0d060f1d"}, - {file = "aiohttp-3.8.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d1fde0f44029e02d02d3993ad55ce93ead9bb9b15c6b7ccd580f90bd7e3de476"}, - {file = "aiohttp-3.8.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4ab94426ddb1ecc6a0b601d832d5d9d421820989b8caa929114811369673235c"}, - {file = "aiohttp-3.8.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:89d2e02167fa95172c017732ed7725bc8523c598757f08d13c5acca308e1a061"}, - {file = "aiohttp-3.8.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:02f9a2c72fc95d59b881cf38a4b2be9381b9527f9d328771e90f72ac76f31ad8"}, - {file = "aiohttp-3.8.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c7149272fb5834fc186328e2c1fa01dda3e1fa940ce18fded6d412e8f2cf76d"}, - {file = "aiohttp-3.8.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:512bd5ab136b8dc0ffe3fdf2dfb0c4b4f49c8577f6cae55dca862cd37a4564e2"}, - {file = "aiohttp-3.8.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:7018ecc5fe97027214556afbc7c502fbd718d0740e87eb1217b17efd05b3d276"}, - {file = "aiohttp-3.8.3-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:88c70ed9da9963d5496d38320160e8eb7e5f1886f9290475a881db12f351ab5d"}, - {file = "aiohttp-3.8.3-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:da22885266bbfb3f78218dc40205fed2671909fbd0720aedba39b4515c038091"}, - {file = "aiohttp-3.8.3-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:e65bc19919c910127c06759a63747ebe14f386cda573d95bcc62b427ca1afc73"}, - {file = "aiohttp-3.8.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:08c78317e950e0762c2983f4dd58dc5e6c9ff75c8a0efeae299d363d439c8e34"}, - {file = "aiohttp-3.8.3-cp311-cp311-win32.whl", hash = "sha256:45d88b016c849d74ebc6f2b6e8bc17cabf26e7e40c0661ddd8fae4c00f015697"}, - {file = "aiohttp-3.8.3-cp311-cp311-win_amd64.whl", hash = "sha256:96372fc29471646b9b106ee918c8eeb4cca423fcbf9a34daa1b93767a88a2290"}, - {file = "aiohttp-3.8.3-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:c971bf3786b5fad82ce5ad570dc6ee420f5b12527157929e830f51c55dc8af77"}, - {file = "aiohttp-3.8.3-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ff25f48fc8e623d95eca0670b8cc1469a83783c924a602e0fbd47363bb54aaca"}, - {file = "aiohttp-3.8.3-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e381581b37db1db7597b62a2e6b8b57c3deec95d93b6d6407c5b61ddc98aca6d"}, - {file = "aiohttp-3.8.3-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:db19d60d846283ee275d0416e2a23493f4e6b6028825b51290ac05afc87a6f97"}, - {file = "aiohttp-3.8.3-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:25892c92bee6d9449ffac82c2fe257f3a6f297792cdb18ad784737d61e7a9a85"}, - {file = "aiohttp-3.8.3-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:398701865e7a9565d49189f6c90868efaca21be65c725fc87fc305906be915da"}, - {file = "aiohttp-3.8.3-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:4a4fbc769ea9b6bd97f4ad0b430a6807f92f0e5eb020f1e42ece59f3ecfc4585"}, - {file = "aiohttp-3.8.3-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:b29bfd650ed8e148f9c515474a6ef0ba1090b7a8faeee26b74a8ff3b33617502"}, - {file = "aiohttp-3.8.3-cp36-cp36m-musllinux_1_1_ppc64le.whl", hash = "sha256:1e56b9cafcd6531bab5d9b2e890bb4937f4165109fe98e2b98ef0dcfcb06ee9d"}, - {file = "aiohttp-3.8.3-cp36-cp36m-musllinux_1_1_s390x.whl", hash = "sha256:ec40170327d4a404b0d91855d41bfe1fe4b699222b2b93e3d833a27330a87a6d"}, - {file = "aiohttp-3.8.3-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:2df5f139233060578d8c2c975128fb231a89ca0a462b35d4b5fcf7c501ebdbe1"}, - {file = "aiohttp-3.8.3-cp36-cp36m-win32.whl", hash = "sha256:f973157ffeab5459eefe7b97a804987876dd0a55570b8fa56b4e1954bf11329b"}, - {file = "aiohttp-3.8.3-cp36-cp36m-win_amd64.whl", hash = "sha256:437399385f2abcd634865705bdc180c8314124b98299d54fe1d4c8990f2f9494"}, - {file = "aiohttp-3.8.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:09e28f572b21642128ef31f4e8372adb6888846f32fecb288c8b0457597ba61a"}, - {file = "aiohttp-3.8.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6f3553510abdbec67c043ca85727396ceed1272eef029b050677046d3387be8d"}, - {file = "aiohttp-3.8.3-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e168a7560b7c61342ae0412997b069753f27ac4862ec7867eff74f0fe4ea2ad9"}, - {file = "aiohttp-3.8.3-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:db4c979b0b3e0fa7e9e69ecd11b2b3174c6963cebadeecfb7ad24532ffcdd11a"}, - {file = "aiohttp-3.8.3-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e164e0a98e92d06da343d17d4e9c4da4654f4a4588a20d6c73548a29f176abe2"}, - {file = "aiohttp-3.8.3-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e8a78079d9a39ca9ca99a8b0ac2fdc0c4d25fc80c8a8a82e5c8211509c523363"}, - {file = "aiohttp-3.8.3-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:21b30885a63c3f4ff5b77a5d6caf008b037cb521a5f33eab445dc566f6d092cc"}, - {file = "aiohttp-3.8.3-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:4b0f30372cef3fdc262f33d06e7b411cd59058ce9174ef159ad938c4a34a89da"}, - {file = "aiohttp-3.8.3-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:8135fa153a20d82ffb64f70a1b5c2738684afa197839b34cc3e3c72fa88d302c"}, - {file = "aiohttp-3.8.3-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:ad61a9639792fd790523ba072c0555cd6be5a0baf03a49a5dd8cfcf20d56df48"}, - {file = "aiohttp-3.8.3-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:978b046ca728073070e9abc074b6299ebf3501e8dee5e26efacb13cec2b2dea0"}, - {file = "aiohttp-3.8.3-cp37-cp37m-win32.whl", hash = "sha256:0d2c6d8c6872df4a6ec37d2ede71eff62395b9e337b4e18efd2177de883a5033"}, - {file = "aiohttp-3.8.3-cp37-cp37m-win_amd64.whl", hash = "sha256:21d69797eb951f155026651f7e9362877334508d39c2fc37bd04ff55b2007091"}, - {file = "aiohttp-3.8.3-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:2ca9af5f8f5812d475c5259393f52d712f6d5f0d7fdad9acdb1107dd9e3cb7eb"}, - {file = "aiohttp-3.8.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1d90043c1882067f1bd26196d5d2db9aa6d268def3293ed5fb317e13c9413ea4"}, - {file = "aiohttp-3.8.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:d737fc67b9a970f3234754974531dc9afeea11c70791dcb7db53b0cf81b79784"}, - {file = "aiohttp-3.8.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ebf909ea0a3fc9596e40d55d8000702a85e27fd578ff41a5500f68f20fd32e6c"}, - {file = "aiohttp-3.8.3-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5835f258ca9f7c455493a57ee707b76d2d9634d84d5d7f62e77be984ea80b849"}, - {file = "aiohttp-3.8.3-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:da37dcfbf4b7f45d80ee386a5f81122501ec75672f475da34784196690762f4b"}, - {file = "aiohttp-3.8.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:87f44875f2804bc0511a69ce44a9595d5944837a62caecc8490bbdb0e18b1342"}, - {file = "aiohttp-3.8.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:527b3b87b24844ea7865284aabfab08eb0faf599b385b03c2aa91fc6edd6e4b6"}, - {file = "aiohttp-3.8.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:d5ba88df9aa5e2f806650fcbeedbe4f6e8736e92fc0e73b0400538fd25a4dd96"}, - {file = "aiohttp-3.8.3-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:e7b8813be97cab8cb52b1375f41f8e6804f6507fe4660152e8ca5c48f0436017"}, - {file = "aiohttp-3.8.3-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:2dea10edfa1a54098703cb7acaa665c07b4e7568472a47f4e64e6319d3821ccf"}, - {file = "aiohttp-3.8.3-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:713d22cd9643ba9025d33c4af43943c7a1eb8547729228de18d3e02e278472b6"}, - {file = "aiohttp-3.8.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:2d252771fc85e0cf8da0b823157962d70639e63cb9b578b1dec9868dd1f4f937"}, - {file = "aiohttp-3.8.3-cp38-cp38-win32.whl", hash = "sha256:66bd5f950344fb2b3dbdd421aaa4e84f4411a1a13fca3aeb2bcbe667f80c9f76"}, - {file = "aiohttp-3.8.3-cp38-cp38-win_amd64.whl", hash = "sha256:84b14f36e85295fe69c6b9789b51a0903b774046d5f7df538176516c3e422446"}, - {file = "aiohttp-3.8.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:16c121ba0b1ec2b44b73e3a8a171c4f999b33929cd2397124a8c7fcfc8cd9e06"}, - {file = "aiohttp-3.8.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:8d6aaa4e7155afaf994d7924eb290abbe81a6905b303d8cb61310a2aba1c68ba"}, - {file = "aiohttp-3.8.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:43046a319664a04b146f81b40e1545d4c8ac7b7dd04c47e40bf09f65f2437346"}, - {file = "aiohttp-3.8.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:599418aaaf88a6d02a8c515e656f6faf3d10618d3dd95866eb4436520096c84b"}, - {file = "aiohttp-3.8.3-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:92a2964319d359f494f16011e23434f6f8ef0434acd3cf154a6b7bec511e2fb7"}, - {file = "aiohttp-3.8.3-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:73a4131962e6d91109bca6536416aa067cf6c4efb871975df734f8d2fd821b37"}, - {file = "aiohttp-3.8.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:598adde339d2cf7d67beaccda3f2ce7c57b3b412702f29c946708f69cf8222aa"}, - {file = "aiohttp-3.8.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:75880ed07be39beff1881d81e4a907cafb802f306efd6d2d15f2b3c69935f6fb"}, - {file = "aiohttp-3.8.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:a0239da9fbafd9ff82fd67c16704a7d1bccf0d107a300e790587ad05547681c8"}, - {file = "aiohttp-3.8.3-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:4e3a23ec214e95c9fe85a58470b660efe6534b83e6cbe38b3ed52b053d7cb6ad"}, - {file = "aiohttp-3.8.3-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:47841407cc89a4b80b0c52276f3cc8138bbbfba4b179ee3acbd7d77ae33f7ac4"}, - {file = "aiohttp-3.8.3-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:54d107c89a3ebcd13228278d68f1436d3f33f2dd2af5415e3feaeb1156e1a62c"}, - {file = "aiohttp-3.8.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:c37c5cce780349d4d51739ae682dec63573847a2a8dcb44381b174c3d9c8d403"}, - {file = "aiohttp-3.8.3-cp39-cp39-win32.whl", hash = "sha256:f178d2aadf0166be4df834c4953da2d7eef24719e8aec9a65289483eeea9d618"}, - {file = "aiohttp-3.8.3-cp39-cp39-win_amd64.whl", hash = "sha256:88e5be56c231981428f4f506c68b6a46fa25c4123a2e86d156c58a8369d31ab7"}, - {file = "aiohttp-3.8.3.tar.gz", hash = "sha256:3828fb41b7203176b82fe5d699e0d845435f2374750a44b480ea6b930f6be269"}, + {file = "aiohttp-3.8.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:5ce45967538fb747370308d3145aa68a074bdecb4f3a300869590f725ced69c1"}, + {file = "aiohttp-3.8.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b744c33b6f14ca26b7544e8d8aadff6b765a80ad6164fb1a430bbadd593dfb1a"}, + {file = "aiohttp-3.8.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1a45865451439eb320784918617ba54b7a377e3501fb70402ab84d38c2cd891b"}, + {file = "aiohttp-3.8.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a86d42d7cba1cec432d47ab13b6637bee393a10f664c425ea7b305d1301ca1a3"}, + {file = "aiohttp-3.8.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ee3c36df21b5714d49fc4580247947aa64bcbe2939d1b77b4c8dcb8f6c9faecc"}, + {file = "aiohttp-3.8.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:176a64b24c0935869d5bbc4c96e82f89f643bcdf08ec947701b9dbb3c956b7dd"}, + {file = "aiohttp-3.8.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c844fd628851c0bc309f3c801b3a3d58ce430b2ce5b359cd918a5a76d0b20cb5"}, + {file = "aiohttp-3.8.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5393fb786a9e23e4799fec788e7e735de18052f83682ce2dfcabaf1c00c2c08e"}, + {file = "aiohttp-3.8.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e4b09863aae0dc965c3ef36500d891a3ff495a2ea9ae9171e4519963c12ceefd"}, + {file = "aiohttp-3.8.4-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:adfbc22e87365a6e564c804c58fc44ff7727deea782d175c33602737b7feadb6"}, + {file = "aiohttp-3.8.4-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:147ae376f14b55f4f3c2b118b95be50a369b89b38a971e80a17c3fd623f280c9"}, + {file = "aiohttp-3.8.4-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:eafb3e874816ebe2a92f5e155f17260034c8c341dad1df25672fb710627c6949"}, + {file = "aiohttp-3.8.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c6cc15d58053c76eacac5fa9152d7d84b8d67b3fde92709195cb984cfb3475ea"}, + {file = "aiohttp-3.8.4-cp310-cp310-win32.whl", hash = "sha256:59f029a5f6e2d679296db7bee982bb3d20c088e52a2977e3175faf31d6fb75d1"}, + {file = "aiohttp-3.8.4-cp310-cp310-win_amd64.whl", hash = "sha256:fe7ba4a51f33ab275515f66b0a236bcde4fb5561498fe8f898d4e549b2e4509f"}, + {file = "aiohttp-3.8.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:3d8ef1a630519a26d6760bc695842579cb09e373c5f227a21b67dc3eb16cfea4"}, + {file = "aiohttp-3.8.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5b3f2e06a512e94722886c0827bee9807c86a9f698fac6b3aee841fab49bbfb4"}, + {file = "aiohttp-3.8.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3a80464982d41b1fbfe3154e440ba4904b71c1a53e9cd584098cd41efdb188ef"}, + {file = "aiohttp-3.8.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b631e26df63e52f7cce0cce6507b7a7f1bc9b0c501fcde69742130b32e8782f"}, + {file = "aiohttp-3.8.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3f43255086fe25e36fd5ed8f2ee47477408a73ef00e804cb2b5cba4bf2ac7f5e"}, + {file = "aiohttp-3.8.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4d347a172f866cd1d93126d9b239fcbe682acb39b48ee0873c73c933dd23bd0f"}, + {file = "aiohttp-3.8.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a3fec6a4cb5551721cdd70473eb009d90935b4063acc5f40905d40ecfea23e05"}, + {file = "aiohttp-3.8.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:80a37fe8f7c1e6ce8f2d9c411676e4bc633a8462844e38f46156d07a7d401654"}, + {file = "aiohttp-3.8.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:d1e6a862b76f34395a985b3cd39a0d949ca80a70b6ebdea37d3ab39ceea6698a"}, + {file = "aiohttp-3.8.4-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:cd468460eefef601ece4428d3cf4562459157c0f6523db89365202c31b6daebb"}, + {file = "aiohttp-3.8.4-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:618c901dd3aad4ace71dfa0f5e82e88b46ef57e3239fc7027773cb6d4ed53531"}, + {file = "aiohttp-3.8.4-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:652b1bff4f15f6287550b4670546a2947f2a4575b6c6dff7760eafb22eacbf0b"}, + {file = "aiohttp-3.8.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:80575ba9377c5171407a06d0196b2310b679dc752d02a1fcaa2bc20b235dbf24"}, + {file = "aiohttp-3.8.4-cp311-cp311-win32.whl", hash = "sha256:bbcf1a76cf6f6dacf2c7f4d2ebd411438c275faa1dc0c68e46eb84eebd05dd7d"}, + {file = "aiohttp-3.8.4-cp311-cp311-win_amd64.whl", hash = "sha256:6e74dd54f7239fcffe07913ff8b964e28b712f09846e20de78676ce2a3dc0bfc"}, + {file = "aiohttp-3.8.4-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:880e15bb6dad90549b43f796b391cfffd7af373f4646784795e20d92606b7a51"}, + {file = "aiohttp-3.8.4-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb96fa6b56bb536c42d6a4a87dfca570ff8e52de2d63cabebfd6fb67049c34b6"}, + {file = "aiohttp-3.8.4-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4a6cadebe132e90cefa77e45f2d2f1a4b2ce5c6b1bfc1656c1ddafcfe4ba8131"}, + {file = "aiohttp-3.8.4-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f352b62b45dff37b55ddd7b9c0c8672c4dd2eb9c0f9c11d395075a84e2c40f75"}, + {file = "aiohttp-3.8.4-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7ab43061a0c81198d88f39aaf90dae9a7744620978f7ef3e3708339b8ed2ef01"}, + {file = "aiohttp-3.8.4-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c9cb1565a7ad52e096a6988e2ee0397f72fe056dadf75d17fa6b5aebaea05622"}, + {file = "aiohttp-3.8.4-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:1b3ea7edd2d24538959c1c1abf97c744d879d4e541d38305f9bd7d9b10c9ec41"}, + {file = "aiohttp-3.8.4-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:7c7837fe8037e96b6dd5cfcf47263c1620a9d332a87ec06a6ca4564e56bd0f36"}, + {file = "aiohttp-3.8.4-cp36-cp36m-musllinux_1_1_ppc64le.whl", hash = "sha256:3b90467ebc3d9fa5b0f9b6489dfb2c304a1db7b9946fa92aa76a831b9d587e99"}, + {file = "aiohttp-3.8.4-cp36-cp36m-musllinux_1_1_s390x.whl", hash = "sha256:cab9401de3ea52b4b4c6971db5fb5c999bd4260898af972bf23de1c6b5dd9d71"}, + {file = "aiohttp-3.8.4-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:d1f9282c5f2b5e241034a009779e7b2a1aa045f667ff521e7948ea9b56e0c5ff"}, + {file = "aiohttp-3.8.4-cp36-cp36m-win32.whl", hash = "sha256:5e14f25765a578a0a634d5f0cd1e2c3f53964553a00347998dfdf96b8137f777"}, + {file = "aiohttp-3.8.4-cp36-cp36m-win_amd64.whl", hash = "sha256:4c745b109057e7e5f1848c689ee4fb3a016c8d4d92da52b312f8a509f83aa05e"}, + {file = "aiohttp-3.8.4-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:aede4df4eeb926c8fa70de46c340a1bc2c6079e1c40ccf7b0eae1313ffd33519"}, + {file = "aiohttp-3.8.4-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4ddaae3f3d32fc2cb4c53fab020b69a05c8ab1f02e0e59665c6f7a0d3a5be54f"}, + {file = "aiohttp-3.8.4-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c4eb3b82ca349cf6fadcdc7abcc8b3a50ab74a62e9113ab7a8ebc268aad35bb9"}, + {file = "aiohttp-3.8.4-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9bcb89336efa095ea21b30f9e686763f2be4478f1b0a616969551982c4ee4c3b"}, + {file = "aiohttp-3.8.4-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c08e8ed6fa3d477e501ec9db169bfac8140e830aa372d77e4a43084d8dd91ab"}, + {file = "aiohttp-3.8.4-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c6cd05ea06daca6ad6a4ca3ba7fe7dc5b5de063ff4daec6170ec0f9979f6c332"}, + {file = "aiohttp-3.8.4-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:b7a00a9ed8d6e725b55ef98b1b35c88013245f35f68b1b12c5cd4100dddac333"}, + {file = "aiohttp-3.8.4-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:de04b491d0e5007ee1b63a309956eaed959a49f5bb4e84b26c8f5d49de140fa9"}, + {file = "aiohttp-3.8.4-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:40653609b3bf50611356e6b6554e3a331f6879fa7116f3959b20e3528783e699"}, + {file = "aiohttp-3.8.4-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:dbf3a08a06b3f433013c143ebd72c15cac33d2914b8ea4bea7ac2c23578815d6"}, + {file = "aiohttp-3.8.4-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:854f422ac44af92bfe172d8e73229c270dc09b96535e8a548f99c84f82dde241"}, + {file = "aiohttp-3.8.4-cp37-cp37m-win32.whl", hash = "sha256:aeb29c84bb53a84b1a81c6c09d24cf33bb8432cc5c39979021cc0f98c1292a1a"}, + {file = "aiohttp-3.8.4-cp37-cp37m-win_amd64.whl", hash = "sha256:db3fc6120bce9f446d13b1b834ea5b15341ca9ff3f335e4a951a6ead31105480"}, + {file = "aiohttp-3.8.4-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:fabb87dd8850ef0f7fe2b366d44b77d7e6fa2ea87861ab3844da99291e81e60f"}, + {file = "aiohttp-3.8.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:91f6d540163f90bbaef9387e65f18f73ffd7c79f5225ac3d3f61df7b0d01ad15"}, + {file = "aiohttp-3.8.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:d265f09a75a79a788237d7f9054f929ced2e69eb0bb79de3798c468d8a90f945"}, + {file = "aiohttp-3.8.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3d89efa095ca7d442a6d0cbc755f9e08190ba40069b235c9886a8763b03785da"}, + {file = "aiohttp-3.8.4-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4dac314662f4e2aa5009977b652d9b8db7121b46c38f2073bfeed9f4049732cd"}, + {file = "aiohttp-3.8.4-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fe11310ae1e4cd560035598c3f29d86cef39a83d244c7466f95c27ae04850f10"}, + {file = "aiohttp-3.8.4-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6ddb2a2026c3f6a68c3998a6c47ab6795e4127315d2e35a09997da21865757f8"}, + {file = "aiohttp-3.8.4-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e75b89ac3bd27d2d043b234aa7b734c38ba1b0e43f07787130a0ecac1e12228a"}, + {file = "aiohttp-3.8.4-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:6e601588f2b502c93c30cd5a45bfc665faaf37bbe835b7cfd461753068232074"}, + {file = "aiohttp-3.8.4-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:a5d794d1ae64e7753e405ba58e08fcfa73e3fad93ef9b7e31112ef3c9a0efb52"}, + {file = "aiohttp-3.8.4-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:a1f4689c9a1462f3df0a1f7e797791cd6b124ddbee2b570d34e7f38ade0e2c71"}, + {file = "aiohttp-3.8.4-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:3032dcb1c35bc330134a5b8a5d4f68c1a87252dfc6e1262c65a7e30e62298275"}, + {file = "aiohttp-3.8.4-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:8189c56eb0ddbb95bfadb8f60ea1b22fcfa659396ea36f6adcc521213cd7b44d"}, + {file = "aiohttp-3.8.4-cp38-cp38-win32.whl", hash = "sha256:33587f26dcee66efb2fff3c177547bd0449ab7edf1b73a7f5dea1e38609a0c54"}, + {file = "aiohttp-3.8.4-cp38-cp38-win_amd64.whl", hash = "sha256:e595432ac259af2d4630008bf638873d69346372d38255774c0e286951e8b79f"}, + {file = "aiohttp-3.8.4-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:5a7bdf9e57126dc345b683c3632e8ba317c31d2a41acd5800c10640387d193ed"}, + {file = "aiohttp-3.8.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:22f6eab15b6db242499a16de87939a342f5a950ad0abaf1532038e2ce7d31567"}, + {file = "aiohttp-3.8.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:7235604476a76ef249bd64cb8274ed24ccf6995c4a8b51a237005ee7a57e8643"}, + {file = "aiohttp-3.8.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ea9eb976ffdd79d0e893869cfe179a8f60f152d42cb64622fca418cd9b18dc2a"}, + {file = "aiohttp-3.8.4-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:92c0cea74a2a81c4c76b62ea1cac163ecb20fb3ba3a75c909b9fa71b4ad493cf"}, + {file = "aiohttp-3.8.4-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:493f5bc2f8307286b7799c6d899d388bbaa7dfa6c4caf4f97ef7521b9cb13719"}, + {file = "aiohttp-3.8.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0a63f03189a6fa7c900226e3ef5ba4d3bd047e18f445e69adbd65af433add5a2"}, + {file = "aiohttp-3.8.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:10c8cefcff98fd9168cdd86c4da8b84baaa90bf2da2269c6161984e6737bf23e"}, + {file = "aiohttp-3.8.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:bca5f24726e2919de94f047739d0a4fc01372801a3672708260546aa2601bf57"}, + {file = "aiohttp-3.8.4-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:03baa76b730e4e15a45f81dfe29a8d910314143414e528737f8589ec60cf7391"}, + {file = "aiohttp-3.8.4-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:8c29c77cc57e40f84acef9bfb904373a4e89a4e8b74e71aa8075c021ec9078c2"}, + {file = "aiohttp-3.8.4-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:03543dcf98a6619254b409be2d22b51f21ec66272be4ebda7b04e6412e4b2e14"}, + {file = "aiohttp-3.8.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:17b79c2963db82086229012cff93ea55196ed31f6493bb1ccd2c62f1724324e4"}, + {file = "aiohttp-3.8.4-cp39-cp39-win32.whl", hash = "sha256:34ce9f93a4a68d1272d26030655dd1b58ff727b3ed2a33d80ec433561b03d67a"}, + {file = "aiohttp-3.8.4-cp39-cp39-win_amd64.whl", hash = "sha256:41a86a69bb63bb2fc3dc9ad5ea9f10f1c9c8e282b471931be0268ddd09430b04"}, + {file = "aiohttp-3.8.4.tar.gz", hash = "sha256:bf2e1a9162c1e441bf805a1fd166e249d574ca04e03b34f97e2928769e91ab5c"}, ] [package.dependencies] aiosignal = ">=1.1.2" async-timeout = ">=4.0.0a3,<5.0" attrs = ">=17.3.0" -charset-normalizer = ">=2.0,<3.0" +charset-normalizer = ">=2.0,<4.0" frozenlist = ">=1.1.1" multidict = ">=4.5,<7.0" yarl = ">=1.0,<2.0" @@ -385,19 +385,102 @@ files = [ [[package]] name = "charset-normalizer" -version = "2.1.1" +version = "3.0.1" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." category = "main" optional = false -python-versions = ">=3.6.0" +python-versions = "*" files = [ - {file = "charset-normalizer-2.1.1.tar.gz", hash = "sha256:5a3d016c7c547f69d6f81fb0db9449ce888b418b5b9952cc5e6e66843e9dd845"}, - {file = "charset_normalizer-2.1.1-py3-none-any.whl", hash = "sha256:83e9a75d1911279afd89352c68b45348559d1fc0506b054b346651b5e7fee29f"}, + {file = "charset-normalizer-3.0.1.tar.gz", hash = "sha256:ebea339af930f8ca5d7a699b921106c6e29c617fe9606fa7baa043c1cdae326f"}, + {file = "charset_normalizer-3.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:88600c72ef7587fe1708fd242b385b6ed4b8904976d5da0893e31df8b3480cb6"}, + {file = "charset_normalizer-3.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c75ffc45f25324e68ab238cb4b5c0a38cd1c3d7f1fb1f72b5541de469e2247db"}, + {file = "charset_normalizer-3.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:db72b07027db150f468fbada4d85b3b2729a3db39178abf5c543b784c1254539"}, + {file = "charset_normalizer-3.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:62595ab75873d50d57323a91dd03e6966eb79c41fa834b7a1661ed043b2d404d"}, + {file = "charset_normalizer-3.0.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ff6f3db31555657f3163b15a6b7c6938d08df7adbfc9dd13d9d19edad678f1e8"}, + {file = "charset_normalizer-3.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:772b87914ff1152b92a197ef4ea40efe27a378606c39446ded52c8f80f79702e"}, + {file = "charset_normalizer-3.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70990b9c51340e4044cfc394a81f614f3f90d41397104d226f21e66de668730d"}, + {file = "charset_normalizer-3.0.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:292d5e8ba896bbfd6334b096e34bffb56161c81408d6d036a7dfa6929cff8783"}, + {file = "charset_normalizer-3.0.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:2edb64ee7bf1ed524a1da60cdcd2e1f6e2b4f66ef7c077680739f1641f62f555"}, + {file = "charset_normalizer-3.0.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:31a9ddf4718d10ae04d9b18801bd776693487cbb57d74cc3458a7673f6f34639"}, + {file = "charset_normalizer-3.0.1-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:44ba614de5361b3e5278e1241fda3dc1838deed864b50a10d7ce92983797fa76"}, + {file = "charset_normalizer-3.0.1-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:12db3b2c533c23ab812c2b25934f60383361f8a376ae272665f8e48b88e8e1c6"}, + {file = "charset_normalizer-3.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c512accbd6ff0270939b9ac214b84fb5ada5f0409c44298361b2f5e13f9aed9e"}, + {file = "charset_normalizer-3.0.1-cp310-cp310-win32.whl", hash = "sha256:502218f52498a36d6bf5ea77081844017bf7982cdbe521ad85e64cabee1b608b"}, + {file = "charset_normalizer-3.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:601f36512f9e28f029d9481bdaf8e89e5148ac5d89cffd3b05cd533eeb423b59"}, + {file = "charset_normalizer-3.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0298eafff88c99982a4cf66ba2efa1128e4ddaca0b05eec4c456bbc7db691d8d"}, + {file = "charset_normalizer-3.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a8d0fc946c784ff7f7c3742310cc8a57c5c6dc31631269876a88b809dbeff3d3"}, + {file = "charset_normalizer-3.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:87701167f2a5c930b403e9756fab1d31d4d4da52856143b609e30a1ce7160f3c"}, + {file = "charset_normalizer-3.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14e76c0f23218b8f46c4d87018ca2e441535aed3632ca134b10239dfb6dadd6b"}, + {file = "charset_normalizer-3.0.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0c0a590235ccd933d9892c627dec5bc7511ce6ad6c1011fdf5b11363022746c1"}, + {file = "charset_normalizer-3.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c7fe7afa480e3e82eed58e0ca89f751cd14d767638e2550c77a92a9e749c317"}, + {file = "charset_normalizer-3.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:79909e27e8e4fcc9db4addea88aa63f6423ebb171db091fb4373e3312cb6d603"}, + {file = "charset_normalizer-3.0.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8ac7b6a045b814cf0c47f3623d21ebd88b3e8cf216a14790b455ea7ff0135d18"}, + {file = "charset_normalizer-3.0.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:72966d1b297c741541ca8cf1223ff262a6febe52481af742036a0b296e35fa5a"}, + {file = "charset_normalizer-3.0.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:f9d0c5c045a3ca9bedfc35dca8526798eb91a07aa7a2c0fee134c6c6f321cbd7"}, + {file = "charset_normalizer-3.0.1-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:5995f0164fa7df59db4746112fec3f49c461dd6b31b841873443bdb077c13cfc"}, + {file = "charset_normalizer-3.0.1-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:4a8fcf28c05c1f6d7e177a9a46a1c52798bfe2ad80681d275b10dcf317deaf0b"}, + {file = "charset_normalizer-3.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:761e8904c07ad053d285670f36dd94e1b6ab7f16ce62b9805c475b7aa1cffde6"}, + {file = "charset_normalizer-3.0.1-cp311-cp311-win32.whl", hash = "sha256:71140351489970dfe5e60fc621ada3e0f41104a5eddaca47a7acb3c1b851d6d3"}, + {file = "charset_normalizer-3.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:9ab77acb98eba3fd2a85cd160851816bfce6871d944d885febf012713f06659c"}, + {file = "charset_normalizer-3.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:84c3990934bae40ea69a82034912ffe5a62c60bbf6ec5bc9691419641d7d5c9a"}, + {file = "charset_normalizer-3.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:74292fc76c905c0ef095fe11e188a32ebd03bc38f3f3e9bcb85e4e6db177b7ea"}, + {file = "charset_normalizer-3.0.1-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c95a03c79bbe30eec3ec2b7f076074f4281526724c8685a42872974ef4d36b72"}, + {file = "charset_normalizer-3.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f4c39b0e3eac288fedc2b43055cfc2ca7a60362d0e5e87a637beac5d801ef478"}, + {file = "charset_normalizer-3.0.1-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:df2c707231459e8a4028eabcd3cfc827befd635b3ef72eada84ab13b52e1574d"}, + {file = "charset_normalizer-3.0.1-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:93ad6d87ac18e2a90b0fe89df7c65263b9a99a0eb98f0a3d2e079f12a0735837"}, + {file = "charset_normalizer-3.0.1-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:59e5686dd847347e55dffcc191a96622f016bc0ad89105e24c14e0d6305acbc6"}, + {file = "charset_normalizer-3.0.1-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:cd6056167405314a4dc3c173943f11249fa0f1b204f8b51ed4bde1a9cd1834dc"}, + {file = "charset_normalizer-3.0.1-cp36-cp36m-musllinux_1_1_ppc64le.whl", hash = "sha256:083c8d17153ecb403e5e1eb76a7ef4babfc2c48d58899c98fcaa04833e7a2f9a"}, + {file = "charset_normalizer-3.0.1-cp36-cp36m-musllinux_1_1_s390x.whl", hash = "sha256:f5057856d21e7586765171eac8b9fc3f7d44ef39425f85dbcccb13b3ebea806c"}, + {file = "charset_normalizer-3.0.1-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:7eb33a30d75562222b64f569c642ff3dc6689e09adda43a082208397f016c39a"}, + {file = "charset_normalizer-3.0.1-cp36-cp36m-win32.whl", hash = "sha256:95dea361dd73757c6f1c0a1480ac499952c16ac83f7f5f4f84f0658a01b8ef41"}, + {file = "charset_normalizer-3.0.1-cp36-cp36m-win_amd64.whl", hash = "sha256:eaa379fcd227ca235d04152ca6704c7cb55564116f8bc52545ff357628e10602"}, + {file = "charset_normalizer-3.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:3e45867f1f2ab0711d60c6c71746ac53537f1684baa699f4f668d4c6f6ce8e14"}, + {file = "charset_normalizer-3.0.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cadaeaba78750d58d3cc6ac4d1fd867da6fc73c88156b7a3212a3cd4819d679d"}, + {file = "charset_normalizer-3.0.1-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:911d8a40b2bef5b8bbae2e36a0b103f142ac53557ab421dc16ac4aafee6f53dc"}, + {file = "charset_normalizer-3.0.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:503e65837c71b875ecdd733877d852adbc465bd82c768a067badd953bf1bc5a3"}, + {file = "charset_normalizer-3.0.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a60332922359f920193b1d4826953c507a877b523b2395ad7bc716ddd386d866"}, + {file = "charset_normalizer-3.0.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:16a8663d6e281208d78806dbe14ee9903715361cf81f6d4309944e4d1e59ac5b"}, + {file = "charset_normalizer-3.0.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:a16418ecf1329f71df119e8a65f3aa68004a3f9383821edcb20f0702934d8087"}, + {file = "charset_normalizer-3.0.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:9d9153257a3f70d5f69edf2325357251ed20f772b12e593f3b3377b5f78e7ef8"}, + {file = "charset_normalizer-3.0.1-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:02a51034802cbf38db3f89c66fb5d2ec57e6fe7ef2f4a44d070a593c3688667b"}, + {file = "charset_normalizer-3.0.1-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:2e396d70bc4ef5325b72b593a72c8979999aa52fb8bcf03f701c1b03e1166918"}, + {file = "charset_normalizer-3.0.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:11b53acf2411c3b09e6af37e4b9005cba376c872503c8f28218c7243582df45d"}, + {file = "charset_normalizer-3.0.1-cp37-cp37m-win32.whl", hash = "sha256:0bf2dae5291758b6f84cf923bfaa285632816007db0330002fa1de38bfcb7154"}, + {file = "charset_normalizer-3.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:2c03cc56021a4bd59be889c2b9257dae13bf55041a3372d3295416f86b295fb5"}, + {file = "charset_normalizer-3.0.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:024e606be3ed92216e2b6952ed859d86b4cfa52cd5bc5f050e7dc28f9b43ec42"}, + {file = "charset_normalizer-3.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:4b0d02d7102dd0f997580b51edc4cebcf2ab6397a7edf89f1c73b586c614272c"}, + {file = "charset_normalizer-3.0.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:358a7c4cb8ba9b46c453b1dd8d9e431452d5249072e4f56cfda3149f6ab1405e"}, + {file = "charset_normalizer-3.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:81d6741ab457d14fdedc215516665050f3822d3e56508921cc7239f8c8e66a58"}, + {file = "charset_normalizer-3.0.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8b8af03d2e37866d023ad0ddea594edefc31e827fee64f8de5611a1dbc373174"}, + {file = "charset_normalizer-3.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9cf4e8ad252f7c38dd1f676b46514f92dc0ebeb0db5552f5f403509705e24753"}, + {file = "charset_normalizer-3.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e696f0dd336161fca9adbb846875d40752e6eba585843c768935ba5c9960722b"}, + {file = "charset_normalizer-3.0.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c22d3fe05ce11d3671297dc8973267daa0f938b93ec716e12e0f6dee81591dc1"}, + {file = "charset_normalizer-3.0.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:109487860ef6a328f3eec66f2bf78b0b72400280d8f8ea05f69c51644ba6521a"}, + {file = "charset_normalizer-3.0.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:37f8febc8ec50c14f3ec9637505f28e58d4f66752207ea177c1d67df25da5aed"}, + {file = "charset_normalizer-3.0.1-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:f97e83fa6c25693c7a35de154681fcc257c1c41b38beb0304b9c4d2d9e164479"}, + {file = "charset_normalizer-3.0.1-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:a152f5f33d64a6be73f1d30c9cc82dfc73cec6477ec268e7c6e4c7d23c2d2291"}, + {file = "charset_normalizer-3.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:39049da0ffb96c8cbb65cbf5c5f3ca3168990adf3551bd1dee10c48fce8ae820"}, + {file = "charset_normalizer-3.0.1-cp38-cp38-win32.whl", hash = "sha256:4457ea6774b5611f4bed5eaa5df55f70abde42364d498c5134b7ef4c6958e20e"}, + {file = "charset_normalizer-3.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:e62164b50f84e20601c1ff8eb55620d2ad25fb81b59e3cd776a1902527a788af"}, + {file = "charset_normalizer-3.0.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:8eade758719add78ec36dc13201483f8e9b5d940329285edcd5f70c0a9edbd7f"}, + {file = "charset_normalizer-3.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:8499ca8f4502af841f68135133d8258f7b32a53a1d594aa98cc52013fff55678"}, + {file = "charset_normalizer-3.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3fc1c4a2ffd64890aebdb3f97e1278b0cc72579a08ca4de8cd2c04799a3a22be"}, + {file = "charset_normalizer-3.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:00d3ffdaafe92a5dc603cb9bd5111aaa36dfa187c8285c543be562e61b755f6b"}, + {file = "charset_normalizer-3.0.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c2ac1b08635a8cd4e0cbeaf6f5e922085908d48eb05d44c5ae9eabab148512ca"}, + {file = "charset_normalizer-3.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f6f45710b4459401609ebebdbcfb34515da4fc2aa886f95107f556ac69a9147e"}, + {file = "charset_normalizer-3.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ae1de54a77dc0d6d5fcf623290af4266412a7c4be0b1ff7444394f03f5c54e3"}, + {file = "charset_normalizer-3.0.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3b590df687e3c5ee0deef9fc8c547d81986d9a1b56073d82de008744452d6541"}, + {file = "charset_normalizer-3.0.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:ab5de034a886f616a5668aa5d098af2b5385ed70142090e2a31bcbd0af0fdb3d"}, + {file = "charset_normalizer-3.0.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:9cb3032517f1627cc012dbc80a8ec976ae76d93ea2b5feaa9d2a5b8882597579"}, + {file = "charset_normalizer-3.0.1-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:608862a7bf6957f2333fc54ab4399e405baad0163dc9f8d99cb236816db169d4"}, + {file = "charset_normalizer-3.0.1-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:0f438ae3532723fb6ead77e7c604be7c8374094ef4ee2c5e03a3a17f1fca256c"}, + {file = "charset_normalizer-3.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:356541bf4381fa35856dafa6a965916e54bed415ad8a24ee6de6e37deccf2786"}, + {file = "charset_normalizer-3.0.1-cp39-cp39-win32.whl", hash = "sha256:39cf9ed17fe3b1bc81f33c9ceb6ce67683ee7526e65fde1447c772afc54a1bb8"}, + {file = "charset_normalizer-3.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:0a11e971ed097d24c534c037d298ad32c6ce81a45736d31e0ff0ad37ab437d59"}, + {file = "charset_normalizer-3.0.1-py3-none-any.whl", hash = "sha256:7e189e2e1d3ed2f4aebabd2d5b0f931e883676e51c7624826e0a4e5fe8a0bf24"}, ] -[package.extras] -unicode-backport = ["unicodedata2"] - [[package]] name = "click" version = "8.1.3" @@ -867,101 +950,101 @@ tqdm = ["tqdm"] [[package]] name = "hiredis" -version = "2.2.1" +version = "2.2.2" description = "Python wrapper for hiredis" category = "main" optional = true python-versions = ">=3.7" files = [ - {file = "hiredis-2.2.1-cp310-cp310-macosx_10_12_universal2.whl", hash = "sha256:998ab35070dc81806a23be5de837466a51b25e739fb1a0d5313474d5bb29c829"}, - {file = "hiredis-2.2.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:70db8f514ebcb6f884497c4eee21d0350bbc4102e63502411f8e100cf3b7921e"}, - {file = "hiredis-2.2.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a57a4a33a78e94618d026fc68e853d3f71fa4a1d4da7a6e828e927819b001f2d"}, - {file = "hiredis-2.2.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:209b94fa473b39e174b665186cad73206ca849cf6e822900b761e83080f67b06"}, - {file = "hiredis-2.2.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:58e51d83b42fdcc29780897641b1dcb30c0e4d3c4f6d9d71d79b2cfec99b8eb7"}, - {file = "hiredis-2.2.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:706995fb1173fab7f12110fbad00bb95dd0453336f7f0b341b4ca7b1b9ff0bc7"}, - {file = "hiredis-2.2.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:812e27a9b20db967f942306267bcd8b1369d7c171831b6f45d22d75576cd01cd"}, - {file = "hiredis-2.2.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:69c32d54ac1f6708145c77d79af12f7448ca1025a0bf912700ad1f0be511026a"}, - {file = "hiredis-2.2.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:96745c4cdca261a50bd70c01f14c6c352a48c4d6a78e2d422040fba7919eadef"}, - {file = "hiredis-2.2.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:943631a49d7746cd413acaf0b712d030a15f02671af94c54759ba3144351f97a"}, - {file = "hiredis-2.2.1-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:796b616478a5c1cac83e9e10fcd803e746e5a02461bfa7767aebae8b304e2124"}, - {file = "hiredis-2.2.1-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:341952a311654c39433c1e0d8d31c2a0c5864b2675ed159ed264ecaa5cfb225b"}, - {file = "hiredis-2.2.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6fbb1a56d455602bd6c276d5c316ae245111b2dc8158355112f2d905e7471c85"}, - {file = "hiredis-2.2.1-cp310-cp310-win32.whl", hash = "sha256:14f67987e1d55b197e46729d1497019228ad8c94427bb63500e6f217aa586ca5"}, - {file = "hiredis-2.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:ea011b3bfa37f2746737860c1e5ba198b63c9b4764e40b042aac7bd2c258938f"}, - {file = "hiredis-2.2.1-cp311-cp311-macosx_10_12_universal2.whl", hash = "sha256:103bde304d558061c4ba1d7ff94351e761da753c28883fd68964f25080152dac"}, - {file = "hiredis-2.2.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:6ba9f425739a55e1409fda5dafad7fdda91c6dcd2b111ba93bb7b53d90737506"}, - {file = "hiredis-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cb59a7535e0b8373f694ce87576c573f533438c5fbee450193333a22118f4a98"}, - {file = "hiredis-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6afbddc82bbb2c4c405d9a49a056ffe6541f8ad3160df49a80573b399f94ba3a"}, - {file = "hiredis-2.2.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a386f00800b1b043b091b93850e02814a8b398952438a9d4895bd70f5c80a821"}, - {file = "hiredis-2.2.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fec7465caac7b0a36551abb37066221cabf59f776d78fdd58ff17669942b4b41"}, - {file = "hiredis-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5cd590dd7858d0107c37b438aa27bbcaa0ba77c5b8eda6ebab7acff0aa89f7d7"}, - {file = "hiredis-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1523ec56d711bee863aaaf4325cef4430da3143ec388e60465f47e28818016cd"}, - {file = "hiredis-2.2.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:d4f6bbe599d255a504ef789c19e23118c654d256343c1ecdf7042fb4b4d0f7fa"}, - {file = "hiredis-2.2.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:d77dbc13d55c1d45d6a203da910002fffd13fa310af5e9c5994959587a192789"}, - {file = "hiredis-2.2.1-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:b2b847ea3f9af99e02c4c58b7cc6714e105c8d73705e5ff1132e9a249391f688"}, - {file = "hiredis-2.2.1-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:18135ecf28fc6577e71c0f8d8eb2f31e4783020a7d455571e7e5d2793374ce20"}, - {file = "hiredis-2.2.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:724aed63871bc386d6f28b5f4d15490d84934709f093e021c4abb785e72db5db"}, - {file = "hiredis-2.2.1-cp311-cp311-win32.whl", hash = "sha256:497a8837984ddfbf6f5a4c034c0107f2c5aaaebeebf34e2c6ab591acffce5f12"}, - {file = "hiredis-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:1776db8af168b22588ec10c3df674897b20cc6d25f093cd2724b8b26d7dac057"}, - {file = "hiredis-2.2.1-cp37-cp37m-macosx_10_12_x86_64.whl", hash = "sha256:49a518b456403602775218062a4dd06bed42b26854ff1ff6784cfee2ef6fa347"}, - {file = "hiredis-2.2.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:02118dc8545e2371448b9983a0041f12124eea907eb61858f2be8e7c1dfa1e43"}, - {file = "hiredis-2.2.1-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:78f2a53149b116e0088f6eda720574f723fbc75189195aab8a7a2a591ca89cab"}, - {file = "hiredis-2.2.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e3b8f0eba6d88c2aec63e6d1e38960f8a25c01f9796d32993ffa1cfcf48744c"}, - {file = "hiredis-2.2.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:38270042f40ed9e576966c603d06c984c80364b0d9ec86962a31551dae27b0cd"}, - {file = "hiredis-2.2.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a11250dd0521e9f729325b19ce9121df4cbb80ad3468cc21e56803e8380bc4b"}, - {file = "hiredis-2.2.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:595474e6c25f1c3c8ec67d587188e7dd47c492829b2c7c5ba1b17ee9e7e9a9ea"}, - {file = "hiredis-2.2.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:8ad00a7621de8ef9ae1616cf24a53d48ad1a699b96668637559a8982d109a800"}, - {file = "hiredis-2.2.1-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:a5e5e51faa7cd02444d4ee1eb59e316c08e974bcfa3a959cb790bc4e9bb616c5"}, - {file = "hiredis-2.2.1-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:0a9493bbc477436a3725e99cfcba768f416ab70ab92956e373d1a3b480b1e204"}, - {file = "hiredis-2.2.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:231e5836579fc75b25c6f9bb6213950ea3d39aadcfeb7f880211ca55df968342"}, - {file = "hiredis-2.2.1-cp37-cp37m-win32.whl", hash = "sha256:2ed6c948648798b440a9da74db65cdd2ad22f38cf4687f5212df369031394591"}, - {file = "hiredis-2.2.1-cp37-cp37m-win_amd64.whl", hash = "sha256:c65f38418e35970d44f9b5a59533f0f60f14b9f91b712dba51092d2c74d4dcd1"}, - {file = "hiredis-2.2.1-cp38-cp38-macosx_10_12_universal2.whl", hash = "sha256:2f6e80fb7cd4cc61af95ab2875801e4c36941a956c183297c3273cbfbbefa9d3"}, - {file = "hiredis-2.2.1-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:a54d2b3328a2305e0dfb257a4545053fdc64df0c64e0635982e191c846cc0456"}, - {file = "hiredis-2.2.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:33624903dfb629d6f7c17ed353b4b415211c29fd447f31e6bf03361865b97e68"}, - {file = "hiredis-2.2.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0f4b92df1e69dc48411045d2117d1d27ec6b5f0dd2b6501759cea2f6c68d5618"}, - {file = "hiredis-2.2.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:03c6a1f6bf2f64f40d076c997cdfcb8b3d1c9557dda6cb7bbad2c5c839921726"}, - {file = "hiredis-2.2.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3af3071d33432960cba88ce4e4932b508ab3e13ce41431c2a1b2dc9a988f7627"}, - {file = "hiredis-2.2.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cbb3f56d371b560bf39fe45d29c24e3d819ae2399733e2c86394a34e76adab38"}, - {file = "hiredis-2.2.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5da26970c41084a2ac337a4f075301a78cffb0e0f3df5e98c3049fc95e10725c"}, - {file = "hiredis-2.2.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:d87f90064106dfd7d2cc7baeb007a8ca289ee985f4bf64bb627c50cdc34208ed"}, - {file = "hiredis-2.2.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:c233199b9f4dd43e2297577e32ba5fcd0378871a47207bc424d5e5344d030a3e"}, - {file = "hiredis-2.2.1-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:99b5bcadd5e029234f89d244b86bc8d21093be7ac26111068bebd92a4a95dc73"}, - {file = "hiredis-2.2.1-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:ed79f65098c4643cb6ec4530b337535f00b58ea02e25180e3df15e9cc9da58dc"}, - {file = "hiredis-2.2.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:c7fd6394779c9a3b324b65394deadb949311662f3770bd34f904b8c04328082c"}, - {file = "hiredis-2.2.1-cp38-cp38-win32.whl", hash = "sha256:bde0178e7e6c49e408b8d3a8c0ec8e69a23e8dc2ae29f87af2d74b21025385dc"}, - {file = "hiredis-2.2.1-cp38-cp38-win_amd64.whl", hash = "sha256:6f5f469ba5ae613e4c652cdedfc723aa802329fcc2d65df1e9ab0ac0de34ad9e"}, - {file = "hiredis-2.2.1-cp39-cp39-macosx_10_12_universal2.whl", hash = "sha256:e5945ef29a76ab792973bef1ffa2970d81dd22edb94dfa5d6cba48beb9f51962"}, - {file = "hiredis-2.2.1-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:bad6e9a0e31678ee15ac3ef72e77c08177c86df05c37d2423ff3cded95131e51"}, - {file = "hiredis-2.2.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e57dfcd72f036cce9eab77bc533a932444459f7e54d96a555d25acf2501048be"}, - {file = "hiredis-2.2.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3afc76a012b907895e679d1e6bcc6394845d0cc91b75264711f8caf53d7b0f37"}, - {file = "hiredis-2.2.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a99c0d50d1a31be285c83301eff4b911dca16aac1c3fe1875c7d6f517a1e9fc4"}, - {file = "hiredis-2.2.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d8849bc74473778c10377f82cf9a534e240734e2f9a92c181ef6d51b4e3d3eb2"}, - {file = "hiredis-2.2.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e199868fe78c2d175bbb7b88f5daf2eae4a643a62f03f8d6736f9832f04f88b"}, - {file = "hiredis-2.2.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a0e98106a28fabb672bb014f6c4506cc67491e4cf9ac56d189cbb1e81a9a3e68"}, - {file = "hiredis-2.2.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0f2607e08dcb1c5d1e925c451facbfc357927acaa336a004552c32a6dd68e050"}, - {file = "hiredis-2.2.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:954abb363ed1d18dfb7510dbd89402cb7c21106307e04e2ee7bccf35a134f4dd"}, - {file = "hiredis-2.2.1-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:0474ab858f5dd15be6b467d89ec14b4c287f53b55ca5455369c3a1a787ef3a24"}, - {file = "hiredis-2.2.1-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:b90dd0adb1d659f8c94b32556198af1e61e38edd27fc7434d4b3b68ad4e51d37"}, - {file = "hiredis-2.2.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:7a5dac3ae05bc64b233f950edf37dce9c904aedbc7e18cfc2adfb98edb85da46"}, - {file = "hiredis-2.2.1-cp39-cp39-win32.whl", hash = "sha256:19666eb154b7155d043bf941e50d1640125f92d3294e2746df87639cc44a10e6"}, - {file = "hiredis-2.2.1-cp39-cp39-win_amd64.whl", hash = "sha256:c702dd28d52656bb86f7a2a76ea9341ac434810871b51fcd6cd28c6d7490fbdf"}, - {file = "hiredis-2.2.1-pp37-pypy37_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c604919bba041e4c4708ecb0fe6c7c8a92a7f1e886b0ae8d2c13c3e4abfc5eda"}, - {file = "hiredis-2.2.1-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:04c972593f26f4769e2be7058b7928179337593bcfc6a8b6bda87eea807b7cbf"}, - {file = "hiredis-2.2.1-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:42504e4058246536a9f477f450ab21275126fc5f094be5d5e5290c6de9d855f9"}, - {file = "hiredis-2.2.1-pp37-pypy37_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:220b6ac9d3fce60d14ccc34f9790e20a50dc56b6fb747fc357600963c0cf6aca"}, - {file = "hiredis-2.2.1-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:a16d81115128e6a9fc6904de051475be195f6c460c9515583dccfd407b16ff78"}, - {file = "hiredis-2.2.1-pp38-pypy38_pp73-macosx_10_12_x86_64.whl", hash = "sha256:df6325aade17b1f86c8b87f6a1d9549a4184fda00e27e2fca0e5d2a987130365"}, - {file = "hiredis-2.2.1-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dcad9c9239845b29f149a895e7e99b8307889cecbfc37b69924c2dad1f4ae4e8"}, - {file = "hiredis-2.2.1-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f0ccf6fc116795d76bca72aa301a33874c507f9e77402e857d298c73419b5ea3"}, - {file = "hiredis-2.2.1-pp38-pypy38_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:63f941e77c024be2a1451089e2fdbd5ff450ff0965f49948bbeb383aef1799ea"}, - {file = "hiredis-2.2.1-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:2bb682785a37145b209f44f5d5290b0f9f4b56205542fc592d0f1b3d5ffdfcf0"}, - {file = "hiredis-2.2.1-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:8fe289556264cb1a2efbcd3d6b3c55e059394ad01b6afa88151264137f85c352"}, - {file = "hiredis-2.2.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:96b079c53b6acd355edb6fe615270613f3f7ddc4159d69837ce15ec518925c40"}, - {file = "hiredis-2.2.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:82ad46d1140c5779cd9dfdafc35f47dd09dadff7654d8001c50bb283da82e7c9"}, - {file = "hiredis-2.2.1-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:17e9f363db56a8edb4eff936354cfa273197465bcd970922f3d292032eca87b0"}, - {file = "hiredis-2.2.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:ae6b356ed166a0ec663a46b547c988815d2b0e5f2d0af31ef34a16cf3ce705d0"}, - {file = "hiredis-2.2.1.tar.gz", hash = "sha256:d9fbef7f9070055a7cc012ac965e3dbabbf2400b395649ea8d6016dc82a7d13a"}, + {file = "hiredis-2.2.2-cp310-cp310-macosx_10_12_universal2.whl", hash = "sha256:ba6123ff137275e2f4c31fc74b93813fcbb79160d43f5357163e09638c7743de"}, + {file = "hiredis-2.2.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:d995846acc8e3339fb7833cd19bf6f3946ff5157c8488a4df9c51cd119a36870"}, + {file = "hiredis-2.2.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:82f869ca44bcafa37cd71cfa1429648fa354d6021dcd72f03a2f66bcb339c546"}, + {file = "hiredis-2.2.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa90a5ee7a7f30c3d72d3513914b8f51f953a71b8cbd52a241b6db6685e55645"}, + {file = "hiredis-2.2.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:01e2e588392b5fdcc3a6aa0eb62a2eb2a142f829082fa4c3354228029d3aa1ce"}, + {file = "hiredis-2.2.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5dac177a6ab8b4eb4d5e74978c29eef7cc9eef14086f814cb3893f7465578044"}, + {file = "hiredis-2.2.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4cb992e3f9753c5a0c637f333c2010d1ad702aebf2d730ee4d484f32b19bae97"}, + {file = "hiredis-2.2.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e61c22fda5fc25d31bbced24a8322d33c5cb8cad9ba698634c16edb5b3e79a91"}, + {file = "hiredis-2.2.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:9873898e26e50cd41415e9d1ea128bfdb60eb26abb4f5be28a4500fd7834dc0c"}, + {file = "hiredis-2.2.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:2c18b00a382546e19bcda8b83dcca5b6e0dbc238d235723434405f48a18e8f77"}, + {file = "hiredis-2.2.2-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:8c3a6998f6f88d7ca4d082fd26525074df13162b274d7c64034784b6fdc56666"}, + {file = "hiredis-2.2.2-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:0fc1f9a9791d028b2b8afa318ccff734c7fc8861d37a04ca9b3d27c9b05f9718"}, + {file = "hiredis-2.2.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:5f2cfd323f83985f2bed6ed013107873275025af270485b7d04c338bfb47bd14"}, + {file = "hiredis-2.2.2-cp310-cp310-win32.whl", hash = "sha256:55c7e9a9e05f8c0555bfba5c16d98492f8b6db650e56d0c35cc28aeabfc86020"}, + {file = "hiredis-2.2.2-cp310-cp310-win_amd64.whl", hash = "sha256:eaff526c2fed31c971b0fa338a25237ae5513550ef75d0b85b9420ec778cca45"}, + {file = "hiredis-2.2.2-cp311-cp311-macosx_10_12_universal2.whl", hash = "sha256:688b9b7458b4f3f452fea6ed062c04fa1fd9a69d9223d95c6cb052581aba553b"}, + {file = "hiredis-2.2.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:544d52fde3a8dac7854673eac20deca05214758193c01926ffbb0d57c6bf4ffe"}, + {file = "hiredis-2.2.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:990916e8b0b4eedddef787e73549b562f8c9e73a7fea82f9b8ff517806774ad0"}, + {file = "hiredis-2.2.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:10dc34854e9acfb3e7cc4157606e2efcb497b1c6fca07bd6c3be34ae5e413f13"}, + {file = "hiredis-2.2.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c446a2007985ae49c2ecd946dd819dea72b931beb5f647ba08655a1a1e133fa8"}, + {file = "hiredis-2.2.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:02b9f928dc6cd43ed0f0ffc1c75fb209fb180f004b7e2e19994805f998d247aa"}, + {file = "hiredis-2.2.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2a355aff8dfa02ebfe67f0946dd706e490bddda9ea260afac9cdc43942310c53"}, + {file = "hiredis-2.2.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:831461abe5b63e73719621a5f31d8fc175528a05dc09d5a8aa8ef565d6deefa4"}, + {file = "hiredis-2.2.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:75349f7c8f77eb0fd33ede4575d1e5b0a902a8176a436bf03293d7fec4bd3894"}, + {file = "hiredis-2.2.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:1eb39b34d15220095dc49ad1e1082580d35cd3b6d9741def52988b5075e4ff03"}, + {file = "hiredis-2.2.2-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:a9b306f4e870747eea8b008dcba2e9f1e4acd12b333a684bc1cc120e633a280e"}, + {file = "hiredis-2.2.2-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:03dfb4ab7a2136ce1be305592553f102e1bd91a96068ab2778e3252aed20d9bc"}, + {file = "hiredis-2.2.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d8bc89c7e33fecb083a199ade0131a34d20365a8c32239e218da57290987ca9a"}, + {file = "hiredis-2.2.2-cp311-cp311-win32.whl", hash = "sha256:ed44b3c711cecde920f238ac35f70ac08744f2079b6369655856e43944464a72"}, + {file = "hiredis-2.2.2-cp311-cp311-win_amd64.whl", hash = "sha256:2e2f0ce3e8ab1314a52f562386220f6714fd24d7968a95528135ad04e88cc741"}, + {file = "hiredis-2.2.2-cp37-cp37m-macosx_10_12_x86_64.whl", hash = "sha256:e7e61ab75b851aac2d6bc634d03738a242a6ef255a44178437b427c5ebac0a87"}, + {file = "hiredis-2.2.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9eb14339e399554bb436cc4628e8aaa3943adf7afcf34aba4cbd1e3e6b9ec7ec"}, + {file = "hiredis-2.2.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e4ec57886f20f4298537cb1ab9dbda98594fb8d7c724c5fbf9a4b55329fd4a63"}, + {file = "hiredis-2.2.2-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a89f5afb9827eab07b9c8c585cd4dc95e5232c727508ae2c935d09531abe9e33"}, + {file = "hiredis-2.2.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3645590b9234cafd21c8ecfbf252ad9aa1d67629f4bdc98ba3627f48f8f7b5aa"}, + {file = "hiredis-2.2.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:99350e89f52186146938bdba0b9c6cd68802c20346707d6ca8366f2d69d89b2f"}, + {file = "hiredis-2.2.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:b5d290f3d8f7a05c4adbe6c355055b87c7081bfa1eccd1ae5491216307ee5f53"}, + {file = "hiredis-2.2.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:c95be6f20377d5995ef41a98314542e194d2dc9c2579d8f130a1aea78d48fd42"}, + {file = "hiredis-2.2.2-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:e4e2da61a04251121cb551f569c3250e6e27e95f2a80f8351c36822eda1f5d2b"}, + {file = "hiredis-2.2.2-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:ac7f8d68826f95a3652e44b0c12bfa74d3aa6531d47d5dbe6a2fbfc7979bc20f"}, + {file = "hiredis-2.2.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:359e662324318baadb768d3c4ade8c4bdcfbb313570eb01e15d75dc5db781815"}, + {file = "hiredis-2.2.2-cp37-cp37m-win32.whl", hash = "sha256:fd0ca35e2cf44866137cbb5ae7e439fab18a0b0e0e1cf51d45137622d59ec012"}, + {file = "hiredis-2.2.2-cp37-cp37m-win_amd64.whl", hash = "sha256:c9488ffb10acc6b121c498875278b0a6715d193742dc92d21a281712169ac06d"}, + {file = "hiredis-2.2.2-cp38-cp38-macosx_10_12_universal2.whl", hash = "sha256:1570fe4f93bc1ea487fb566f2b863fd0ed146f643a4ea31e4e07036db9e0c7f8"}, + {file = "hiredis-2.2.2-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:8753c561b37cccbda7264c9b4486e206a6318c18377cd647beb3aa41a15a6beb"}, + {file = "hiredis-2.2.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:a06d0dd84f10be6b15a92edbca2490b64917280f66d8267c63de99b6550308ad"}, + {file = "hiredis-2.2.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40ff3f1ec3a4046732e9e41df08dcb1a559847196755d295d43e32528aae39e6"}, + {file = "hiredis-2.2.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c24d856e13c02bd9d28a189e47be70cbba6f2c2a4bd85a8cc98819db9e7e3e06"}, + {file = "hiredis-2.2.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4ee9fe7cef505e8d925c70bebcc16bfab12aa7af922f948346baffd4730f7b00"}, + {file = "hiredis-2.2.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:03ab1d545794bb0e09f3b1e2c8b3adcfacd84f6f2d402bfdcd441a98c0e9643c"}, + {file = "hiredis-2.2.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:14dfccf4696d75395c587a5dafafb4f7aa0a5d55309341d10bc2e7f1eaa20771"}, + {file = "hiredis-2.2.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:2ddc573809ca4374da1b24b48604f34f3d5f0911fcccfb1c403ff8d8ca31c232"}, + {file = "hiredis-2.2.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:24301ca2bf9b2f843b4c3015c90f161798fa3bbc5b95fd494785751b137dbbe2"}, + {file = "hiredis-2.2.2-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:b083a69e158138ffa95740ff6984d328259387b5596908021b3ccb946469ff66"}, + {file = "hiredis-2.2.2-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:8e16dc949cc2e9c5fbcd08de05b5fb61b89ff65738d772863c5c96248628830e"}, + {file = "hiredis-2.2.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:674f296c3c89cb53f97aa9ba2508d3f360ad481b9e0c0e3a59b342a15192adaf"}, + {file = "hiredis-2.2.2-cp38-cp38-win32.whl", hash = "sha256:20ecbf87aac4f0f33f9c55ae15cb73b485d256c57518c590b7d0c9c152150632"}, + {file = "hiredis-2.2.2-cp38-cp38-win_amd64.whl", hash = "sha256:b11960237a3025bf248135e5b497dc4923e83d137eb798fbfe78b40d57c4b156"}, + {file = "hiredis-2.2.2-cp39-cp39-macosx_10_12_universal2.whl", hash = "sha256:18103090b8eda9c529830e26594e88b0b1472055785f3ed29b8adc694d03862a"}, + {file = "hiredis-2.2.2-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:d1acb7c957e5343303b3862947df3232dc7395da320b3b9ae076dfaa56ad59dc"}, + {file = "hiredis-2.2.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4997f55e1208af95a8fbd0fa187b04c672fcec8f66e49b9ab7fcc45cc1657dc4"}, + {file = "hiredis-2.2.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:449e18506d22af40977abd0f5a8979f57f88d4562fe591478a3438d76a15133d"}, + {file = "hiredis-2.2.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a32a4474f7a4abdea954f3365608edee3f90f1de9fa05b81d214d4cad04c718a"}, + {file = "hiredis-2.2.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e86c800c6941698777fc58419216a66a7f76504f1cea72381d2ee206888e964d"}, + {file = "hiredis-2.2.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c73aa295c5369135247ff63aa1fbb116067485d0506cd787cc0c868e72bbee55"}, + {file = "hiredis-2.2.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2e10a66680023bd5c5a3d605dae0844e3dde60eac5b79e39f51395a2aceaf634"}, + {file = "hiredis-2.2.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:03ab760fc96e0c5d36226eb727f30645bf6a53c97f14bfc0a4d0401bfc9b8af7"}, + {file = "hiredis-2.2.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:855d258e7f1aee3d7fbd5b1dc87790b1b5016e23d369a97b934a25ae7bc0171f"}, + {file = "hiredis-2.2.2-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:ccc33d87866d213f84f857a98f69c13f94fbf99a3304e328869890c9e49c8d65"}, + {file = "hiredis-2.2.2-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:339af17bb9817f8acb127247c79a99cad63db6738c0fb2aec9fa3d4f35d2a250"}, + {file = "hiredis-2.2.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:57f73aa04d0b70ff436fb35fa7ea2b796aa7addbd7ebb8d1aa1f3d1b3e4439f1"}, + {file = "hiredis-2.2.2-cp39-cp39-win32.whl", hash = "sha256:e97d4e650b8d933a1229f341db92b610fc52b8d752490235977b63b81fbbc2cb"}, + {file = "hiredis-2.2.2-cp39-cp39-win_amd64.whl", hash = "sha256:8d43a7bba66a800279e33229a206861be09c279e261eaa8db4824e59465f4848"}, + {file = "hiredis-2.2.2-pp37-pypy37_pp73-macosx_10_12_x86_64.whl", hash = "sha256:632d79fd02b03e8d9fbaebbe40bfe34b920c5d0a9c0ef6270752e0db85208175"}, + {file = "hiredis-2.2.2-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3a5fefac31c84143782ec1ebc323c04e733a6e4bfebcef9907a34e47a465e648"}, + {file = "hiredis-2.2.2-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5155bc1710df8e21aa48c9b2f4d4e13e4987e1efff363a1ef9c84fae2cc6c145"}, + {file = "hiredis-2.2.2-pp37-pypy37_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2f220b71235d2deab1b4b22681c8aee444720d973b80f1b86a4e2a85f6bcf1e1"}, + {file = "hiredis-2.2.2-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:f1f1efbe9cc29a3af39cf7eed27225f951aed3f48a1149c7fb74529fb5ab86d4"}, + {file = "hiredis-2.2.2-pp38-pypy38_pp73-macosx_10_12_x86_64.whl", hash = "sha256:1f1c44242c18b1f02e6d1162f133d65d00e09cc10d9165dccc78662def72abc2"}, + {file = "hiredis-2.2.2-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9e0f444d9062f7e487ef42bab2fb2e290f1704afcbca48ad3ec23de63eef0fda"}, + {file = "hiredis-2.2.2-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac15e7e1efca51b4695e540c80c328accb352c9608da7c2df82d1fa1a3c539ef"}, + {file = "hiredis-2.2.2-pp38-pypy38_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:20cfbc469400669a5999aa34ccba3872a1e34490ec3d5c84e8c0752c27977b7c"}, + {file = "hiredis-2.2.2-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:bae004a0b978bf62e38d0eef5ab9156f8101d01167b3ca7054bd0994b773e917"}, + {file = "hiredis-2.2.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:a1ce725542133dbdda9e8704867ef52651886bd1ef568c6fd997a27404381985"}, + {file = "hiredis-2.2.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e6ea7532221c97fa6d79f7d19d452cd9d1141d759c54279cc4774ce24728f13"}, + {file = "hiredis-2.2.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a7114961ed78d708142f6c6eb1d2ed65dc3da4b5ae8a4660ad889dd7fc891971"}, + {file = "hiredis-2.2.2-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1b084fbc3e69f99865242f8e1ccd4ea2a34bf6a3983d015d61133377526c0ce2"}, + {file = "hiredis-2.2.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:2d1ba0799f3487294f72b2157944d5c3a4fb33c99e2d495d63eab98c7ec7234b"}, + {file = "hiredis-2.2.2.tar.gz", hash = "sha256:9c270bd0567a9c60673284e000132f603bb4ecbcd707567647a68f85ef45c4d4"}, ] [[package]] @@ -982,14 +1065,14 @@ nest-asyncio = "*" [[package]] name = "identify" -version = "2.5.17" +version = "2.5.18" description = "File identification library for Python" category = "dev" optional = false python-versions = ">=3.7" files = [ - {file = "identify-2.5.17-py2.py3-none-any.whl", hash = "sha256:7d526dd1283555aafcc91539acc061d8f6f59adb0a7bba462735b0a318bff7ed"}, - {file = "identify-2.5.17.tar.gz", hash = "sha256:93cc61a861052de9d4c541a7acb7e3dcc9c11b398a2144f6e52ae5285f5f4f06"}, + {file = "identify-2.5.18-py2.py3-none-any.whl", hash = "sha256:93aac7ecf2f6abf879b8f29a8002d3c6de7086b8c28d88e1ad15045a15ab63f9"}, + {file = "identify-2.5.18.tar.gz", hash = "sha256:89e144fa560cc4cffb6ef2ab5e9fb18ed9f9b3cb054384bab4b95c12f6c309fe"}, ] [package.extras] @@ -2612,14 +2695,14 @@ telegram = ["requests"] [[package]] name = "types-pyopenssl" -version = "23.0.0.2" +version = "23.0.0.3" description = "Typing stubs for pyOpenSSL" category = "dev" optional = false python-versions = "*" files = [ - {file = "types-pyOpenSSL-23.0.0.2.tar.gz", hash = "sha256:2e95f9a667d5eeb0af699196f857f7d23d5b4d642437bd37355bc13a87e9f4ae"}, - {file = "types_pyOpenSSL-23.0.0.2-py3-none-any.whl", hash = "sha256:ea7e5d06f9190a1cb013ad4b13d48896e5cd1e785c04491f38b206d1bc4b8dc1"}, + {file = "types-pyOpenSSL-23.0.0.3.tar.gz", hash = "sha256:6ca54d593f8b946f9570f9ed7457c41da3b518feff5e344851941a6209bea62b"}, + {file = "types_pyOpenSSL-23.0.0.3-py3-none-any.whl", hash = "sha256:847ab17a16475a882dc29898648a6a35ad0d3e11a5bba5aa8ab2f3435a8647cb"}, ] [package.dependencies] @@ -2959,4 +3042,4 @@ redis = ["hiredis", "redis"] [metadata] lock-version = "2.0" python-versions = ">=3.9,<3.12" -content-hash = "0a403f9f02355b959655c29c4cc8310f882857daee230503923f185b29b7ada2" +content-hash = "fc0b0183b433afdcea59bc0b8de982fde28a1c5efdbf9df98925dba8b61b63fa" diff --git a/pyproject.toml b/pyproject.toml index 4f1a78a5578e..bda38982f57f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,7 +49,7 @@ generate-setup-file = false python = ">=3.9,<3.12" cython = "==3.0.0a11" aiodns = "^3.0.0" -aiohttp = "^3.8.3" +aiohttp = "^3.8.4" click = "^8.1.3" frozendict = "^2.3.4" fsspec = ">=2023.1.0" @@ -63,7 +63,7 @@ tabulate = "^0.9.0" toml = "^0.10.2" tqdm = "^4.64.1" uvloop = {version = "^0.17.0", markers = "sys_platform != 'win32'"} -hiredis = {version = "^2.2.1", optional = true} +hiredis = {version = "^2.2.2", optional = true} ib_insync = {version = "^0.9.81", optional = true} redis = {version = "^4.5.1", optional = true} docker = {version = "^6.0.1", optional = true} From fc8fd7ffd037242c3a004ea0c221bbb5624e3918 Mon Sep 17 00:00:00 2001 From: Chris Sellers Date: Tue, 14 Feb 2023 10:05:19 +1100 Subject: [PATCH 45/81] Handle order initialized when rehydrating --- nautilus_trader/model/orders/base.pyx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/nautilus_trader/model/orders/base.pyx b/nautilus_trader/model/orders/base.pyx index 52d0f0f8e940..5d80b4cf3cfd 100644 --- a/nautilus_trader/model/orders/base.pyx +++ b/nautilus_trader/model/orders/base.pyx @@ -806,7 +806,9 @@ cdef class Order: Condition.equal(self.venue_order_id, event.venue_order_id, "self.venue_order_id", "event.venue_order_id") # Handle event (FSM can raise InvalidStateTrigger) - if isinstance(event, OrderDenied): + if isinstance(event, OrderInitialized): + pass # Do nothing else + elif isinstance(event, OrderDenied): self._fsm.trigger(OrderStatus.DENIED) self._denied(event) elif isinstance(event, OrderSubmitted): From 4eae94df37f0ec71a10db4c27c49cd42a8ab66f2 Mon Sep 17 00:00:00 2001 From: Chris Sellers Date: Tue, 14 Feb 2023 10:34:10 +1100 Subject: [PATCH 46/81] Fix registration of SimulationModule --- RELEASES.md | 3 +- nautilus_trader/backtest/exchange.pyx | 7 ++ nautilus_trader/backtest/modules.pyx | 10 +-- nautilus_trader/common/actor.pxd | 1 - nautilus_trader/common/actor.pyx | 9 +-- nautilus_trader/execution/emulator.pyx | 1 - nautilus_trader/trading/strategy.pyx | 1 - nautilus_trader/trading/trader.pyx | 1 - .../infrastructure/test_cache_database.py | 2 - tests/unit_tests/common/test_common_actor.py | 67 ------------------- 10 files changed, 17 insertions(+), 85 deletions(-) diff --git a/RELEASES.md b/RELEASES.md index 01972c1661cd..da6bf846eb46 100644 --- a/RELEASES.md +++ b/RELEASES.md @@ -15,7 +15,8 @@ Released on TBD (UTC). - Expose and improve `MatchingEngine` public API for custom functionality ### Fixes -None +- Fixed registration of `SimulationModule` (and refine `Actor` base registration) +- Fixed loading of previously emulated and transformed orders (handles second `OrderInitialized`) --- diff --git a/nautilus_trader/backtest/exchange.pyx b/nautilus_trader/backtest/exchange.pyx index bad1ecd9ab12..da10bd9fcaa6 100644 --- a/nautilus_trader/backtest/exchange.pyx +++ b/nautilus_trader/backtest/exchange.pyx @@ -181,6 +181,13 @@ cdef class SimulatedExchange: for module in modules: Condition.not_in(module, self.modules, "module", "modules") module.register_venue(self) + module.register_base( + msgbus=msgbus, + cache=cache, + clock=clock, + logger=logger, + + ) self.modules.append(module) self._log.info(f"Loaded {module}.") diff --git a/nautilus_trader/backtest/modules.pyx b/nautilus_trader/backtest/modules.pyx index b63071ff10fc..5ccbff01ef7a 100644 --- a/nautilus_trader/backtest/modules.pyx +++ b/nautilus_trader/backtest/modules.pyx @@ -13,12 +13,14 @@ # limitations under the License. # ------------------------------------------------------------------------------------------------- -from cpython.datetime cimport datetime -from libc.stdint cimport uint64_t - import pandas as pd import pytz +from nautilus_trader.config import ActorConfig + +from cpython.datetime cimport datetime +from libc.stdint cimport uint64_t + from nautilus_trader.accounting.calculators cimport RolloverInterestCalculator from nautilus_trader.backtest.exchange cimport SimulatedExchange from nautilus_trader.core.correctness cimport Condition @@ -32,8 +34,6 @@ from nautilus_trader.model.objects cimport Price from nautilus_trader.model.orderbook.book cimport OrderBook from nautilus_trader.model.position cimport Position -from nautilus_trader.config import ActorConfig - class SimulationModuleConfig(ActorConfig): pass diff --git a/nautilus_trader/common/actor.pxd b/nautilus_trader/common/actor.pxd index 94eff0a7ba92..018aa3c42d09 100644 --- a/nautilus_trader/common/actor.pxd +++ b/nautilus_trader/common/actor.pxd @@ -94,7 +94,6 @@ cdef class Actor(Component): cpdef void register_base( self, - TraderId trader_id, MessageBus msgbus, CacheFacade cache, Clock clock, diff --git a/nautilus_trader/common/actor.pyx b/nautilus_trader/common/actor.pyx index d85d06b5ef40..cb5fa8f0c998 100644 --- a/nautilus_trader/common/actor.pyx +++ b/nautilus_trader/common/actor.pyx @@ -502,7 +502,6 @@ cdef class Actor(Component): cpdef void register_base( self, - TraderId trader_id, MessageBus msgbus, CacheFacade cache, Clock clock, @@ -513,8 +512,6 @@ cdef class Actor(Component): Parameters ---------- - trader_id : TraderId - The trader ID for the actor. msgbus : MessageBus The message bus for the actor. cache : CacheFacade @@ -529,7 +526,6 @@ cdef class Actor(Component): System method (not intended to be called by user code). """ - Condition.not_none(trader_id, "trader_id") Condition.not_none(msgbus, "msgbus") Condition.not_none(cache, "cache") Condition.not_none(clock, "clock") @@ -538,14 +534,15 @@ cdef class Actor(Component): clock.register_default_handler(self.handle_event) self._change_clock(clock) self._change_logger(logger) - self._change_msgbus(msgbus) # The trader ID is also assigned here + self._change_msgbus(msgbus) # The trader ID is assigned here - self.trader_id = trader_id self.msgbus = msgbus self.cache = cache self.clock = self._clock self.log = self._log + self.log.info(f"Registered {self.id}.") + cpdef void register_warning_event(self, type event) except *: """ Register the given event type for warning log levels. diff --git a/nautilus_trader/execution/emulator.pyx b/nautilus_trader/execution/emulator.pyx index a92b08fe6fe8..67a95a77b9cd 100644 --- a/nautilus_trader/execution/emulator.pyx +++ b/nautilus_trader/execution/emulator.pyx @@ -90,7 +90,6 @@ cdef class OrderEmulator(Actor): super().__init__() self.register_base( - trader_id=trader_id, msgbus=msgbus, cache=cache, clock=clock, diff --git a/nautilus_trader/trading/strategy.pyx b/nautilus_trader/trading/strategy.pyx index 720569f4ad00..84101157c858 100644 --- a/nautilus_trader/trading/strategy.pyx +++ b/nautilus_trader/trading/strategy.pyx @@ -271,7 +271,6 @@ cdef class Strategy(Actor): Condition.not_none(logger, "logger") self.register_base( - trader_id=trader_id, msgbus=msgbus, cache=cache, clock=clock, diff --git a/nautilus_trader/trading/trader.pyx b/nautilus_trader/trading/trader.pyx index 7ce04bc334ab..8d23b06f7467 100644 --- a/nautilus_trader/trading/trader.pyx +++ b/nautilus_trader/trading/trader.pyx @@ -363,7 +363,6 @@ cdef class Trader(Component): # Wire component into trader actor.register_base( - trader_id=self.id, msgbus=self._msgbus, cache=self._cache, clock=clock, # Clock per component diff --git a/tests/integration_tests/infrastructure/test_cache_database.py b/tests/integration_tests/infrastructure/test_cache_database.py index f3278dfac9ea..e42c98a1576f 100644 --- a/tests/integration_tests/infrastructure/test_cache_database.py +++ b/tests/integration_tests/infrastructure/test_cache_database.py @@ -438,7 +438,6 @@ def test_update_actor(self): # Arrange actor = MockActor() actor.register_base( - trader_id=self.trader_id, msgbus=self.msgbus, cache=self.cache, clock=self.clock, @@ -825,7 +824,6 @@ def test_delete_actor(self): # Arrange, Act actor = MockActor() actor.register_base( - trader_id=self.trader_id, msgbus=self.msgbus, cache=self.cache, clock=self.clock, diff --git a/tests/unit_tests/common/test_common_actor.py b/tests/unit_tests/common/test_common_actor.py index d5f4bef06178..cd2e2c0609ca 100644 --- a/tests/unit_tests/common/test_common_actor.py +++ b/tests/unit_tests/common/test_common_actor.py @@ -150,7 +150,6 @@ def test_initialization(self): # Arrange actor = Actor(config=ActorConfig(component_id=self.component_id)) actor.register_base( - trader_id=self.trader_id, msgbus=self.msgbus, cache=self.cache, clock=self.clock, @@ -165,7 +164,6 @@ def test_register_warning_event(self): # Arrange actor = Actor(config=ActorConfig(component_id=self.component_id)) actor.register_base( - trader_id=self.trader_id, msgbus=self.msgbus, cache=self.cache, clock=self.clock, @@ -182,7 +180,6 @@ def test_deregister_warning_event(self): # Arrange actor = Actor(config=ActorConfig(component_id=self.component_id)) actor.register_base( - trader_id=self.trader_id, msgbus=self.msgbus, cache=self.cache, clock=self.clock, @@ -488,7 +485,6 @@ def test_start_when_user_code_raises_error_logs_and_reraises(self): # Arrange actor = KaboomActor() actor.register_base( - trader_id=self.trader_id, msgbus=self.msgbus, cache=self.cache, clock=self.clock, @@ -504,7 +500,6 @@ def test_stop_when_user_code_raises_error_logs_and_reraises(self): # Arrange actor = KaboomActor() actor.register_base( - trader_id=self.trader_id, msgbus=self.msgbus, cache=self.cache, clock=self.clock, @@ -523,7 +518,6 @@ def test_resume_when_user_code_raises_error_logs_and_reraises(self): # Arrange actor = KaboomActor() actor.register_base( - trader_id=self.trader_id, msgbus=self.msgbus, cache=self.cache, clock=self.clock, @@ -544,7 +538,6 @@ def test_reset_when_user_code_raises_error_logs_and_reraises(self): # Arrange actor = KaboomActor() actor.register_base( - trader_id=self.trader_id, msgbus=self.msgbus, cache=self.cache, clock=self.clock, @@ -560,7 +553,6 @@ def test_dispose_when_user_code_raises_error_logs_and_reraises(self): # Arrange actor = KaboomActor() actor.register_base( - trader_id=self.trader_id, msgbus=self.msgbus, cache=self.cache, clock=self.clock, @@ -576,7 +568,6 @@ def test_degrade_when_user_code_raises_error_logs_and_reraises(self): # Arrange actor = KaboomActor() actor.register_base( - trader_id=self.trader_id, msgbus=self.msgbus, cache=self.cache, clock=self.clock, @@ -595,7 +586,6 @@ def test_fault_when_user_code_raises_error_logs_and_reraises(self): # Arrange actor = KaboomActor() actor.register_base( - trader_id=self.trader_id, msgbus=self.msgbus, cache=self.cache, clock=self.clock, @@ -614,7 +604,6 @@ def test_handle_quote_tick_when_user_code_raises_exception_logs_and_reraises(sel # Arrange actor = KaboomActor() actor.register_base( - trader_id=self.trader_id, msgbus=self.msgbus, cache=self.cache, clock=self.clock, @@ -634,7 +623,6 @@ def test_handle_trade_tick_when_user_code_raises_exception_logs_and_reraises(sel # Arrange actor = KaboomActor() actor.register_base( - trader_id=self.trader_id, msgbus=self.msgbus, cache=self.cache, clock=self.clock, @@ -654,7 +642,6 @@ def test_handle_bar_when_user_code_raises_exception_logs_and_reraises(self): # Arrange actor = KaboomActor() actor.register_base( - trader_id=self.trader_id, msgbus=self.msgbus, cache=self.cache, clock=self.clock, @@ -674,7 +661,6 @@ def test_handle_data_when_user_code_raises_exception_logs_and_reraises(self): # Arrange actor = KaboomActor() actor.register_base( - trader_id=self.trader_id, msgbus=self.msgbus, cache=self.cache, clock=self.clock, @@ -700,7 +686,6 @@ def test_handle_event_when_user_code_raises_exception_logs_and_reraises(self): # Arrange actor = KaboomActor() actor.register_base( - trader_id=self.trader_id, msgbus=self.msgbus, cache=self.cache, clock=self.clock, @@ -720,7 +705,6 @@ def test_start(self): # Arrange actor = MockActor() actor.register_base( - trader_id=self.trader_id, msgbus=self.msgbus, cache=self.cache, clock=self.clock, @@ -738,7 +722,6 @@ def test_stop(self): # Arrange actor = MockActor() actor.register_base( - trader_id=self.trader_id, msgbus=self.msgbus, cache=self.cache, clock=self.clock, @@ -757,7 +740,6 @@ def test_resume(self): # Arrange actor = MockActor() actor.register_base( - trader_id=self.trader_id, msgbus=self.msgbus, cache=self.cache, clock=self.clock, @@ -778,7 +760,6 @@ def test_reset(self): # Arrange actor = MockActor() actor.register_base( - trader_id=self.trader_id, msgbus=self.msgbus, cache=self.cache, clock=self.clock, @@ -796,7 +777,6 @@ def test_dispose(self): # Arrange actor = MockActor() actor.register_base( - trader_id=self.trader_id, msgbus=self.msgbus, cache=self.cache, clock=self.clock, @@ -816,7 +796,6 @@ def test_degrade(self): # Arrange actor = MockActor() actor.register_base( - trader_id=self.trader_id, msgbus=self.msgbus, cache=self.cache, clock=self.clock, @@ -836,7 +815,6 @@ def test_fault(self): # Arrange actor = MockActor() actor.register_base( - trader_id=self.trader_id, msgbus=self.msgbus, cache=self.cache, clock=self.clock, @@ -856,7 +834,6 @@ def test_handle_instrument_with_blow_up_logs_exception(self): # Arrange actor = KaboomActor() actor.register_base( - trader_id=self.trader_id, msgbus=self.msgbus, cache=self.cache, clock=self.clock, @@ -874,7 +851,6 @@ def test_handle_instrument_when_not_running_does_not_send_to_on_instrument(self) # Arrange actor = MockActor() actor.register_base( - trader_id=self.trader_id, msgbus=self.msgbus, cache=self.cache, clock=self.clock, @@ -892,7 +868,6 @@ def test_handle_instrument_when_running_sends_to_on_instrument(self): # Arrange actor = MockActor() actor.register_base( - trader_id=self.trader_id, msgbus=self.msgbus, cache=self.cache, clock=self.clock, @@ -912,7 +887,6 @@ def test_handle_instruments_when_running_sends_to_on_instruments(self): # Arrange actor = MockActor() actor.register_base( - trader_id=self.trader_id, msgbus=self.msgbus, cache=self.cache, clock=self.clock, @@ -932,7 +906,6 @@ def test_handle_instruments_when_not_running_does_not_send_to_on_instrument(self # Arrange actor = MockActor() actor.register_base( - trader_id=self.trader_id, msgbus=self.msgbus, cache=self.cache, clock=self.clock, @@ -950,7 +923,6 @@ def test_handle_ticker_when_not_running_does_not_send_to_on_quote_tick(self): # Arrange actor = MockActor() actor.register_base( - trader_id=self.trader_id, msgbus=self.msgbus, cache=self.cache, clock=self.clock, @@ -970,7 +942,6 @@ def test_handle_ticker_when_running_sends_to_on_quote_tick(self): # Arrange actor = MockActor() actor.register_base( - trader_id=self.trader_id, msgbus=self.msgbus, cache=self.cache, clock=self.clock, @@ -992,7 +963,6 @@ def test_handle_quote_tick_when_not_running_does_not_send_to_on_quote_tick(self) # Arrange actor = MockActor() actor.register_base( - trader_id=self.trader_id, msgbus=self.msgbus, cache=self.cache, clock=self.clock, @@ -1012,7 +982,6 @@ def test_handle_quote_tick_when_running_sends_to_on_quote_tick(self): # Arrange actor = MockActor() actor.register_base( - trader_id=self.trader_id, msgbus=self.msgbus, cache=self.cache, clock=self.clock, @@ -1034,7 +1003,6 @@ def test_handle_trade_tick_when_not_running_does_not_send_to_on_trade_tick(self) # Arrange actor = MockActor() actor.register_base( - trader_id=self.trader_id, msgbus=self.msgbus, cache=self.cache, clock=self.clock, @@ -1054,7 +1022,6 @@ def test_handle_trade_tick_when_running_sends_to_on_trade_tick(self): # Arrange actor = MockActor() actor.register_base( - trader_id=self.trader_id, msgbus=self.msgbus, cache=self.cache, clock=self.clock, @@ -1076,7 +1043,6 @@ def test_handle_bar_when_not_running_does_not_send_to_on_bar(self): # Arrange actor = MockActor() actor.register_base( - trader_id=self.trader_id, msgbus=self.msgbus, cache=self.cache, clock=self.clock, @@ -1096,7 +1062,6 @@ def test_handle_bar_when_running_sends_to_on_bar(self): # Arrange actor = MockActor() actor.register_base( - trader_id=self.trader_id, msgbus=self.msgbus, cache=self.cache, clock=self.clock, @@ -1118,7 +1083,6 @@ def test_handle_data_when_not_running_does_not_send_to_on_data(self): # Arrange actor = MockActor() actor.register_base( - trader_id=self.trader_id, msgbus=self.msgbus, cache=self.cache, clock=self.clock, @@ -1144,7 +1108,6 @@ def test_handle_data_when_running_sends_to_on_data(self): # Arrange actor = MockActor() actor.register_base( - trader_id=self.trader_id, msgbus=self.msgbus, cache=self.cache, clock=self.clock, @@ -1172,7 +1135,6 @@ def test_subscribe_custom_data(self): # Arrange actor = MockActor() actor.register_base( - trader_id=self.trader_id, msgbus=self.msgbus, cache=self.cache, clock=self.clock, @@ -1195,7 +1157,6 @@ def test_subscribe_custom_data_with_client_id(self): # Arrange actor = MockActor() actor.register_base( - trader_id=self.trader_id, msgbus=self.msgbus, cache=self.cache, clock=self.clock, @@ -1218,7 +1179,6 @@ def test_unsubscribe_custom_data(self): # Arrange actor = MockActor() actor.register_base( - trader_id=self.trader_id, msgbus=self.msgbus, cache=self.cache, clock=self.clock, @@ -1239,7 +1199,6 @@ def test_unsubscribe_custom_data_with_client_id(self): # Arrange actor = MockActor() actor.register_base( - trader_id=self.trader_id, msgbus=self.msgbus, cache=self.cache, clock=self.clock, @@ -1260,7 +1219,6 @@ def test_subscribe_order_book(self): # Arrange actor = MockActor() actor.register_base( - trader_id=self.trader_id, msgbus=self.msgbus, cache=self.cache, clock=self.clock, @@ -1277,7 +1235,6 @@ def test_unsubscribe_order_book(self): # Arrange actor = MockActor() actor.register_base( - trader_id=self.trader_id, msgbus=self.msgbus, cache=self.cache, clock=self.clock, @@ -1296,7 +1253,6 @@ def test_subscribe_order_book_data(self): # Arrange actor = MockActor() actor.register_base( - trader_id=self.trader_id, msgbus=self.msgbus, cache=self.cache, clock=self.clock, @@ -1313,7 +1269,6 @@ def test_unsubscribe_order_book_deltas(self): # Arrange actor = MockActor() actor.register_base( - trader_id=self.trader_id, msgbus=self.msgbus, cache=self.cache, clock=self.clock, @@ -1332,7 +1287,6 @@ def test_subscribe_instruments(self): # Arrange actor = MockActor() actor.register_base( - trader_id=self.trader_id, msgbus=self.msgbus, cache=self.cache, clock=self.clock, @@ -1354,7 +1308,6 @@ def test_unsubscribe_instruments(self): # Arrange actor = MockActor() actor.register_base( - trader_id=self.trader_id, msgbus=self.msgbus, cache=self.cache, clock=self.clock, @@ -1372,7 +1325,6 @@ def test_subscribe_instrument(self): # Arrange actor = MockActor() actor.register_base( - trader_id=self.trader_id, msgbus=self.msgbus, cache=self.cache, clock=self.clock, @@ -1391,7 +1343,6 @@ def test_unsubscribe_instrument(self): # Arrange actor = MockActor() actor.register_base( - trader_id=self.trader_id, msgbus=self.msgbus, cache=self.cache, clock=self.clock, @@ -1411,7 +1362,6 @@ def test_subscribe_ticker(self): # Arrange actor = MockActor() actor.register_base( - trader_id=self.trader_id, msgbus=self.msgbus, cache=self.cache, clock=self.clock, @@ -1430,7 +1380,6 @@ def test_unsubscribe_ticker(self): # Arrange actor = MockActor() actor.register_base( - trader_id=self.trader_id, msgbus=self.msgbus, cache=self.cache, clock=self.clock, @@ -1450,7 +1399,6 @@ def test_subscribe_quote_ticks(self): # Arrange actor = MockActor() actor.register_base( - trader_id=self.trader_id, msgbus=self.msgbus, cache=self.cache, clock=self.clock, @@ -1469,7 +1417,6 @@ def test_unsubscribe_quote_ticks(self): # Arrange actor = MockActor() actor.register_base( - trader_id=self.trader_id, msgbus=self.msgbus, cache=self.cache, clock=self.clock, @@ -1489,7 +1436,6 @@ def test_subscribe_trade_ticks(self): # Arrange actor = MockActor() actor.register_base( - trader_id=self.trader_id, msgbus=self.msgbus, cache=self.cache, clock=self.clock, @@ -1508,7 +1454,6 @@ def test_unsubscribe_trade_ticks(self): # Arrange actor = MockActor() actor.register_base( - trader_id=self.trader_id, msgbus=self.msgbus, cache=self.cache, clock=self.clock, @@ -1528,7 +1473,6 @@ def test_publish_data_sends_to_subscriber(self): # Arrange actor = MockActor() actor.register_base( - trader_id=self.trader_id, msgbus=self.msgbus, cache=self.cache, clock=self.clock, @@ -1555,7 +1499,6 @@ def test_publish_signal_warns_invalid_type(self): # Arrange actor = MockActor() actor.register_base( - trader_id=self.trader_id, msgbus=self.msgbus, cache=self.cache, clock=self.clock, @@ -1570,7 +1513,6 @@ def test_publish_signal_sends_to_subscriber(self): # Arrange actor = MockActor() actor.register_base( - trader_id=self.trader_id, msgbus=self.msgbus, cache=self.cache, clock=self.clock, @@ -1599,7 +1541,6 @@ def test_publish_data_persist(self): # Arrange actor = MockActor() actor.register_base( - trader_id=self.trader_id, msgbus=self.msgbus, cache=self.cache, clock=self.clock, @@ -1628,7 +1569,6 @@ def test_subscribe_bars(self): # Arrange actor = MockActor() actor.register_base( - trader_id=self.trader_id, msgbus=self.msgbus, cache=self.cache, clock=self.clock, @@ -1648,7 +1588,6 @@ def test_unsubscribe_bars(self): # Arrange actor = MockActor() actor.register_base( - trader_id=self.trader_id, msgbus=self.msgbus, cache=self.cache, clock=self.clock, @@ -1670,7 +1609,6 @@ def test_subscribe_venue_status_updates(self): # Arrange actor = MockActor() actor.register_base( - trader_id=self.trader_id, msgbus=self.msgbus, cache=self.cache, clock=self.clock, @@ -1686,7 +1624,6 @@ def test_request_data_sends_request_to_data_engine(self): # Arrange actor = MockActor() actor.register_base( - trader_id=self.trader_id, msgbus=self.msgbus, cache=self.cache, clock=self.clock, @@ -1705,7 +1642,6 @@ def test_request_quote_ticks_sends_request_to_data_engine(self): # Arrange actor = MockActor() actor.register_base( - trader_id=self.trader_id, msgbus=self.msgbus, cache=self.cache, clock=self.clock, @@ -1722,7 +1658,6 @@ def test_request_trade_ticks_sends_request_to_data_engine(self): # Arrange actor = MockActor() actor.register_base( - trader_id=self.trader_id, msgbus=self.msgbus, cache=self.cache, clock=self.clock, @@ -1739,7 +1674,6 @@ def test_request_bars_sends_request_to_data_engine(self): # Arrange actor = MockActor() actor.register_base( - trader_id=self.trader_id, msgbus=self.msgbus, cache=self.cache, clock=self.clock, @@ -1765,7 +1699,6 @@ def test_request_bars_with_invalid_params_raises_value_error(self, start, stop): # Arrange actor = MockActor() actor.register_base( - trader_id=self.trader_id, msgbus=self.msgbus, cache=self.cache, clock=self.clock, From e2b90e25993f92a596a486d793949d790c614af6 Mon Sep 17 00:00:00 2001 From: Chris Sellers Date: Tue, 14 Feb 2023 10:35:31 +1100 Subject: [PATCH 47/81] Remove redundant blank line --- nautilus_trader/backtest/exchange.pyx | 1 - 1 file changed, 1 deletion(-) diff --git a/nautilus_trader/backtest/exchange.pyx b/nautilus_trader/backtest/exchange.pyx index da10bd9fcaa6..22d9cfc3e217 100644 --- a/nautilus_trader/backtest/exchange.pyx +++ b/nautilus_trader/backtest/exchange.pyx @@ -186,7 +186,6 @@ cdef class SimulatedExchange: cache=cache, clock=clock, logger=logger, - ) self.modules.append(module) self._log.info(f"Loaded {module}.") From ee440bc9237d2ce1c62cce22b8c7f9460c2f34b2 Mon Sep 17 00:00:00 2001 From: Chris Sellers Date: Tue, 14 Feb 2023 11:10:35 +1100 Subject: [PATCH 48/81] Cleanup redundant tuple things --- nautilus_trader/adapters/binance/futures/schemas/user.py | 2 +- nautilus_trader/adapters/binance/spot/schemas/user.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/nautilus_trader/adapters/binance/futures/schemas/user.py b/nautilus_trader/adapters/binance/futures/schemas/user.py index 050858fa36fe..bf89283a48bf 100644 --- a/nautilus_trader/adapters/binance/futures/schemas/user.py +++ b/nautilus_trader/adapters/binance/futures/schemas/user.py @@ -215,7 +215,7 @@ def parse_to_order_status_report( price = Price.from_str(self.p) if self.p is not None else None trigger_price = Price.from_str(self.sp) if self.sp is not None else None trailing_offset = Decimal(self.cr) * 100 if self.cr is not None else None - order_side = (OrderSide.BUY if self.S == BinanceOrderSide.BUY else OrderSide.SELL,) + order_side = OrderSide.BUY if self.S == BinanceOrderSide.BUY else OrderSide.SELL post_only = self.f == BinanceTimeInForce.GTX return OrderStatusReport( diff --git a/nautilus_trader/adapters/binance/spot/schemas/user.py b/nautilus_trader/adapters/binance/spot/schemas/user.py index f968b79b6e0a..e84656fd95f0 100644 --- a/nautilus_trader/adapters/binance/spot/schemas/user.py +++ b/nautilus_trader/adapters/binance/spot/schemas/user.py @@ -166,7 +166,7 @@ def parse_to_order_status_report( ) -> OrderStatusReport: price = Price.from_str(self.p) if self.p is not None else None trigger_price = Price.from_str(self.P) if self.P is not None else None - order_side = (OrderSide.BUY if self.S == BinanceOrderSide.BUY else OrderSide.SELL,) + order_side = OrderSide.BUY if self.S == BinanceOrderSide.BUY else OrderSide.SELL post_only = self.f == BinanceTimeInForce.GTX display_qty = ( Quantity.from_str( From febc0cc825dfbb96621890cab7a02b41b24cfb53 Mon Sep 17 00:00:00 2001 From: Chris Sellers Date: Tue, 14 Feb 2023 11:11:55 +1100 Subject: [PATCH 49/81] Extend timeouts for live Binance examples --- examples/live/binance_futures_market_maker.py | 8 ++++---- examples/live/binance_futures_testnet_ema_cross.py | 8 ++++---- .../live/binance_futures_testnet_ema_cross_bracket.py | 8 ++++---- ...inance_futures_testnet_ema_cross_with_trailing_stop.py | 8 ++++---- examples/live/binance_futures_testnet_market_maker.py | 8 ++++---- examples/live/binance_spot_ema_cross.py | 8 ++++---- examples/live/binance_spot_market_maker.py | 8 ++++---- examples/live/binance_spot_testnet_ema_cross.py | 8 ++++---- 8 files changed, 32 insertions(+), 32 deletions(-) diff --git a/examples/live/binance_futures_market_maker.py b/examples/live/binance_futures_market_maker.py index a6733e78e092..73a6aa1d7661 100644 --- a/examples/live/binance_futures_market_maker.py +++ b/examples/live/binance_futures_market_maker.py @@ -61,10 +61,10 @@ instrument_provider=InstrumentProviderConfig(load_all=True), ), }, - timeout_connection=5.0, - timeout_reconciliation=5.0, - timeout_portfolio=5.0, - timeout_disconnection=5.0, + timeout_connection=10.0, + timeout_reconciliation=10.0, + timeout_portfolio=10.0, + timeout_disconnection=10.0, timeout_post_stop=2.0, ) # Instantiate the node with a configuration diff --git a/examples/live/binance_futures_testnet_ema_cross.py b/examples/live/binance_futures_testnet_ema_cross.py index db709f4396ee..d594dedef39a 100644 --- a/examples/live/binance_futures_testnet_ema_cross.py +++ b/examples/live/binance_futures_testnet_ema_cross.py @@ -69,10 +69,10 @@ instrument_provider=InstrumentProviderConfig(load_all=True), ), }, - timeout_connection=5.0, - timeout_reconciliation=5.0, - timeout_portfolio=5.0, - timeout_disconnection=5.0, + timeout_connection=10.0, + timeout_reconciliation=10.0, + timeout_portfolio=10.0, + timeout_disconnection=10.0, timeout_post_stop=2.0, ) # Instantiate the node with a configuration diff --git a/examples/live/binance_futures_testnet_ema_cross_bracket.py b/examples/live/binance_futures_testnet_ema_cross_bracket.py index c437159d5e21..0956df5b56bc 100644 --- a/examples/live/binance_futures_testnet_ema_cross_bracket.py +++ b/examples/live/binance_futures_testnet_ema_cross_bracket.py @@ -69,10 +69,10 @@ instrument_provider=InstrumentProviderConfig(load_all=True), ), }, - timeout_connection=5.0, - timeout_reconciliation=5.0, - timeout_portfolio=5.0, - timeout_disconnection=5.0, + timeout_connection=10.0, + timeout_reconciliation=10.0, + timeout_portfolio=10.0, + timeout_disconnection=10.0, timeout_post_stop=2.0, ) # Instantiate the node with a configuration diff --git a/examples/live/binance_futures_testnet_ema_cross_with_trailing_stop.py b/examples/live/binance_futures_testnet_ema_cross_with_trailing_stop.py index fd4c6f6f4002..d63b739dc8c0 100644 --- a/examples/live/binance_futures_testnet_ema_cross_with_trailing_stop.py +++ b/examples/live/binance_futures_testnet_ema_cross_with_trailing_stop.py @@ -69,10 +69,10 @@ instrument_provider=InstrumentProviderConfig(load_all=True), ), }, - timeout_connection=5.0, - timeout_reconciliation=5.0, - timeout_portfolio=5.0, - timeout_disconnection=5.0, + timeout_connection=10.0, + timeout_reconciliation=10.0, + timeout_portfolio=10.0, + timeout_disconnection=10.0, timeout_post_stop=2.0, ) # Instantiate the node with a configuration diff --git a/examples/live/binance_futures_testnet_market_maker.py b/examples/live/binance_futures_testnet_market_maker.py index 299013d2fb9a..0416014db5de 100644 --- a/examples/live/binance_futures_testnet_market_maker.py +++ b/examples/live/binance_futures_testnet_market_maker.py @@ -69,10 +69,10 @@ instrument_provider=InstrumentProviderConfig(load_all=True), ), }, - timeout_connection=5.0, - timeout_reconciliation=5.0, - timeout_portfolio=5.0, - timeout_disconnection=5.0, + timeout_connection=10.0, + timeout_reconciliation=10.0, + timeout_portfolio=10.0, + timeout_disconnection=10.0, timeout_post_stop=2.0, ) # Instantiate the node with a configuration diff --git a/examples/live/binance_spot_ema_cross.py b/examples/live/binance_spot_ema_cross.py index 30fecec36a37..4bd15406afd7 100644 --- a/examples/live/binance_spot_ema_cross.py +++ b/examples/live/binance_spot_ema_cross.py @@ -69,10 +69,10 @@ instrument_provider=InstrumentProviderConfig(load_all=True), ), }, - timeout_connection=5.0, - timeout_reconciliation=5.0, - timeout_portfolio=5.0, - timeout_disconnection=5.0, + timeout_connection=10.0, + timeout_reconciliation=10.0, + timeout_portfolio=10.0, + timeout_disconnection=10.0, timeout_post_stop=2.0, ) # Instantiate the node with a configuration diff --git a/examples/live/binance_spot_market_maker.py b/examples/live/binance_spot_market_maker.py index 7979eb77df30..12e524e7d251 100644 --- a/examples/live/binance_spot_market_maker.py +++ b/examples/live/binance_spot_market_maker.py @@ -69,10 +69,10 @@ instrument_provider=InstrumentProviderConfig(load_all=True), ), }, - timeout_connection=5.0, - timeout_reconciliation=5.0, - timeout_portfolio=5.0, - timeout_disconnection=5.0, + timeout_connection=10.0, + timeout_reconciliation=10.0, + timeout_portfolio=10.0, + timeout_disconnection=10.0, timeout_post_stop=2.0, ) # Instantiate the node with a configuration diff --git a/examples/live/binance_spot_testnet_ema_cross.py b/examples/live/binance_spot_testnet_ema_cross.py index 5325446aa3bd..f755c208ebc9 100644 --- a/examples/live/binance_spot_testnet_ema_cross.py +++ b/examples/live/binance_spot_testnet_ema_cross.py @@ -69,10 +69,10 @@ instrument_provider=InstrumentProviderConfig(load_all=True), ), }, - timeout_connection=5.0, - timeout_reconciliation=5.0, - timeout_portfolio=5.0, - timeout_disconnection=5.0, + timeout_connection=10.0, + timeout_reconciliation=10.0, + timeout_portfolio=10.0, + timeout_disconnection=10.0, timeout_post_stop=2.0, ) # Instantiate the node with a configuration From bd19582854dd359a8fdc19a7f02259e470047142 Mon Sep 17 00:00:00 2001 From: Chris Sellers Date: Tue, 14 Feb 2023 11:28:00 +1100 Subject: [PATCH 50/81] Fix Binance symbols conversions in providers --- nautilus_trader/adapters/binance/futures/providers.py | 6 ++++-- nautilus_trader/adapters/binance/spot/providers.py | 7 +++++-- .../sandbox_http_futures_testnet_instrument_provider.py | 7 +++++++ 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/nautilus_trader/adapters/binance/futures/providers.py b/nautilus_trader/adapters/binance/futures/providers.py index fc233dea34a5..2c1ea3604767 100644 --- a/nautilus_trader/adapters/binance/futures/providers.py +++ b/nautilus_trader/adapters/binance/futures/providers.py @@ -139,7 +139,9 @@ async def load_ids_async( self._log.info(f"Loading instruments {instrument_ids}{filters_str}.") # Extract all symbol strings - symbols = [instrument_id.symbol.value for instrument_id in instrument_ids] + symbols = [ + str(BinanceSymbol(instrument_id.symbol.value)) for instrument_id in instrument_ids + ] # Get exchange info for all assets exchange_info = await self._http_market.query_futures_exchange_info() @@ -172,7 +174,7 @@ async def load_async(self, instrument_id: InstrumentId, filters: Optional[dict] filters_str = "..." if not filters else f" with filters {filters}..." self._log.debug(f"Loading instrument {instrument_id}{filters_str}.") - symbol = instrument_id.symbol.value + symbol = str(BinanceSymbol(instrument_id.symbol.value)) # Get exchange info for all assets exchange_info = await self._http_market.query_futures_exchange_info() diff --git a/nautilus_trader/adapters/binance/spot/providers.py b/nautilus_trader/adapters/binance/spot/providers.py index 90e20ed24fc4..eacd8dce72cc 100644 --- a/nautilus_trader/adapters/binance/spot/providers.py +++ b/nautilus_trader/adapters/binance/spot/providers.py @@ -22,6 +22,7 @@ from nautilus_trader.adapters.binance.common.enums import BinanceAccountType from nautilus_trader.adapters.binance.common.enums import BinanceSymbolFilterType from nautilus_trader.adapters.binance.common.schemas.market import BinanceSymbolFilter +from nautilus_trader.adapters.binance.common.schemas.symbol import BinanceSymbol from nautilus_trader.adapters.binance.http.client import BinanceHttpClient from nautilus_trader.adapters.binance.http.error import BinanceClientError from nautilus_trader.adapters.binance.spot.http.market import BinanceSpotMarketHttpAPI @@ -145,7 +146,9 @@ async def load_ids_async( return # Extract all symbol strings - symbols = [instrument_id.symbol.value for instrument_id in instrument_ids] + symbols = [ + str(BinanceSymbol(instrument_id.symbol.value)) for instrument_id in instrument_ids + ] # Get exchange info for all assets exchange_info = await self._http_market.query_spot_exchange_info(symbols=symbols) symbol_info_dict: dict[str, BinanceSpotSymbolInfo] = { @@ -166,7 +169,7 @@ async def load_async(self, instrument_id: InstrumentId, filters: Optional[dict] filters_str = "..." if not filters else f" with filters {filters}..." self._log.debug(f"Loading instrument {instrument_id}{filters_str}.") - symbol = instrument_id.symbol.value + symbol = str(BinanceSymbol(instrument_id.symbol.value)) # Get current commission rates try: diff --git a/tests/integration_tests/adapters/binance/sandbox/sandbox_http_futures_testnet_instrument_provider.py b/tests/integration_tests/adapters/binance/sandbox/sandbox_http_futures_testnet_instrument_provider.py index 34c05587c6b4..5ea28e4027dd 100644 --- a/tests/integration_tests/adapters/binance/sandbox/sandbox_http_futures_testnet_instrument_provider.py +++ b/tests/integration_tests/adapters/binance/sandbox/sandbox_http_futures_testnet_instrument_provider.py @@ -18,11 +18,14 @@ import pytest +from nautilus_trader.adapters.binance.common.constants import BINANCE_VENUE from nautilus_trader.adapters.binance.common.enums import BinanceAccountType from nautilus_trader.adapters.binance.factories import get_cached_binance_http_client from nautilus_trader.adapters.binance.futures.providers import BinanceFuturesInstrumentProvider from nautilus_trader.common.clock import LiveClock from nautilus_trader.common.logging import Logger +from nautilus_trader.model.identifiers import InstrumentId +from nautilus_trader.model.identifiers import Symbol @pytest.mark.asyncio @@ -37,6 +40,7 @@ async def test_binance_futures_testnet_instrument_provider(): account_type=BinanceAccountType.FUTURES_USDT, key=os.getenv("BINANCE_FUTURES_TESTNET_API_KEY"), secret=os.getenv("BINANCE_FUTURES_TESTNET_API_SECRET"), + is_testnet=True, ) await client.connect() @@ -46,6 +50,9 @@ async def test_binance_futures_testnet_instrument_provider(): logger=Logger(clock=clock), ) + # await provider.load_all_async() + btcusdt_perp = InstrumentId(Symbol("BTCUSDT-PERP"), BINANCE_VENUE) + await provider.load_ids_async(instrument_ids=[btcusdt_perp]) await provider.load_all_async() print(provider.count) From 579035042bd0d37816beb5e7714eb9dd0598fd12 Mon Sep 17 00:00:00 2001 From: Bradley McElroy Date: Tue, 14 Feb 2023 12:16:15 +1100 Subject: [PATCH 51/81] Betfair ticker (#1002) --- examples/live/betfair.py | 23 ++++++++++--------- nautilus_trader/adapters/betfair/data.py | 15 +++++------- .../strategies/orderbook_imbalance.py | 7 ++++-- .../adapters/betfair/test_betfair_data.py | 10 ++++---- 4 files changed, 28 insertions(+), 27 deletions(-) diff --git a/examples/live/betfair.py b/examples/live/betfair.py index 478ba9910572..533026a4212d 100644 --- a/examples/live/betfair.py +++ b/examples/live/betfair.py @@ -18,7 +18,6 @@ import traceback from nautilus_trader.adapters.betfair.config import BetfairDataClientConfig -from nautilus_trader.adapters.betfair.config import BetfairExecClientConfig from nautilus_trader.adapters.betfair.factories import BetfairLiveDataClientFactory from nautilus_trader.adapters.betfair.factories import BetfairLiveExecClientFactory from nautilus_trader.adapters.betfair.factories import get_cached_betfair_client @@ -61,8 +60,8 @@ async def main(market_id: str): instruments = provider.list_all() print(f"Found instruments:\n{[ins.id for ins in instruments]}") - # Determine account currency - account = await client.get_account_details() + # Determine account currency - used in execution client + # account = await client.get_account_details() # Configure trading node config = TradingNodeConfig( @@ -79,14 +78,15 @@ async def main(market_id: str): ), }, exec_clients={ - "BETFAIR": BetfairExecClientConfig( - base_currency=account["currencyCode"], - # "username": "YOUR_BETFAIR_USERNAME", - # "password": "YOUR_BETFAIR_PASSWORD", - # "app_key": "YOUR_BETFAIR_APP_KEY", - # "cert_dir": "YOUR_BETFAIR_CERT_DIR", - market_filter=market_filter, - ), + # # UNCOMMENT TO SEND ORDERS + # "BETFAIR": BetfairExecClientConfig( + # base_currency=account["currencyCode"], + # # "username": "YOUR_BETFAIR_USERNAME", + # # "password": "YOUR_BETFAIR_PASSWORD", + # # "app_key": "YOUR_BETFAIR_APP_KEY", + # # "cert_dir": "YOUR_BETFAIR_CERT_DIR", + # market_filter=market_filter, + # ), }, ) strategies = [ @@ -95,6 +95,7 @@ async def main(market_id: str): instrument_id=instrument.id.value, max_trade_size=5, order_id_tag=instrument.selection_id, + subscribe_ticker=True, ), ) for instrument in instruments diff --git a/nautilus_trader/adapters/betfair/data.py b/nautilus_trader/adapters/betfair/data.py index 3f57b10d556e..6fc290f1902b 100644 --- a/nautilus_trader/adapters/betfair/data.py +++ b/nautilus_trader/adapters/betfair/data.py @@ -235,35 +235,32 @@ async def delayed_subscribe(self, delay=0): await self._stream.send_subscription_message(market_ids=list(self._subscribed_market_ids)) self._log.info(f"Added market_ids {self._subscribed_market_ids} for data.") - def subscribe_trade_ticks(self, instrument_id: InstrumentId): + async def _subscribe_ticker(self, instrument_id: InstrumentId) -> None: pass # Subscribed as part of orderbook - def subscribe_instrument(self, instrument_id: InstrumentId): + async def _subscribe_instrument(self, instrument_id: InstrumentId): for instrument in self._instrument_provider.list_all(): self._handle_data(data=instrument) - def subscribe_instrument_status_updates(self, instrument_id: InstrumentId): + async def _subscribe_instrument_status_updates(self, instrument_id: InstrumentId): pass # Subscribed as part of orderbook - def subscribe_instrument_close(self, instrument_id: InstrumentId): + async def _subscribe_instrument_close(self, instrument_id: InstrumentId): pass # Subscribed as part of orderbook - def unsubscribe_order_book_snapshots(self, instrument_id: InstrumentId): + async def _unsubscribe_order_book_snapshots(self, instrument_id: InstrumentId): # TODO - this could be done by removing the market from self.__subscribed_market_ids and resending the # subscription message - when we have a use case self._log.warning("Betfair does not support unsubscribing from instruments") - def unsubscribe_order_book_deltas(self, instrument_id: InstrumentId): + async def _unsubscribe_order_book_deltas(self, instrument_id: InstrumentId): # TODO - this could be done by removing the market from self.__subscribed_market_ids and resending the # subscription message - when we have a use case self._log.warning("Betfair does not support unsubscribing from instruments") # -- INTERNAL --------------------------------------------------------------------------------- - def _log_betfair_error(self, ex: Exception, method_name: str): - self._log.warning(f"{type(ex).__name__}: {ex} in {method_name}") - def handle_data(self, data: Data): self._handle_data(data=data) diff --git a/nautilus_trader/examples/strategies/orderbook_imbalance.py b/nautilus_trader/examples/strategies/orderbook_imbalance.py index fe9010eb3a2e..178f5b645424 100644 --- a/nautilus_trader/examples/strategies/orderbook_imbalance.py +++ b/nautilus_trader/examples/strategies/orderbook_imbalance.py @@ -65,6 +65,7 @@ class OrderBookImbalanceConfig(StrategyConfig): trigger_imbalance_ratio: float = 0.20 book_type: str = "L2_MBP" use_quote_ticks: bool = False + subscribe_ticker: bool = False class OrderBookImbalance(Strategy): @@ -105,10 +106,12 @@ def on_start(self): if self.config.use_quote_ticks: book_type = BookType.L1_TBBO - self.subscribe_quote_ticks(instrument_id=self.instrument.id) + self.subscribe_quote_ticks(self.instrument.id) else: book_type = book_type_from_str(self.config.book_type) - self.subscribe_order_book_deltas(instrument_id=self.instrument.id, book_type=book_type) + self.subscribe_order_book_deltas(self.instrument.id, book_type) + if self.config.subscribe_ticker: + self.subscribe_ticker(self.instrument.id) self._book = OrderBook.create(instrument=self.instrument, book_type=book_type) def on_order_book_delta(self, data: OrderBookData): diff --git a/tests/integration_tests/adapters/betfair/test_betfair_data.py b/tests/integration_tests/adapters/betfair/test_betfair_data.py index 4061c9e43866..170314208055 100644 --- a/tests/integration_tests/adapters/betfair/test_betfair_data.py +++ b/tests/integration_tests/adapters/betfair/test_betfair_data.py @@ -118,9 +118,9 @@ def setup(self): clock=self.clock, logger=self.logger, ) - + self.instrument_id = TestInstrumentProvider.betting_instrument() self.cache = TestComponentStubs.cache() - self.cache.add_instrument(TestInstrumentProvider.betting_instrument()) + self.cache.add_instrument(self.instrument_id) self.portfolio = Portfolio( msgbus=self.msgbus, @@ -214,9 +214,9 @@ async def test_connect( await self.client._connect() def test_subscriptions(self): - self.client.subscribe_trade_ticks(TestIdStubs.betting_instrument_id()) - self.client.subscribe_instrument_status_updates(TestIdStubs.betting_instrument_id()) - self.client.subscribe_instrument_close(TestIdStubs.betting_instrument_id()) + self.client.subscribe_trade_ticks(self.instrument_id) + self.client.subscribe_instrument_status_updates(self.instrument_id) + self.client.subscribe_instrument_close(self.instrument_id) def test_market_heartbeat(self): self.client.on_market_update(BetfairStreaming.mcm_HEARTBEAT()) From 18e3e117b302eecf1fe22a1b887e51e79caed7a2 Mon Sep 17 00:00:00 2001 From: Bradley McElroy Date: Tue, 14 Feb 2023 14:12:46 +1100 Subject: [PATCH 52/81] Fix stopping socket client (#1003) --- nautilus_trader/adapters/betfair/execution.py | 2 +- nautilus_trader/network/socket.pxd | 11 ++-- nautilus_trader/network/socket.pyx | 57 ++++++++++++------- .../integration_tests/network/test_socket.py | 2 +- 4 files changed, 44 insertions(+), 28 deletions(-) diff --git a/nautilus_trader/adapters/betfair/execution.py b/nautilus_trader/adapters/betfair/execution.py index ad5678d11ec3..511327b2758d 100644 --- a/nautilus_trader/adapters/betfair/execution.py +++ b/nautilus_trader/adapters/betfair/execution.py @@ -180,7 +180,7 @@ async def _disconnect(self) -> None: async def watch_stream(self): """Ensure socket stream is connected""" - while self.stream.is_running: + while not self.stream.is_stopping: if not self.stream.is_connected: await self.stream.connect() await asyncio.sleep(1) diff --git a/nautilus_trader/network/socket.pxd b/nautilus_trader/network/socket.pxd index bc9a4208dd76..1320b51d6757 100644 --- a/nautilus_trader/network/socket.pxd +++ b/nautilus_trader/network/socket.pxd @@ -26,10 +26,7 @@ cdef class SocketClient: cdef bytes _crlf cdef str _encoding cdef int _incomplete_read_count - cdef readonly bint is_running - cdef readonly bint is_stopped - cdef readonly int reconnection_count - cdef readonly bint is_stopping + cdef readonly int _reconnection_count # readonly for test cdef readonly object host # TODO(cs): Temporary `object` typing """The host for the socket client.\n\n:returns: `str`""" @@ -37,5 +34,9 @@ cdef class SocketClient: """The port for the socket client.\n\n:returns: `int`""" cdef readonly bint ssl """If the socket client is using SSL.\n\n:returns: `bool`""" + cdef readonly bint is_stopping + """If the client is stopping.\n\n:returns: `bool`""" + cdef readonly bint is_running + """If the client is running.\n\n:returns: `bool`""" cdef readonly bint is_connected - """If the socket is connected.\n\n:returns: `bool`""" + """If the client is connected.\n\n:returns: `bool`""" diff --git a/nautilus_trader/network/socket.pyx b/nautilus_trader/network/socket.pyx index 9ab54486861a..e9c57fafd147 100644 --- a/nautilus_trader/network/socket.pyx +++ b/nautilus_trader/network/socket.pyx @@ -82,11 +82,10 @@ cdef class SocketClient: self._crlf = crlf or b"\r\n" self._encoding = encoding - self.is_running = False self._incomplete_read_count = 0 + self._reconnection_count = 0 + self.is_stopping = False self.is_running = False - self.is_stopped = False - self.reconnection_count = 0 self.is_connected = False async def connect(self): @@ -105,18 +104,23 @@ cdef class SocketClient: await self.post_connection() self._log.debug("Starting main loop") self._loop.create_task(self.start()) - self.is_running = True self.is_connected = True self._log.info("Connected.") + async def post_connection(self): + """ + The actions to perform post-connection. i.e. sending further connection messages. + """ + await sleep0() + async def disconnect(self): self._log.info("Disconnecting .. ") - self.stop() - self._log.debug("Main loop stop triggered.") - while not self.is_stopped: - self._log.debug("Waiting for stop") - await asyncio.sleep(0.25) - self._log.debug("Stopped, closing connections") + self.is_stopping = True + self._log.debug("main loop stop triggered.") + while not self.is_running: + await sleep0() + await self.post_disconnection() + self._log.debug("main loop stopped, closing connections") self._writer.close() await self._writer.wait_closed() self._log.debug("Connections closed") @@ -125,19 +129,28 @@ cdef class SocketClient: self.is_connected = False self._log.info("Disconnected.") - def stop(self): - self.is_running = False + async def post_disconnection(self) -> None: + """ + Actions to be performed post disconnection. + + """ + # Override to implement additional disconnection related behaviour + # (canceling ping tasks etc.). + pass async def reconnect(self): self._log.info("Reconnecting") await self.disconnect() await self.connect() - async def post_connection(self): + async def post_reconnection(self) -> None: """ - The actions to perform post-connection. i.e. sending further connection messages. + Actions to be performed post reconnection. + """ - await sleep0() + # Override to implement additional reconnection related behaviour + # (resubscribing etc.). + pass async def send(self, bytes raw): self._log.debug("[SEND] " + raw.decode()) @@ -146,11 +159,12 @@ cdef class SocketClient: async def start(self): self._log.debug("Starting recv loop") + self.is_running = True cdef: bytes partial = b"" bytes raw = b"" - while self.is_running: + while not self.is_stopping: try: raw = await self._reader.readuntil(separator=self._crlf) if partial: @@ -162,14 +176,15 @@ cdef class SocketClient: await sleep0() except asyncio.IncompleteReadError as e: partial = e.partial + if self.is_stopping: + break self._log.warning(str(e)) self._incomplete_read_count += 1 - await asyncio.sleep(0.010) + await sleep0() if self._incomplete_read_count > 10: # Something probably wrong; reconnect - self._log.warning(f"Incomplete read error ({self._incomplete_read_count=}), reconnecting.. ({self.reconnection_count=})") - self.is_running = False - self.reconnection_count += 1 + self._log.warning(f"Incomplete read error ({self._incomplete_read_count=}), reconnecting.. ({self._reconnection_count=})") + self._reconnection_count += 1 self._loop.create_task(self.reconnect()) return await sleep0() @@ -177,4 +192,4 @@ cdef class SocketClient: except ConnectionResetError: self._loop.create_task(self.reconnect()) return - self.is_running = True + self.is_running = False diff --git a/tests/integration_tests/network/test_socket.py b/tests/integration_tests/network/test_socket.py index 1199fd2bef7a..c4f7d5e2051e 100644 --- a/tests/integration_tests/network/test_socket.py +++ b/tests/integration_tests/network/test_socket.py @@ -68,4 +68,4 @@ def handler(raw): # Reconnect and receive another message await asyncio.sleep(1) - assert client.reconnection_count >= 1 + assert client._reconnection_count >= 1 From f0f7ab75911198e2a5d25362ee4abd0509a28b96 Mon Sep 17 00:00:00 2001 From: Bradley McElroy Date: Tue, 14 Feb 2023 18:46:25 +1100 Subject: [PATCH 53/81] Fix missing betfair bsp (#1006) --- nautilus_trader/adapters/betfair/data.py | 5 +++-- .../integration_tests/adapters/betfair/test_betfair_data.py | 6 +++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/nautilus_trader/adapters/betfair/data.py b/nautilus_trader/adapters/betfair/data.py index 6fc290f1902b..39ed90a6ae7a 100644 --- a/nautilus_trader/adapters/betfair/data.py +++ b/nautilus_trader/adapters/betfair/data.py @@ -25,6 +25,7 @@ from nautilus_trader.adapters.betfair.client.core import BetfairClient from nautilus_trader.adapters.betfair.common import BETFAIR_VENUE from nautilus_trader.adapters.betfair.data_types import BetfairStartingPrice +from nautilus_trader.adapters.betfair.data_types import BSPOrderBookDeltas from nautilus_trader.adapters.betfair.data_types import InstrumentSearch from nautilus_trader.adapters.betfair.data_types import SubscriptionStatus from nautilus_trader.adapters.betfair.parsing.streaming import BetfairParser @@ -281,10 +282,10 @@ def _on_market_update(self, mcm: MCM): updates = self.parser.parse(mcm=mcm) for data in updates: self._log.debug(f"{data}") - if isinstance(data, BetfairStartingPrice): + if isinstance(data, (BetfairStartingPrice, BSPOrderBookDeltas)): # Not a regular data type generic_data = GenericData( - DataType(BetfairStartingPrice, metadata={"instrument_id": data.instrument_id}), + DataType(data.__class__, metadata={"instrument_id": data.instrument_id}), data, ) self._handle_data(generic_data) diff --git a/tests/integration_tests/adapters/betfair/test_betfair_data.py b/tests/integration_tests/adapters/betfair/test_betfair_data.py index 170314208055..757849a7ea9e 100644 --- a/tests/integration_tests/adapters/betfair/test_betfair_data.py +++ b/tests/integration_tests/adapters/betfair/test_betfair_data.py @@ -335,7 +335,7 @@ def test_market_bsp(self): "InstrumentStatusUpdate": 9, "OrderBookSnapshot": 8, "BetfairTicker": 8, - "BSPOrderBookDeltas": 8, + "GenericData": 8, "OrderBookDeltas": 2, "InstrumentClose": 1, } @@ -343,8 +343,8 @@ def test_market_bsp(self): sp_deltas = [ d for deltas in self.messages - if isinstance(deltas, BSPOrderBookDeltas) - for d in deltas.deltas + if isinstance(deltas, GenericData) + for d in deltas.data.deltas ] assert len(sp_deltas) == 30 From 0812539dcf45baf22f17ff68694ef37690f3212a Mon Sep 17 00:00:00 2001 From: Chris Sellers Date: Tue, 14 Feb 2023 18:31:37 +1100 Subject: [PATCH 54/81] Consistently use Self return for new fn --- nautilus_core/core/src/uuid.rs | 2 +- nautilus_core/model/src/data/tick.rs | 4 ++-- nautilus_core/model/src/identifiers/account_id.rs | 2 +- nautilus_core/model/src/identifiers/client_id.rs | 2 +- nautilus_core/model/src/identifiers/client_order_id.rs | 2 +- nautilus_core/model/src/identifiers/component_id.rs | 2 +- nautilus_core/model/src/identifiers/exec_algorithm_id.rs | 2 +- nautilus_core/model/src/identifiers/instrument_id.rs | 2 +- nautilus_core/model/src/identifiers/order_list_id.rs | 2 +- nautilus_core/model/src/identifiers/position_id.rs | 2 +- nautilus_core/model/src/identifiers/strategy_id.rs | 2 +- nautilus_core/model/src/identifiers/symbol.rs | 2 +- nautilus_core/model/src/identifiers/trade_id.rs | 2 +- nautilus_core/model/src/identifiers/trader_id.rs | 2 +- nautilus_core/model/src/identifiers/venue.rs | 2 +- nautilus_core/model/src/identifiers/venue_order_id.rs | 2 +- nautilus_core/model/src/types/currency.rs | 2 +- nautilus_core/model/src/types/money.rs | 4 ++-- nautilus_core/model/src/types/price.rs | 4 ++-- nautilus_core/model/src/types/quantity.rs | 4 ++-- nautilus_core/persistence/src/parquet/writer.rs | 2 +- 21 files changed, 25 insertions(+), 25 deletions(-) diff --git a/nautilus_core/core/src/uuid.rs b/nautilus_core/core/src/uuid.rs index ccb2dd1f05b5..12cb1262b3a2 100644 --- a/nautilus_core/core/src/uuid.rs +++ b/nautilus_core/core/src/uuid.rs @@ -35,7 +35,7 @@ impl UUID4 { #[must_use] pub fn new() -> Self { let uuid = Uuid::new_v4(); - Self { + UUID4 { value: Box::new(Rc::new(uuid.to_string())), } } diff --git a/nautilus_core/model/src/data/tick.rs b/nautilus_core/model/src/data/tick.rs index a63d8ecfd937..0679090cb23f 100644 --- a/nautilus_core/model/src/data/tick.rs +++ b/nautilus_core/model/src/data/tick.rs @@ -49,7 +49,7 @@ impl QuoteTick { ask_size: Quantity, ts_event: UnixNanos, ts_init: UnixNanos, - ) -> QuoteTick { + ) -> Self { correctness::u8_equal( bid.precision, ask.precision, @@ -107,7 +107,7 @@ impl TradeTick { trade_id: TradeId, ts_event: UnixNanos, ts_init: UnixNanos, - ) -> TradeTick { + ) -> Self { TradeTick { instrument_id, price, diff --git a/nautilus_core/model/src/identifiers/account_id.rs b/nautilus_core/model/src/identifiers/account_id.rs index b97fb7e97aff..59a61da90baf 100644 --- a/nautilus_core/model/src/identifiers/account_id.rs +++ b/nautilus_core/model/src/identifiers/account_id.rs @@ -38,7 +38,7 @@ impl Display for AccountId { impl AccountId { #[must_use] - pub fn new(s: &str) -> AccountId { + pub fn new(s: &str) -> Self { correctness::valid_string(s, "`AccountId` value"); correctness::string_contains(s, "-", "`TraderId` value"); diff --git a/nautilus_core/model/src/identifiers/client_id.rs b/nautilus_core/model/src/identifiers/client_id.rs index 4addffc32117..20d78021b176 100644 --- a/nautilus_core/model/src/identifiers/client_id.rs +++ b/nautilus_core/model/src/identifiers/client_id.rs @@ -38,7 +38,7 @@ impl Display for ClientId { impl ClientId { #[must_use] - pub fn new(s: &str) -> ClientId { + pub fn new(s: &str) -> Self { correctness::valid_string(s, "`ClientId` value"); ClientId { diff --git a/nautilus_core/model/src/identifiers/client_order_id.rs b/nautilus_core/model/src/identifiers/client_order_id.rs index a6d2e0f33c77..9bdc7d34c616 100644 --- a/nautilus_core/model/src/identifiers/client_order_id.rs +++ b/nautilus_core/model/src/identifiers/client_order_id.rs @@ -38,7 +38,7 @@ impl Display for ClientOrderId { impl ClientOrderId { #[must_use] - pub fn new(s: &str) -> ClientOrderId { + pub fn new(s: &str) -> Self { correctness::valid_string(s, "`ClientOrderId` value"); ClientOrderId { diff --git a/nautilus_core/model/src/identifiers/component_id.rs b/nautilus_core/model/src/identifiers/component_id.rs index 9d6064e48f2f..4fc2bb9af16c 100644 --- a/nautilus_core/model/src/identifiers/component_id.rs +++ b/nautilus_core/model/src/identifiers/component_id.rs @@ -38,7 +38,7 @@ impl Display for ComponentId { impl ComponentId { #[must_use] - pub fn new(s: &str) -> ComponentId { + pub fn new(s: &str) -> Self { correctness::valid_string(s, "`ComponentId` value"); ComponentId { diff --git a/nautilus_core/model/src/identifiers/exec_algorithm_id.rs b/nautilus_core/model/src/identifiers/exec_algorithm_id.rs index 9464aff93a6e..42b717a7d4a9 100644 --- a/nautilus_core/model/src/identifiers/exec_algorithm_id.rs +++ b/nautilus_core/model/src/identifiers/exec_algorithm_id.rs @@ -38,7 +38,7 @@ impl Display for ExecAlgorithmId { impl ExecAlgorithmId { #[must_use] - pub fn new(s: &str) -> ExecAlgorithmId { + pub fn new(s: &str) -> Self { correctness::valid_string(s, "`ExecAlgorithmId` value"); ExecAlgorithmId { diff --git a/nautilus_core/model/src/identifiers/instrument_id.rs b/nautilus_core/model/src/identifiers/instrument_id.rs index 96a9da654561..b3c8cd109866 100644 --- a/nautilus_core/model/src/identifiers/instrument_id.rs +++ b/nautilus_core/model/src/identifiers/instrument_id.rs @@ -49,7 +49,7 @@ impl Display for InstrumentId { impl InstrumentId { #[must_use] - pub fn new(symbol: Symbol, venue: Venue) -> InstrumentId { + pub fn new(symbol: Symbol, venue: Venue) -> Self { InstrumentId { symbol, venue } } } diff --git a/nautilus_core/model/src/identifiers/order_list_id.rs b/nautilus_core/model/src/identifiers/order_list_id.rs index 43b804bdfce9..79f842697074 100644 --- a/nautilus_core/model/src/identifiers/order_list_id.rs +++ b/nautilus_core/model/src/identifiers/order_list_id.rs @@ -38,7 +38,7 @@ impl Display for OrderListId { impl OrderListId { #[must_use] - pub fn new(s: &str) -> OrderListId { + pub fn new(s: &str) -> Self { correctness::valid_string(s, "`OrderListId` value"); OrderListId { diff --git a/nautilus_core/model/src/identifiers/position_id.rs b/nautilus_core/model/src/identifiers/position_id.rs index 370d7874110c..1cffcd56f459 100644 --- a/nautilus_core/model/src/identifiers/position_id.rs +++ b/nautilus_core/model/src/identifiers/position_id.rs @@ -38,7 +38,7 @@ impl Display for PositionId { impl PositionId { #[must_use] - pub fn new(s: &str) -> PositionId { + pub fn new(s: &str) -> Self { correctness::valid_string(s, "`PositionId` value"); PositionId { diff --git a/nautilus_core/model/src/identifiers/strategy_id.rs b/nautilus_core/model/src/identifiers/strategy_id.rs index a3b00c5b7b58..ee7ef9cf1758 100644 --- a/nautilus_core/model/src/identifiers/strategy_id.rs +++ b/nautilus_core/model/src/identifiers/strategy_id.rs @@ -36,7 +36,7 @@ impl Display for StrategyId { impl StrategyId { #[must_use] - pub fn new(s: &str) -> StrategyId { + pub fn new(s: &str) -> Self { correctness::valid_string(s, "`StrategyId` value"); if s != "EXTERNAL" { correctness::string_contains(s, "-", "`StrategyId` value"); diff --git a/nautilus_core/model/src/identifiers/symbol.rs b/nautilus_core/model/src/identifiers/symbol.rs index ad479d1a6ae0..5cd76c58c765 100644 --- a/nautilus_core/model/src/identifiers/symbol.rs +++ b/nautilus_core/model/src/identifiers/symbol.rs @@ -38,7 +38,7 @@ impl Display for Symbol { impl Symbol { #[must_use] - pub fn new(s: &str) -> Symbol { + pub fn new(s: &str) -> Self { correctness::valid_string(s, "`Symbol` value"); Symbol { diff --git a/nautilus_core/model/src/identifiers/trade_id.rs b/nautilus_core/model/src/identifiers/trade_id.rs index d7d1805a8bc9..fb7ac9013687 100644 --- a/nautilus_core/model/src/identifiers/trade_id.rs +++ b/nautilus_core/model/src/identifiers/trade_id.rs @@ -38,7 +38,7 @@ impl Display for TradeId { impl TradeId { #[must_use] - pub fn new(s: &str) -> TradeId { + pub fn new(s: &str) -> Self { correctness::valid_string(s, "`TradeId` value"); TradeId { diff --git a/nautilus_core/model/src/identifiers/trader_id.rs b/nautilus_core/model/src/identifiers/trader_id.rs index f921baf48ccb..6b1fc28f4a7a 100644 --- a/nautilus_core/model/src/identifiers/trader_id.rs +++ b/nautilus_core/model/src/identifiers/trader_id.rs @@ -36,7 +36,7 @@ impl Display for TraderId { impl TraderId { #[must_use] - pub fn new(s: &str) -> TraderId { + pub fn new(s: &str) -> Self { correctness::valid_string(s, "`TraderId` value"); correctness::string_contains(s, "-", "`TraderId` value"); diff --git a/nautilus_core/model/src/identifiers/venue.rs b/nautilus_core/model/src/identifiers/venue.rs index a220a0730ed0..edc78ea47c15 100644 --- a/nautilus_core/model/src/identifiers/venue.rs +++ b/nautilus_core/model/src/identifiers/venue.rs @@ -38,7 +38,7 @@ impl Display for Venue { impl Venue { #[must_use] - pub fn new(s: &str) -> Venue { + pub fn new(s: &str) -> Self { correctness::valid_string(s, "`Venue` value"); Venue { diff --git a/nautilus_core/model/src/identifiers/venue_order_id.rs b/nautilus_core/model/src/identifiers/venue_order_id.rs index 004fa40322e4..bc7b24fa40eb 100644 --- a/nautilus_core/model/src/identifiers/venue_order_id.rs +++ b/nautilus_core/model/src/identifiers/venue_order_id.rs @@ -38,7 +38,7 @@ impl Display for VenueOrderId { impl VenueOrderId { #[must_use] - pub fn new(s: &str) -> VenueOrderId { + pub fn new(s: &str) -> Self { correctness::valid_string(s, "`VenueOrderId` value"); VenueOrderId { diff --git a/nautilus_core/model/src/types/currency.rs b/nautilus_core/model/src/types/currency.rs index 2ba896feafb0..2212ea4ba914 100644 --- a/nautilus_core/model/src/types/currency.rs +++ b/nautilus_core/model/src/types/currency.rs @@ -47,7 +47,7 @@ impl Currency { correctness::valid_string(name, "`Currency` name"); correctness::u8_in_range_inclusive(precision, 0, 9, "`Currency` precision"); - Self { + Currency { code: Box::new(Rc::new(code.to_string())), precision, iso4217, diff --git a/nautilus_core/model/src/types/money.rs b/nautilus_core/model/src/types/money.rs index 95050684ecb0..ff3c6de28067 100644 --- a/nautilus_core/model/src/types/money.rs +++ b/nautilus_core/model/src/types/money.rs @@ -35,10 +35,10 @@ pub struct Money { impl Money { #[must_use] - pub fn new(amount: f64, currency: Currency) -> Money { + pub fn new(amount: f64, currency: Currency) -> Self { correctness::f64_in_range_inclusive(amount, MONEY_MIN, MONEY_MAX, "`Money` amount"); - Self { + Money { raw: f64_to_fixed_i64(amount, currency.precision), currency, } diff --git a/nautilus_core/model/src/types/price.rs b/nautilus_core/model/src/types/price.rs index 515fc27a59df..25e94efd800a 100644 --- a/nautilus_core/model/src/types/price.rs +++ b/nautilus_core/model/src/types/price.rs @@ -38,14 +38,14 @@ impl Price { pub fn new(value: f64, precision: u8) -> Self { correctness::f64_in_range_inclusive(value, PRICE_MIN, PRICE_MAX, "`Price` value"); - Self { + Price { raw: f64_to_fixed_i64(value, precision), precision, } } pub fn from_raw(raw: i64, precision: u8) -> Self { - Self { raw, precision } + Price { raw, precision } } pub fn is_zero(&self) -> bool { diff --git a/nautilus_core/model/src/types/quantity.rs b/nautilus_core/model/src/types/quantity.rs index 0bf826c35fb8..552e163d76a8 100644 --- a/nautilus_core/model/src/types/quantity.rs +++ b/nautilus_core/model/src/types/quantity.rs @@ -38,14 +38,14 @@ impl Quantity { pub fn new(value: f64, precision: u8) -> Self { correctness::f64_in_range_inclusive(value, QUANTITY_MIN, QUANTITY_MAX, "`Quantity` value"); - Self { + Quantity { raw: f64_to_fixed_u64(value, precision), precision, } } pub fn from_raw(raw: u64, precision: u8) -> Self { - Self { raw, precision } + Quantity { raw, precision } } pub fn is_zero(&self) -> bool { diff --git a/nautilus_core/persistence/src/parquet/writer.rs b/nautilus_core/persistence/src/parquet/writer.rs index e043415ab5d3..98f048eb2b68 100644 --- a/nautilus_core/persistence/src/parquet/writer.rs +++ b/nautilus_core/persistence/src/parquet/writer.rs @@ -42,7 +42,7 @@ where W: Write, { #[must_use] - pub fn new(w: W, schema: Schema) -> ParquetWriter { + pub fn new(w: W, schema: Schema) -> Self { let options = WriteOptions { write_statistics: true, compression: CompressionOptions::Uncompressed, From b875555675338ede5958be4494ab3f8e0bca48b7 Mon Sep 17 00:00:00 2001 From: Chris Sellers Date: Tue, 14 Feb 2023 18:34:24 +1100 Subject: [PATCH 55/81] Update dependencies --- nautilus_core/Cargo.lock | 37 ++++++------------------------------- nautilus_core/Cargo.toml | 2 +- poetry.lock | 2 ++ 3 files changed, 9 insertions(+), 32 deletions(-) diff --git a/nautilus_core/Cargo.lock b/nautilus_core/Cargo.lock index a062998ecb67..bef272cb6312 100644 --- a/nautilus_core/Cargo.lock +++ b/nautilus_core/Cargo.lock @@ -135,18 +135,6 @@ version = "1.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" -[[package]] -name = "bstr" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba3569f383e8f1598449f1a423e72e99569137b47740b1da11ef19af3d5c3223" -dependencies = [ - "lazy_static", - "memchr", - "regex-automata", - "serde", -] - [[package]] name = "bumpalo" version = "3.12.0" @@ -401,13 +389,12 @@ checksum = "7a81dae078cea95a014a339291cec439d2f232ebe854a9d672b796c6afafa9b7" [[package]] name = "csv" -version = "1.1.6" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22813a6dc45b335f9bade10bf7271dc477e81113e89eb251a0bc2a8a81c536e1" +checksum = "af91f40b7355f82b0a891f50e70399475945bb0b0da4f1700ce60761c9d3e359" dependencies = [ - "bstr", "csv-core", - "itoa 0.4.8", + "itoa", "ryu", "serde", ] @@ -557,9 +544,9 @@ checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" [[package]] name = "fastrand" -version = "1.8.0" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7a407cfaa3385c4ae6b23e84623d48c2798d06e3e6a1878f7f59f17b3f86499" +checksum = "e51093e27b0797c359783294ca4f0a911c270184cb10f85783b118614a1501be" dependencies = [ "instant", ] @@ -796,12 +783,6 @@ dependencies = [ "either", ] -[[package]] -name = "itoa" -version = "0.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b71991ff56294aa922b450139ee08b3bfc70982c6b2c7562771375cf73542dd4" - [[package]] name = "itoa" version = "1.0.5" @@ -1310,12 +1291,6 @@ dependencies = [ "regex-syntax", ] -[[package]] -name = "regex-automata" -version = "0.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132" - [[package]] name = "regex-syntax" version = "0.6.28" @@ -1456,7 +1431,7 @@ version = "1.0.93" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cad406b69c91885b5107daf2c29572f6c8cdb3c66826821e286c533490c0bc76" dependencies = [ - "itoa 1.0.5", + "itoa", "ryu", "serde", ] diff --git a/nautilus_core/Cargo.toml b/nautilus_core/Cargo.toml index 615d82f59ec6..91513c118756 100644 --- a/nautilus_core/Cargo.toml +++ b/nautilus_core/Cargo.toml @@ -17,7 +17,7 @@ documentation = "https://docs.nautilustrader.io" [workspace.dependencies] chrono = "0.4.22" -pyo3 = "0.18.0" +pyo3 = "0.18.1" rand = "0.8.5" rust-fsm = "0.6.1" strum = { version = "0.24.1", features = ["derive"] } diff --git a/poetry.lock b/poetry.lock index 8f9994f296ac..f055337cf3ab 100644 --- a/poetry.lock +++ b/poetry.lock @@ -596,6 +596,8 @@ files = [ {file = "cryptography-39.0.1-cp36-abi3-win32.whl", hash = "sha256:fe913f20024eb2cb2f323e42a64bdf2911bb9738a15dba7d3cce48151034e3a8"}, {file = "cryptography-39.0.1-cp36-abi3-win_amd64.whl", hash = "sha256:ced4e447ae29ca194449a3f1ce132ded8fcab06971ef5f618605aacaa612beac"}, {file = "cryptography-39.0.1-pp38-pypy38_pp73-macosx_10_12_x86_64.whl", hash = "sha256:807ce09d4434881ca3a7594733669bd834f5b2c6d5c7e36f8c00f691887042ad"}, + {file = "cryptography-39.0.1-pp38-pypy38_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c5caeb8188c24888c90b5108a441c106f7faa4c4c075a2bcae438c6e8ca73cef"}, + {file = "cryptography-39.0.1-pp38-pypy38_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:4789d1e3e257965e960232345002262ede4d094d1a19f4d3b52e48d4d8f3b885"}, {file = "cryptography-39.0.1-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:96f1157a7c08b5b189b16b47bc9db2332269d6680a196341bf30046330d15388"}, {file = "cryptography-39.0.1-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:e422abdec8b5fa8462aa016786680720d78bdce7a30c652b7fadf83a4ba35336"}, {file = "cryptography-39.0.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:b0afd054cd42f3d213bf82c629efb1ee5f22eba35bf0eec88ea9ea7304f511a2"}, From a98b480dea61f3bf362ff60ce8f27933bd1be2cf Mon Sep 17 00:00:00 2001 From: Chris Sellers Date: Tue, 14 Feb 2023 19:07:16 +1100 Subject: [PATCH 56/81] Add missing Condition check --- nautilus_trader/model/instruments/base.pyx | 1 + 1 file changed, 1 insertion(+) diff --git a/nautilus_trader/model/instruments/base.pyx b/nautilus_trader/model/instruments/base.pyx index e880173fca58..c981ce3a7660 100644 --- a/nautilus_trader/model/instruments/base.pyx +++ b/nautilus_trader/model/instruments/base.pyx @@ -518,6 +518,7 @@ cdef class Instrument(Data): """ Condition.not_none(quantity, "quantity") + Condition.not_none(price, "price") if self.is_inverse: if inverse_as_quote: From f07205c43a3a466f82593c44eafb769610e3b743 Mon Sep 17 00:00:00 2001 From: Chris Sellers Date: Tue, 14 Feb 2023 21:06:35 +1100 Subject: [PATCH 57/81] Fix handling of MarketToLimit orders --- nautilus_trader/backtest/matching_engine.pyx | 2 +- nautilus_trader/risk/engine.pyx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/nautilus_trader/backtest/matching_engine.pyx b/nautilus_trader/backtest/matching_engine.pyx index a6653e5d529b..a6942c260d73 100644 --- a/nautilus_trader/backtest/matching_engine.pyx +++ b/nautilus_trader/backtest/matching_engine.pyx @@ -1888,7 +1888,7 @@ cdef class OrderMatchingEngine: strategy_id=order.strategy_id, instrument_id=order.instrument_id, client_order_id=order.client_order_id, - venue_order_id=self._generate_venue_order_id(), + venue_order_id=order.venue_order_id or self._generate_venue_order_id(), account_id=order.account_id or self._account_ids[order.trader_id], event_id=UUID4(), ts_event=timestamp, diff --git a/nautilus_trader/risk/engine.pyx b/nautilus_trader/risk/engine.pyx index dcc879deaa0d..b0351b8a7fcf 100644 --- a/nautilus_trader/risk/engine.pyx +++ b/nautilus_trader/risk/engine.pyx @@ -721,7 +721,7 @@ cdef class RiskEngine(Component): Money cum_notional_sell = None double xrate for order in orders: - if order.order_type == OrderType.MARKET: + if order.order_type == OrderType.MARKET or order.order_type == OrderType.MARKET_TO_LIMIT: if last_px is None: # Determine entry price last_quote = self._cache.quote_tick(instrument.id) From 15afd28aa07408727c20049b042937e72b1767fb Mon Sep 17 00:00:00 2001 From: Chris Sellers Date: Wed, 15 Feb 2023 06:16:15 +1100 Subject: [PATCH 58/81] Improve error messages for build --- build.py | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/build.py b/build.py index 26580b46b70c..3f4edffcdda9 100644 --- a/build.py +++ b/build.py @@ -234,14 +234,9 @@ def _get_clang_version() -> str: return output except subprocess.CalledProcessError as e: raise RuntimeError( + "You are installing from source which requires the Clang compiler to be installed.\n" f"Error running clang: {e.stderr.decode()}", ) from e - except FileNotFoundError as e: - if "clang" in e.strerror: - raise RuntimeError( - "You are installing from source which requires the Clang compiler to be installed.", - ) from e - raise def _get_rustc_version() -> str: @@ -256,15 +251,10 @@ def _get_rustc_version() -> str: return output except subprocess.CalledProcessError as e: raise RuntimeError( + "You are installing from source which requires the Rust compiler to " + "be installed.\nFind more information at https://www.rust-lang.org/tools/install\n" f"Error running rustc: {e.stderr.decode()}", ) from e - except FileNotFoundError as e: - if "rustc" in e.strerror: - raise RuntimeError( - "You are installing from source which requires the Rust compiler to " - "be installed. Find more information at https://www.rust-lang.org/tools/install", - ) from e - raise def build(pyo3_only=False) -> None: From cbe3736243769191a144c66a96b79f85705216ac Mon Sep 17 00:00:00 2001 From: Chris Sellers Date: Wed, 15 Feb 2023 06:18:11 +1100 Subject: [PATCH 59/81] Revert attempt to request actual fees --- .../adapters/binance/futures/providers.py | 46 ++++++++++--------- .../adapters/binance/spot/providers.py | 21 ++++----- 2 files changed, 34 insertions(+), 33 deletions(-) diff --git a/nautilus_trader/adapters/binance/futures/providers.py b/nautilus_trader/adapters/binance/futures/providers.py index 2c1ea3604767..de17c03878c9 100644 --- a/nautilus_trader/adapters/binance/futures/providers.py +++ b/nautilus_trader/adapters/binance/futures/providers.py @@ -103,18 +103,20 @@ async def load_all_async(self, filters: Optional[dict] = None) -> None: exchange_info = await self._http_market.query_futures_exchange_info() for symbol_info in exchange_info.symbols: - if self._client.base_url.__contains__("testnet.binancefuture.com"): - fee = None - else: - try: - # Get current commission rates for the symbol - fee = await self._http_wallet.query_futures_commission_rate(symbol_info.symbol) - except BinanceClientError as e: - self._log.error( - "Cannot load instruments: API key authentication failed " - f"(this is needed to fetch the applicable account fee tier). {e.message}", - ) - return + fee: Optional[BinanceFuturesCommissionRate] = None + # TODO(cs): This won't work for 174 instruments, we'll have to pre-request these + # in some other way. + # if not self._client.base_url.__contains__("testnet.binancefuture.com"): + # try: + # # Get current commission rates for the symbol + # fee = await self._http_wallet.query_futures_commission_rate(symbol_info.symbol) + # print(fee) + # except BinanceClientError as e: + # self._log.error( + # "Cannot load instruments: API key authentication failed " + # f"(this is needed to fetch the applicable account fee tier). {e.message}", + # ) + # return self._parse_instrument( symbol_info=symbol_info, @@ -151,15 +153,17 @@ async def load_ids_async( for symbol in symbols: fee: Optional[BinanceFuturesCommissionRate] = None - if not self._client.base_url.__contains__("testnet.binancefuture.com"): - try: - # Get current commission rates for the symbol - fee = await self._http_wallet.query_futures_commission_rate(symbol) - except BinanceClientError as e: - self._log.error( - "Cannot load instruments: API key authentication failed " - f"(this is needed to fetch the applicable account fee tier). {e.message}", - ) + # TODO(cs): This won't work for 174 instruments, we'll have to pre-request these + # in some other way. + # if not self._client.base_url.__contains__("testnet.binancefuture.com"): + # try: + # # Get current commission rates for the symbol + # fee = await self._http_wallet.query_futures_commission_rate(symbol) + # except BinanceClientError as e: + # self._log.error( + # "Cannot load instruments: API key authentication failed " + # f"(this is needed to fetch the applicable account fee tier). {e.message}", + # ) self._parse_instrument( symbol_info=symbol_info_dict[symbol], diff --git a/nautilus_trader/adapters/binance/spot/providers.py b/nautilus_trader/adapters/binance/spot/providers.py index eacd8dce72cc..99e4dba22038 100644 --- a/nautilus_trader/adapters/binance/spot/providers.py +++ b/nautilus_trader/adapters/binance/spot/providers.py @@ -96,18 +96,15 @@ async def load_all_async(self, filters: Optional[dict] = None) -> None: self._log.info(f"Loading all instruments{filters_str}") # Get current commission rates - if self._client.base_url.__contains__("testnet.binance.vision"): - fees_dict: dict[str, BinanceSpotTradeFee] = {} - else: - try: - response = await self._http_wallet.query_spot_trade_fees() - fees_dict = {fee.symbol: fee for fee in response} - except BinanceClientError as e: - self._log.error( - "Cannot load instruments: API key authentication failed " - f"(this is needed to fetch the applicable account fee tier). {e.message}", - ) - return + try: + response = await self._http_wallet.query_spot_trade_fees() + fees_dict: dict[str, BinanceSpotTradeFee] = {fee.symbol: fee for fee in response} + except BinanceClientError as e: + self._log.error( + "Cannot load instruments: API key authentication failed " + f"(this is needed to fetch the applicable account fee tier). {e.message}", + ) + return # Get exchange info for all assets exchange_info = await self._http_market.query_spot_exchange_info() From 53ce41ac223acb2e31e9eb64b25b51df9d5de3a6 Mon Sep 17 00:00:00 2001 From: Chris Sellers Date: Wed, 15 Feb 2023 06:26:36 +1100 Subject: [PATCH 60/81] Update release notes --- RELEASES.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/RELEASES.md b/RELEASES.md index da6bf846eb46..7ecfac44c581 100644 --- a/RELEASES.md +++ b/RELEASES.md @@ -9,6 +9,7 @@ Released on TBD (UTC). - Renamed `build_time_bars_with_no_updates` -> `time_bars_build_with_no_updates` (consistency with new param) ### Enhancements +- Complete overhaul and improvements to Binance adapter(s), thanks @poshcoe - Added Binance aggregated trades functionality with `use_agg_trade_ticks`, thanks @poshcoe - Added `time_bars_timestamp_on_close` option for configurable bar timestamping (True by default) - Implemented optimized logger using Rust MPSC channel and separate thread @@ -16,7 +17,8 @@ Released on TBD (UTC). ### Fixes - Fixed registration of `SimulationModule` (and refine `Actor` base registration) -- Fixed loading of previously emulated and transformed orders (handles second `OrderInitialized`) +- Fixed loading of previously emulated and transformed orders (handles transforming `OrderInitialized` event) +- Fixed handling of `MARKET_TO_LIMIT` orders in matching and risk engines, thanks @martinsaip --- From 7c179683d1174a0685cc9ad932a96de7525cc318 Mon Sep 17 00:00:00 2001 From: Chris Sellers Date: Wed, 15 Feb 2023 07:05:12 +1100 Subject: [PATCH 61/81] Enhance OrderFactory and OrderList --- RELEASES.md | 5 + nautilus_trader/common/factories.pxd | 8 +- nautilus_trader/common/factories.pyx | 59 ++++++++++- nautilus_trader/model/orders/list.pyx | 5 +- nautilus_trader/trading/strategy.pyx | 2 +- .../common/test_common_factories.py | 98 +++++++++++++++++++ tests/unit_tests/model/test_model_orders.py | 3 + 7 files changed, 176 insertions(+), 4 deletions(-) create mode 100644 tests/unit_tests/common/test_common_factories.py diff --git a/RELEASES.md b/RELEASES.md index 7ecfac44c581..ed89cad7dbdc 100644 --- a/RELEASES.md +++ b/RELEASES.md @@ -7,13 +7,18 @@ Released on TBD (UTC). - Renamed `OrderFactory.bracket` param `post_only_entry` -> `entry_post_only` (consistency with other params) - Renamed `OrderFactory.bracket` param `post_only_tp` -> `tp_post_only` (consistency with other params) - Renamed `build_time_bars_with_no_updates` -> `time_bars_build_with_no_updates` (consistency with new param) +- Renamed `OrderFactory.set_order_count` -> `set_client_order_id_count` (clarity) ### Enhancements - Complete overhaul and improvements to Binance adapter(s), thanks @poshcoe - Added Binance aggregated trades functionality with `use_agg_trade_ticks`, thanks @poshcoe - Added `time_bars_timestamp_on_close` option for configurable bar timestamping (True by default) +- Added `OrderFactory.generate_client_order_id()` (calls internal generator) +- Added `OrderFactory.generate_order_list_id()` (calls internal generator) +- Added `OrderFactory.create_list(...)` as easier method for creating order lists - Implemented optimized logger using Rust MPSC channel and separate thread - Expose and improve `MatchingEngine` public API for custom functionality +- Added `__len__` implementation for `OrderList` (returns length of orders) ### Fixes - Fixed registration of `SimulationModule` (and refine `Actor` base registration) diff --git a/nautilus_trader/common/factories.pxd b/nautilus_trader/common/factories.pxd index 035e5e795b27..36db1654649f 100644 --- a/nautilus_trader/common/factories.pxd +++ b/nautilus_trader/common/factories.pxd @@ -26,7 +26,9 @@ from nautilus_trader.model.enums_c cimport OrderType from nautilus_trader.model.enums_c cimport TimeInForce from nautilus_trader.model.enums_c cimport TrailingOffsetType from nautilus_trader.model.enums_c cimport TriggerType +from nautilus_trader.model.identifiers cimport ClientOrderId from nautilus_trader.model.identifiers cimport InstrumentId +from nautilus_trader.model.identifiers cimport OrderListId from nautilus_trader.model.identifiers cimport StrategyId from nautilus_trader.model.identifiers cimport TraderId from nautilus_trader.model.objects cimport Price @@ -53,10 +55,14 @@ cdef class OrderFactory: cdef readonly StrategyId strategy_id """The order factories trading strategy ID.\n\n:returns: `StrategyId`""" - cpdef void set_order_id_count(self, int count) except * + cpdef void set_client_order_id_count(self, int count) except * cpdef void set_order_list_id_count(self, int count) except * + cpdef ClientOrderId generate_client_order_id(self) except * + cpdef OrderListId generate_order_list_id(self) except * cpdef void reset(self) except * + cpdef OrderList create_list(self, list orders) + cpdef MarketOrder market( self, InstrumentId instrument_id, diff --git a/nautilus_trader/common/factories.pyx b/nautilus_trader/common/factories.pyx index ba6333e0f8db..ca318f6b4c1c 100644 --- a/nautilus_trader/common/factories.pyx +++ b/nautilus_trader/common/factories.pyx @@ -18,6 +18,7 @@ from cpython.datetime cimport datetime from nautilus_trader.common.clock cimport Clock from nautilus_trader.common.generators cimport ClientOrderIdGenerator from nautilus_trader.common.generators cimport OrderListIdGenerator +from nautilus_trader.core.correctness cimport Condition from nautilus_trader.core.datetime cimport dt_to_unix_nanos from nautilus_trader.core.uuid cimport UUID4 from nautilus_trader.model.enums_c cimport ContingencyType @@ -96,7 +97,7 @@ cdef class OrderFactory: initial_count=initial_order_list_id_count, ) - cpdef void set_order_id_count(self, int count) except *: + cpdef void set_client_order_id_count(self, int count) except *: """ Set the internal order ID generator count to the given count. @@ -128,6 +129,32 @@ cdef class OrderFactory: """ self._order_list_id_generator.set_count(count) + cpdef ClientOrderId generate_client_order_id(self) except *: + """ + Generate and return a new client order ID. + + The identifier will be the next in the logical sequence. + + Returns + ------- + ClientOrderId + + """ + return self._order_id_generator.generate() + + cpdef OrderListId generate_order_list_id(self) except *: + """ + Generate and return a new order list ID. + + The identifier will be the next in the logical sequence. + + Returns + ------- + OrderListId + + """ + return self._order_list_id_generator.generate() + cpdef void reset(self) except *: """ Reset the order factory. @@ -137,6 +164,36 @@ cdef class OrderFactory: self._order_id_generator.reset() self._order_list_id_generator.reset() + cpdef OrderList create_list(self, list orders): + """ + Return a new order list containing the given `orders`. + + Parameters + ---------- + orders : list[Order] + The orders for the list. + + Returns + ------- + OrderList + + Raises + ------ + ValueError + If `orders` is empty. + + Notes + ----- + The order at index 0 in the list will be considered the 'first' order. + + """ + Condition.not_empty(orders, "orders") + + return OrderList( + order_list_id=self._order_list_id_generator.generate(), + orders=orders, + ) + cpdef MarketOrder market( self, InstrumentId instrument_id, diff --git a/nautilus_trader/model/orders/list.pyx b/nautilus_trader/model/orders/list.pyx index bf29f0ca618b..6a532f4884ca 100644 --- a/nautilus_trader/model/orders/list.pyx +++ b/nautilus_trader/model/orders/list.pyx @@ -20,7 +20,7 @@ from nautilus_trader.model.orders.base cimport Order cdef class OrderList: """ - Represents a list of bulk or related parent-child contingent orders. + Represents a list of bulk or related contingent orders. Parameters ---------- @@ -59,6 +59,9 @@ cdef class OrderList: def __hash__(self) -> int: return hash(self.id) + def __len__(self) -> int: + return len(self.orders) + def __repr__(self) -> str: return ( f"OrderList(" diff --git a/nautilus_trader/trading/strategy.pyx b/nautilus_trader/trading/strategy.pyx index 84101157c858..cf816ecc226a 100644 --- a/nautilus_trader/trading/strategy.pyx +++ b/nautilus_trader/trading/strategy.pyx @@ -299,7 +299,7 @@ cdef class Strategy(Actor): cdef int order_id_count = len(client_order_ids) cdef int order_list_id_count = len(order_list_ids) - self.order_factory.set_order_id_count(order_id_count) + self.order_factory.set_client_order_id_count(order_id_count) self.order_factory.set_order_list_id_count(order_list_id_count) self.log.info(f"Set ClientOrderIdGenerator client_order_id count to {order_id_count}.") self.log.info(f"Set ClientOrderIdGenerator order_list_id count to {order_list_id_count}.") diff --git a/tests/unit_tests/common/test_common_factories.py b/tests/unit_tests/common/test_common_factories.py new file mode 100644 index 000000000000..55d81b157f71 --- /dev/null +++ b/tests/unit_tests/common/test_common_factories.py @@ -0,0 +1,98 @@ +# ------------------------------------------------------------------------------------------------- +# Copyright (C) 2015-2023 Nautech Systems Pty Ltd. All rights reserved. +# https://nautechsystems.io +# +# Licensed under the GNU Lesser General Public License Version 3.0 (the "License"); +# You may not use this file except in compliance with the License. +# You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ------------------------------------------------------------------------------------------------- + +from nautilus_trader.backtest.data.providers import TestInstrumentProvider +from nautilus_trader.common.clock import TestClock +from nautilus_trader.common.factories import OrderFactory +from nautilus_trader.model.enums import OrderSide +from nautilus_trader.model.identifiers import ClientOrderId +from nautilus_trader.model.identifiers import OrderListId +from nautilus_trader.model.objects import Quantity +from nautilus_trader.test_kit.stubs.identifiers import TestIdStubs + + +ETHUSDT_PERP_BINANCE = TestInstrumentProvider.ethusdt_perp_binance() + + +class TestOrderFactory: + def setup(self): + # Fixture Setup + self.trader_id = TestIdStubs.trader_id() + self.strategy_id = TestIdStubs.strategy_id() + self.account_id = TestIdStubs.account_id() + + self.order_factory = OrderFactory( + trader_id=self.trader_id, + strategy_id=self.strategy_id, + clock=TestClock(), + ) + + def test_generate_client_order_id(self): + # Arrange, Act + result = self.order_factory.generate_client_order_id() + + # Assert + assert result == ClientOrderId("O-19700101-000-001-1") + + def test_generate_order_list_id(self): + # Arrange, Act + result = self.order_factory.generate_order_list_id() + + # Assert + assert result == OrderListId("OL-19700101-000-001-1") + + def test_set_client_order_id_count(self): + # Arrange, Act + self.order_factory.set_client_order_id_count(1) + + result = self.order_factory.generate_client_order_id() + + # Assert + assert result == ClientOrderId("O-19700101-000-001-2") + + def test_set_order_list_id_count(self): + # Arrange, Act + self.order_factory.set_order_list_id_count(1) + + result = self.order_factory.generate_order_list_id() + + # Assert + assert result == OrderListId("OL-19700101-000-001-2") + + def test_create_list(self): + # Arrange + order1 = self.order_factory.market( + ETHUSDT_PERP_BINANCE.id, + OrderSide.BUY, + Quantity.from_str("1.5"), + ) + + order2 = self.order_factory.market( + ETHUSDT_PERP_BINANCE.id, + OrderSide.BUY, + Quantity.from_str("1.5"), + ) + + order3 = self.order_factory.market( + ETHUSDT_PERP_BINANCE.id, + OrderSide.BUY, + Quantity.from_str("1.5"), + ) + + # Act + order_list = self.order_factory.create_list([order1, order2, order3]) + + # Assert + assert len(order_list) == 3 diff --git a/tests/unit_tests/model/test_model_orders.py b/tests/unit_tests/model/test_model_orders.py index 6162adf50c3f..ccc44ef946d3 100644 --- a/tests/unit_tests/model/test_model_orders.py +++ b/tests/unit_tests/model/test_model_orders.py @@ -1263,6 +1263,7 @@ def test_bracket_market_entry_order_list(self): # Assert assert bracket.id == OrderListId("OL-19700101-000-001-1") assert bracket.instrument_id == AUDUSD_SIM.id + assert len(bracket) == 3 assert len(bracket.orders) == 3 assert bracket.orders[0].order_type == OrderType.MARKET assert bracket.orders[1].order_type == OrderType.STOP_MARKET @@ -1317,6 +1318,7 @@ def test_bracket_limit_entry_order_list(self): # Assert assert bracket.id == OrderListId("OL-19700101-000-001-1") assert bracket.instrument_id == AUDUSD_SIM.id + assert len(bracket) == 3 assert len(bracket.orders) == 3 assert bracket.orders[0].order_type == OrderType.LIMIT assert bracket.orders[1].order_type == OrderType.STOP_MARKET @@ -1374,6 +1376,7 @@ def test_bracket_stop_limit_entry_stop_limit_tp_order_list(self): # Assert assert bracket.id == OrderListId("OL-19700101-000-001-1") assert bracket.instrument_id == AUDUSD_SIM.id + assert len(bracket) == 3 assert len(bracket.orders) == 3 assert bracket.orders[0].order_type == OrderType.LIMIT_IF_TOUCHED assert bracket.orders[1].order_type == OrderType.STOP_MARKET From 373fa29d23ed50b6f3be9db85d7703a3d7b52e69 Mon Sep 17 00:00:00 2001 From: Chris Sellers Date: Wed, 15 Feb 2023 08:40:27 +1100 Subject: [PATCH 62/81] Improve TradingNode async commands --- RELEASES.md | 9 ++-- examples/live/betfair.py | 2 +- examples/live/betfair_sandbox.py | 2 +- examples/live/binance_futures_market_maker.py | 2 +- .../live/binance_futures_testnet_ema_cross.py | 2 +- ...nance_futures_testnet_ema_cross_bracket.py | 2 +- ...es_testnet_ema_cross_with_trailing_stop.py | 2 +- examples/live/binance_spot_ema_cross.py | 2 +- examples/live/binance_spot_market_maker.py | 2 +- .../live/binance_spot_testnet_ema_cross.py | 2 +- .../interactive_brokers_book_imbalance.py | 2 +- examples/live/interactive_brokers_example.py | 2 +- nautilus_trader/backtest/__main__.py | 15 +++++++ nautilus_trader/live/__main__.py | 20 ++++++++- nautilus_trader/live/node.py | 45 +++++++++++-------- .../integration_tests/live/test_live_node.py | 17 +++---- 16 files changed, 86 insertions(+), 42 deletions(-) diff --git a/RELEASES.md b/RELEASES.md index ed89cad7dbdc..22b68d0d9aff 100644 --- a/RELEASES.md +++ b/RELEASES.md @@ -7,7 +7,8 @@ Released on TBD (UTC). - Renamed `OrderFactory.bracket` param `post_only_entry` -> `entry_post_only` (consistency with other params) - Renamed `OrderFactory.bracket` param `post_only_tp` -> `tp_post_only` (consistency with other params) - Renamed `build_time_bars_with_no_updates` -> `time_bars_build_with_no_updates` (consistency with new param) -- Renamed `OrderFactory.set_order_count` -> `set_client_order_id_count` (clarity) +- Renamed `OrderFactory.set_order_count()` -> `set_client_order_id_count()` (clarity) +- Renamed `TradingNode.start()` to `TradingNode.run()` ### Enhancements - Complete overhaul and improvements to Binance adapter(s), thanks @poshcoe @@ -16,14 +17,16 @@ Released on TBD (UTC). - Added `OrderFactory.generate_client_order_id()` (calls internal generator) - Added `OrderFactory.generate_order_list_id()` (calls internal generator) - Added `OrderFactory.create_list(...)` as easier method for creating order lists +- Added `__len__` implementation for `OrderList` (returns length of orders) - Implemented optimized logger using Rust MPSC channel and separate thread - Expose and improve `MatchingEngine` public API for custom functionality -- Added `__len__` implementation for `OrderList` (returns length of orders) +- Exposed `TradingNode.run_async()` for easier running from async context +- Exposed `TradingNode.stop_async()` for easier stopping from async context ### Fixes - Fixed registration of `SimulationModule` (and refine `Actor` base registration) - Fixed loading of previously emulated and transformed orders (handles transforming `OrderInitialized` event) -- Fixed handling of `MARKET_TO_LIMIT` orders in matching and risk engines, thanks @martinsaip +- Fixed handling of `MARKET_TO_LIMIT` orders in matching and risk engines, thanks for reporting @martinsaip --- diff --git a/examples/live/betfair.py b/examples/live/betfair.py index 533026a4212d..92b1b8bc261e 100644 --- a/examples/live/betfair.py +++ b/examples/live/betfair.py @@ -111,7 +111,7 @@ async def main(market_id: str): node.build() try: - node.start() + node.run() await asyncio.gather(*asyncio.all_tasks()) except Exception as e: print(e) diff --git a/examples/live/betfair_sandbox.py b/examples/live/betfair_sandbox.py index 2e4d07d6f072..f224b01bc517 100644 --- a/examples/live/betfair_sandbox.py +++ b/examples/live/betfair_sandbox.py @@ -96,7 +96,7 @@ async def main(market_id: str): SandboxExecutionClient.INSTRUMENTS = instruments node.build() - node.start() + node.run() # try: # node.start() # except Exception as ex: diff --git a/examples/live/binance_futures_market_maker.py b/examples/live/binance_futures_market_maker.py index 73a6aa1d7661..34a5c60a5498 100644 --- a/examples/live/binance_futures_market_maker.py +++ b/examples/live/binance_futures_market_maker.py @@ -93,6 +93,6 @@ # Stop and dispose of the node with SIGINT/CTRL+C if __name__ == "__main__": try: - node.start() + node.run() finally: node.dispose() diff --git a/examples/live/binance_futures_testnet_ema_cross.py b/examples/live/binance_futures_testnet_ema_cross.py index d594dedef39a..21c22d025690 100644 --- a/examples/live/binance_futures_testnet_ema_cross.py +++ b/examples/live/binance_futures_testnet_ema_cross.py @@ -102,6 +102,6 @@ # Stop and dispose of the node with SIGINT/CTRL+C if __name__ == "__main__": try: - node.start() + node.run() finally: node.dispose() diff --git a/examples/live/binance_futures_testnet_ema_cross_bracket.py b/examples/live/binance_futures_testnet_ema_cross_bracket.py index 0956df5b56bc..ea9184d3d3ba 100644 --- a/examples/live/binance_futures_testnet_ema_cross_bracket.py +++ b/examples/live/binance_futures_testnet_ema_cross_bracket.py @@ -104,6 +104,6 @@ # Stop and dispose of the node with SIGINT/CTRL+C if __name__ == "__main__": try: - node.start() + node.run() finally: node.dispose() diff --git a/examples/live/binance_futures_testnet_ema_cross_with_trailing_stop.py b/examples/live/binance_futures_testnet_ema_cross_with_trailing_stop.py index d63b739dc8c0..420a31718bbe 100644 --- a/examples/live/binance_futures_testnet_ema_cross_with_trailing_stop.py +++ b/examples/live/binance_futures_testnet_ema_cross_with_trailing_stop.py @@ -105,6 +105,6 @@ # Stop and dispose of the node with SIGINT/CTRL+C if __name__ == "__main__": try: - node.start() + node.run() finally: node.dispose() diff --git a/examples/live/binance_spot_ema_cross.py b/examples/live/binance_spot_ema_cross.py index 4bd15406afd7..e0f7c41a41ce 100644 --- a/examples/live/binance_spot_ema_cross.py +++ b/examples/live/binance_spot_ema_cross.py @@ -102,6 +102,6 @@ # Stop and dispose of the node with SIGINT/CTRL+C if __name__ == "__main__": try: - node.start() + node.run() finally: node.dispose() diff --git a/examples/live/binance_spot_market_maker.py b/examples/live/binance_spot_market_maker.py index 12e524e7d251..45a3547f3965 100644 --- a/examples/live/binance_spot_market_maker.py +++ b/examples/live/binance_spot_market_maker.py @@ -101,6 +101,6 @@ # Stop and dispose of the node with SIGINT/CTRL+C if __name__ == "__main__": try: - node.start() + node.run() finally: node.dispose() diff --git a/examples/live/binance_spot_testnet_ema_cross.py b/examples/live/binance_spot_testnet_ema_cross.py index f755c208ebc9..24ff6e0ca10c 100644 --- a/examples/live/binance_spot_testnet_ema_cross.py +++ b/examples/live/binance_spot_testnet_ema_cross.py @@ -102,6 +102,6 @@ # Stop and dispose of the node with SIGINT/CTRL+C if __name__ == "__main__": try: - node.start() + node.run() finally: node.dispose() diff --git a/examples/live/interactive_brokers_book_imbalance.py b/examples/live/interactive_brokers_book_imbalance.py index cd354eb2e1d5..709fede2f627 100644 --- a/examples/live/interactive_brokers_book_imbalance.py +++ b/examples/live/interactive_brokers_book_imbalance.py @@ -102,6 +102,6 @@ # Stop and dispose of the node with SIGINT/CTRL+C if __name__ == "__main__": try: - node.start() + node.run() finally: node.dispose() diff --git a/examples/live/interactive_brokers_example.py b/examples/live/interactive_brokers_example.py index fa0704d25cb0..1ac766486c21 100644 --- a/examples/live/interactive_brokers_example.py +++ b/examples/live/interactive_brokers_example.py @@ -102,6 +102,6 @@ # Stop and dispose of the node with SIGINT/CTRL+C if __name__ == "__main__": try: - node.start() + node.run() finally: node.dispose() diff --git a/nautilus_trader/backtest/__main__.py b/nautilus_trader/backtest/__main__.py index fc649eaf9de9..5a9ae3d37815 100644 --- a/nautilus_trader/backtest/__main__.py +++ b/nautilus_trader/backtest/__main__.py @@ -1,3 +1,18 @@ +# ------------------------------------------------------------------------------------------------- +# Copyright (C) 2015-2023 Nautech Systems Pty Ltd. All rights reserved. +# https://nautechsystems.io +# +# Licensed under the GNU Lesser General Public License Version 3.0 (the "License"); +# You may not use this file except in compliance with the License. +# You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ------------------------------------------------------------------------------------------------- + from typing import Optional import click diff --git a/nautilus_trader/live/__main__.py b/nautilus_trader/live/__main__.py index 4d018852ba0d..c79aa6c9b7ff 100644 --- a/nautilus_trader/live/__main__.py +++ b/nautilus_trader/live/__main__.py @@ -1,3 +1,18 @@ +# ------------------------------------------------------------------------------------------------- +# Copyright (C) 2015-2023 Nautech Systems Pty Ltd. All rights reserved. +# https://nautechsystems.io +# +# Licensed under the GNU Lesser General Public License Version 3.0 (the "License"); +# You may not use this file except in compliance with the License. +# You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ------------------------------------------------------------------------------------------------- + from typing import Optional import click @@ -25,7 +40,10 @@ def main( node = TradingNode(config=config) node.build() if start: - node.start() + try: + node.run() + finally: + node.dispose() if __name__ == "__main__": diff --git a/nautilus_trader/live/node.py b/nautilus_trader/live/node.py index da4538aea9f2..5bf7e5eb024d 100644 --- a/nautilus_trader/live/node.py +++ b/nautilus_trader/live/node.py @@ -269,21 +269,15 @@ def build(self) -> None: self._builder.build_exec_clients(self._config.exec_clients) self._is_built = True - def start(self) -> None: + def run(self) -> None: """ - Start the trading node. + Start and run the trading node. """ - if not self._is_built: - raise RuntimeError( - "The trading nodes clients have not been built. " - "Run `node.build()` prior to start.", - ) - try: if self.kernel.loop.is_running(): - self.kernel.loop.create_task(self._run()) + self.kernel.loop.create_task(self.run_async()) else: - self.kernel.loop.run_until_complete(self._run()) + self.kernel.loop.run_until_complete(self.run_async()) except RuntimeError as e: self.kernel.log.exception("Error on run", e) @@ -291,16 +285,15 @@ def stop(self) -> None: """ Stop the trading node gracefully. - After a specified delay the internal `Trader` residuals will be checked. - - If save strategy is specified then strategy states will then be saved. + After a specified delay the internal `Trader` residual state will be checked. + If save strategy is configured, then strategy states will be saved. """ try: if self.kernel.loop.is_running(): - self.kernel.loop.create_task(self._stop()) + self.kernel.loop.create_task(self.stop_async()) else: - self.kernel.loop.run_until_complete(self._stop()) + self.kernel.loop.run_until_complete(self.stop_async()) except RuntimeError as e: self.kernel.log.exception("Error on stop", e) @@ -309,7 +302,6 @@ def dispose(self) -> None: # noqa C901 'TradingNode.dispose' is too complex (11 Dispose of the trading node. Gracefully shuts down the executor and event loop. - """ try: timeout = self.kernel.clock.utc_now() + timedelta( @@ -393,8 +385,17 @@ def _loop_sig_handler(self, sig) -> None: self.kernel.log.warning(f"Received {sig!s}, shutting down...") self.stop() - async def _run(self) -> None: + async def run_async(self) -> None: + """ + Start and run the trading node asynchronously. + """ try: + if not self._is_built: + raise RuntimeError( + "The trading nodes clients have not been built. " + "Run `node.build()` prior to start.", + ) + self.kernel.log.info("STARTING...") self._is_running = True @@ -520,8 +521,14 @@ async def _await_portfolio_initialized(self) -> bool: return True # Portfolio initialized - async def _stop(self) -> None: - self._is_stopping = True + async def stop_async(self) -> None: + """ + Stop the trading node gracefully, asynchronously. + + After a specified delay the internal `Trader` residual state will be checked. + + If save strategy is configured, then strategy states will be saved. + """ self.kernel.log.info("STOPPING...") if self.kernel.trader.is_running: diff --git a/tests/integration_tests/live/test_live_node.py b/tests/integration_tests/live/test_live_node.py index 549734f81268..8597058e2166 100644 --- a/tests/integration_tests/live/test_live_node.py +++ b/tests/integration_tests/live/test_live_node.py @@ -202,11 +202,12 @@ def test_build_called_twice_raises_runtime_error(self): node.build() node.build() - def test_start_when_not_built_raises_runtime_error(self): + @pytest.mark.asyncio + async def test_run_when_not_built_raises_runtime_error(self): # Arrange, # Act with pytest.raises(RuntimeError): node = TradingNode() - node.start() + await node.run_async() def test_add_data_client_factory(self): # Arrange @@ -238,7 +239,7 @@ async def test_build_with_multiple_clients(self): node.add_exec_client_factory("BETFAIR", BetfairLiveExecClientFactory) node.build() - node.start() + node.run() await asyncio.sleep(1) # assert self.node.kernel.data_engine.registered_clients @@ -255,7 +256,7 @@ async def test_register_log_sink(self): node.kernel.add_log_sink(sink.append) node.build() - node.start() + node.run() await asyncio.sleep(1) # Assert: Log record received @@ -264,13 +265,13 @@ async def test_register_log_sink(self): assert sink[-1]["instance_id"] == node.instance_id.value @pytest.mark.asyncio - async def test_start(self): + async def test_run(self): # Arrange node = TradingNode() node.build() # Act - node.start() + node.run() await asyncio.sleep(2) # Assert @@ -281,7 +282,7 @@ async def test_stop(self): # Arrange node = TradingNode() node.build() - node.start() + node.run() await asyncio.sleep(2) # Allow node to start # Act @@ -306,7 +307,7 @@ async def test_dispose(self, monkeypatch): node.build() node.kernel.cache.add_instrument(TestInstrumentProvider.ethusdt_perp_binance()) - node.start() + node.run() await asyncio.sleep(2) # Allow node to start node.stop() From 9321a4191cd59b99a46b44c517638ea35d5b2fa7 Mon Sep 17 00:00:00 2001 From: Chris Sellers Date: Wed, 15 Feb 2023 08:47:23 +1100 Subject: [PATCH 63/81] Fix live example --- examples/live/binance_futures_testnet_market_maker.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/live/binance_futures_testnet_market_maker.py b/examples/live/binance_futures_testnet_market_maker.py index 0416014db5de..bfd74feb761d 100644 --- a/examples/live/binance_futures_testnet_market_maker.py +++ b/examples/live/binance_futures_testnet_market_maker.py @@ -101,6 +101,6 @@ # Stop and dispose of the node with SIGINT/CTRL+C if __name__ == "__main__": try: - node.start() + node.run() finally: node.dispose() From 55e1af2c10da905408eb76869d642c65302dfefe Mon Sep 17 00:00:00 2001 From: Bradley McElroy Date: Wed, 15 Feb 2023 08:49:52 +1100 Subject: [PATCH 64/81] Move one to common/functions.py (#1007) --- .../adapters/betfair/parsing/streaming.py | 2 +- nautilus_trader/adapters/betfair/util.py | 22 +------------------ .../adapters/interactive_brokers/providers.py | 2 +- nautilus_trader/common/functions.py | 17 ++++++++++++++ 4 files changed, 20 insertions(+), 23 deletions(-) create mode 100644 nautilus_trader/common/functions.py diff --git a/nautilus_trader/adapters/betfair/parsing/streaming.py b/nautilus_trader/adapters/betfair/parsing/streaming.py index 5e11768ef2f1..f09898975ae9 100644 --- a/nautilus_trader/adapters/betfair/parsing/streaming.py +++ b/nautilus_trader/adapters/betfair/parsing/streaming.py @@ -46,7 +46,7 @@ from nautilus_trader.adapters.betfair.parsing.constants import STRICT_MARKET_DATA_HANDLING from nautilus_trader.adapters.betfair.parsing.requests import parse_handicap from nautilus_trader.adapters.betfair.util import hash_market_trade -from nautilus_trader.adapters.betfair.util import one +from nautilus_trader.common.functions import one from nautilus_trader.core.datetime import millis_to_nanos from nautilus_trader.execution.reports import TradeReport from nautilus_trader.model.data.tick import TradeTick diff --git a/nautilus_trader/adapters/betfair/util.py b/nautilus_trader/adapters/betfair/util.py index e32bd4db7f39..b0cba583488f 100644 --- a/nautilus_trader/adapters/betfair/util.py +++ b/nautilus_trader/adapters/betfair/util.py @@ -16,6 +16,7 @@ from typing import Optional import msgspec +from betfair_parser.spec.streaming import MCM from betfair_parser.spec.streaming import STREAM_DECODER from nautilus_trader.common.providers import InstrumentProvider @@ -70,28 +71,7 @@ def hash_market_trade(timestamp: int, price: float, volume: float): return f"{str(timestamp)[:-6]}{price}{str(volume)}" -def one(iterable): - it = iter(iterable) - - try: - first_value = next(it) - except StopIteration as e: - raise (ValueError("too few items in iterable (expected 1)")) from e - - try: - second_value = next(it) - except StopIteration: - pass - else: - msg = f"Expected exactly one item in iterable, but got {first_value}, {second_value}, and perhaps more." - raise ValueError(msg) - - return first_value - - def historical_instrument_provider_loader(instrument_provider, line): - from betfair_parser.spec.streaming import MCM - from nautilus_trader.adapters.betfair.providers import make_instruments if instrument_provider is None: diff --git a/nautilus_trader/adapters/interactive_brokers/providers.py b/nautilus_trader/adapters/interactive_brokers/providers.py index 4b4db0b47f47..bde82f39bf0e 100644 --- a/nautilus_trader/adapters/interactive_brokers/providers.py +++ b/nautilus_trader/adapters/interactive_brokers/providers.py @@ -26,9 +26,9 @@ from ib_insync import ContractDetails from ib_insync import Future -from nautilus_trader.adapters.betfair.util import one from nautilus_trader.adapters.interactive_brokers.common import IB_VENUE from nautilus_trader.adapters.interactive_brokers.parsing.instruments import parse_instrument +from nautilus_trader.common.functions import one from nautilus_trader.common.logging import Logger from nautilus_trader.common.providers import InstrumentProvider from nautilus_trader.config import InstrumentProviderConfig diff --git a/nautilus_trader/common/functions.py b/nautilus_trader/common/functions.py new file mode 100644 index 000000000000..0dfacce12db8 --- /dev/null +++ b/nautilus_trader/common/functions.py @@ -0,0 +1,17 @@ +def one(iterable): + it = iter(iterable) + + try: + first_value = next(it) + except StopIteration as e: + raise (ValueError("too few items in iterable (expected 1)")) from e + + try: + second_value = next(it) + except StopIteration: + pass + else: + msg = f"Expected exactly one item in iterable, but got {first_value}, {second_value}, and perhaps more." + raise ValueError(msg) + + return first_value From 2c627e83e4f4f257a370a28942b95d9928c0f844 Mon Sep 17 00:00:00 2001 From: Chris Sellers Date: Wed, 15 Feb 2023 11:38:27 +1100 Subject: [PATCH 65/81] Add header --- nautilus_trader/common/functions.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/nautilus_trader/common/functions.py b/nautilus_trader/common/functions.py index 0dfacce12db8..8a816cccc080 100644 --- a/nautilus_trader/common/functions.py +++ b/nautilus_trader/common/functions.py @@ -1,3 +1,19 @@ +# ------------------------------------------------------------------------------------------------- +# Copyright (C) 2015-2023 Nautech Systems Pty Ltd. All rights reserved. +# https://nautechsystems.io +# +# Licensed under the GNU Lesser General Public License Version 3.0 (the "License"); +# You may not use this file except in compliance with the License. +# You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ------------------------------------------------------------------------------------------------- + + def one(iterable): it = iter(iterable) From f051e9090a23a5c7a02ea4549ef325a9a0d02cb1 Mon Sep 17 00:00:00 2001 From: Chris Sellers Date: Wed, 15 Feb 2023 12:05:24 +1100 Subject: [PATCH 66/81] Add thousand separators --- .../test_backtest_acceptance.py | 53 ++++++++++++------- 1 file changed, 34 insertions(+), 19 deletions(-) diff --git a/tests/acceptance_tests/test_backtest_acceptance.py b/tests/acceptance_tests/test_backtest_acceptance.py index 3f82325555e3..2400fc8670d9 100644 --- a/tests/acceptance_tests/test_backtest_acceptance.py +++ b/tests/acceptance_tests/test_backtest_acceptance.py @@ -116,7 +116,10 @@ def test_run_ema_cross_strategy(self): # Assert - Should return expected PnL assert strategy.fast_ema.count == 2689 assert self.engine.iteration == 115044 - assert self.engine.portfolio.account(self.venue).balance_total(USD) == Money(996798.21, USD) + assert self.engine.portfolio.account(self.venue).balance_total(USD) == Money( + 996_798.21, + USD, + ) def test_rerun_ema_cross_strategy_returns_identical_performance(self): # Arrange @@ -176,7 +179,7 @@ def test_run_multiple_strategies(self): assert strategy2.fast_ema.count == 2689 assert self.engine.iteration == 115044 assert self.engine.portfolio.account(self.venue).balance_total(USD) == Money( - 1023449.90, + 1_023_449.90, USD, ) @@ -240,7 +243,10 @@ def test_run_ema_cross_with_five_minute_bar_spec(self): # Assert assert strategy.fast_ema.count == 8353 assert self.engine.iteration == 120468 - assert self.engine.portfolio.account(self.venue).balance_total(GBP) == Money(961323.91, GBP) + assert self.engine.portfolio.account(self.venue).balance_total(GBP) == Money( + 961_323.91, + GBP, + ) def test_run_ema_cross_stop_entry_trail_strategy(self): # Arrange @@ -266,7 +272,7 @@ def test_run_ema_cross_stop_entry_trail_strategy(self): assert strategy.fast_ema.count == 8353 assert self.engine.iteration == 120468 assert self.engine.portfolio.account(self.venue).balance_total(GBP) == Money( - 1009220.90, + 1_009_220.90, GBP, ) @@ -293,7 +299,10 @@ def test_run_ema_cross_stop_entry_trail_strategy_with_emulation(self): # Assert - Should return expected PnL assert strategy.fast_ema.count == 41761 assert self.engine.iteration == 120468 - assert self.engine.portfolio.account(self.venue).balance_total(GBP) == Money(963946.75, GBP) + assert self.engine.portfolio.account(self.venue).balance_total(GBP) == Money( + 963_946.75, + GBP, + ) class TestBacktestAcceptanceTestsGBPUSDBarsExternal: @@ -374,7 +383,7 @@ def test_run_ema_cross_with_minute_bar_spec(self): assert strategy.fast_ema.count == 30117 assert self.engine.iteration == 60234 ending_balance = self.engine.portfolio.account(self.venue).balance_total(USD) - assert ending_balance == Money(1088115.65, USD) + assert ending_balance == Money(1_088_115.65, USD) class TestBacktestAcceptanceTestsBTCUSDTSpotNoCashPositions: @@ -436,12 +445,12 @@ def test_run_ema_cross_with_minute_trade_bars(self): self.engine.run() # Assert - assert strategy.fast_ema.count == 10000 - assert self.engine.iteration == 10000 + assert strategy.fast_ema.count == 10_000 + assert self.engine.iteration == 10_000 btc_ending_balance = self.engine.portfolio.account(self.venue).balance_total(BTC) usdt_ending_balance = self.engine.portfolio.account(self.venue).balance_total(USDT) assert btc_ending_balance == Money(9.57200000, BTC) - assert usdt_ending_balance == Money(10017571.74970600, USDT) + assert usdt_ending_balance == Money(10_017_571.74970600, USDT) def test_run_ema_cross_with_trade_ticks_from_bar_data(self): # Arrange @@ -471,13 +480,13 @@ def test_run_ema_cross_with_trade_ticks_from_bar_data(self): self.engine.run() # Assert - assert len(ticks) == 40000 - assert strategy.fast_ema.count == 10000 - assert self.engine.iteration == 40000 + assert len(ticks) == 40_000 + assert strategy.fast_ema.count == 10_000 + assert self.engine.iteration == 40_000 btc_ending_balance = self.engine.portfolio.account(self.venue).balance_total(BTC) usdt_ending_balance = self.engine.portfolio.account(self.venue).balance_total(USDT) assert btc_ending_balance == Money(9.57200000, BTC) - assert usdt_ending_balance == Money(10017571.72928400, USDT) + assert usdt_ending_balance == Money(10_017_571.72928400, USDT) class TestBacktestAcceptanceTestsAUDUSD: @@ -532,8 +541,11 @@ def test_run_ema_cross_with_minute_bar_spec(self): # Assert assert strategy.fast_ema.count == 1771 - assert self.engine.iteration == 100000 - assert self.engine.portfolio.account(self.venue).balance_total(AUD) == Money(991360.15, AUD) + assert self.engine.iteration == 100_000 + assert self.engine.portfolio.account(self.venue).balance_total(AUD) == Money( + 991_360.15, + AUD, + ) def test_run_ema_cross_with_tick_bar_spec(self): # Arrange @@ -551,9 +563,12 @@ def test_run_ema_cross_with_tick_bar_spec(self): self.engine.run() # Assert - assert strategy.fast_ema.count == 1000 - assert self.engine.iteration == 100000 - assert self.engine.portfolio.account(self.venue).balance_total(AUD) == Money(996361.60, AUD) + assert strategy.fast_ema.count == 1_000 + assert self.engine.iteration == 100_000 + assert self.engine.portfolio.account(self.venue).balance_total(AUD) == Money( + 996_361.60, + AUD, + ) class TestBacktestAcceptanceTestsETHUSDT: @@ -607,7 +622,7 @@ def test_run_ema_cross_with_tick_bar_spec(self): assert strategy.fast_ema.count == 279 assert self.engine.iteration == 69806 expected_commission = Money(127.56763570, USDT) - expected_usdt = Money(998869.96375810, USDT) + expected_usdt = Money(998_869.96375810, USDT) assert self.engine.portfolio.account(self.venue).commission(USDT) == expected_commission assert self.engine.portfolio.account(self.venue).balance_total(USDT) == expected_usdt From c8dfa786433c7f1419d84b7e8e0f27f26864a636 Mon Sep 17 00:00:00 2001 From: Chris Sellers Date: Wed, 15 Feb 2023 12:38:51 +1100 Subject: [PATCH 67/81] Make frozen config classes consistent --- nautilus_trader/config/backtest.py | 6 +++--- nautilus_trader/config/common.py | 30 +++++++++++++++--------------- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/nautilus_trader/config/backtest.py b/nautilus_trader/config/backtest.py index 850cbd522989..8cd7784e7c11 100644 --- a/nautilus_trader/config/backtest.py +++ b/nautilus_trader/config/backtest.py @@ -34,7 +34,7 @@ from nautilus_trader.model.identifiers import ClientId -class BacktestVenueConfig(NautilusConfig): +class BacktestVenueConfig(NautilusConfig, frozen=True): """ Represents a venue configuration for one specific backtest engine. """ @@ -54,7 +54,7 @@ class BacktestVenueConfig(NautilusConfig): modules: Optional[list[ImportableConfig]] = None -class BacktestDataConfig(NautilusConfig): +class BacktestDataConfig(NautilusConfig, frozen=True): """ Represents the data configuration for one specific backtest run. """ @@ -197,7 +197,7 @@ class BacktestEngineConfig(NautilusKernelConfig): run_analysis: bool = True -class BacktestRunConfig(NautilusConfig): +class BacktestRunConfig(NautilusConfig, frozen=True): """ Represents the configuration for one specific backtest run. diff --git a/nautilus_trader/config/common.py b/nautilus_trader/config/common.py index 90e3d7c03600..db28a4625552 100644 --- a/nautilus_trader/config/common.py +++ b/nautilus_trader/config/common.py @@ -107,7 +107,7 @@ def validate(self) -> bool: return bool(msgspec.json.decode(self.json(), type=self.__class__)) -class CacheConfig(NautilusConfig): +class CacheConfig(NautilusConfig, frozen=True): """ Configuration for ``Cache`` instances. @@ -123,7 +123,7 @@ class CacheConfig(NautilusConfig): bar_capacity: PositiveInt = 1000 -class CacheDatabaseConfig(NautilusConfig): +class CacheDatabaseConfig(NautilusConfig, frozen=True): """ Configuration for ``CacheDatabase`` instances. @@ -154,7 +154,7 @@ class CacheDatabaseConfig(NautilusConfig): flush: bool = False -class InstrumentProviderConfig(NautilusConfig): +class InstrumentProviderConfig(NautilusConfig, frozen=True): """ Configuration for ``InstrumentProvider`` instances. @@ -190,7 +190,7 @@ def __hash__(self): log_warnings: bool = True -class DataEngineConfig(NautilusConfig): +class DataEngineConfig(NautilusConfig, frozen=True): """ Configuration for ``DataEngine`` instances. @@ -213,7 +213,7 @@ class DataEngineConfig(NautilusConfig): debug: bool = False -class RiskEngineConfig(NautilusConfig): +class RiskEngineConfig(NautilusConfig, frozen=True): """ Configuration for ``RiskEngine`` instances. @@ -242,7 +242,7 @@ class RiskEngineConfig(NautilusConfig): debug: bool = False -class ExecEngineConfig(NautilusConfig): +class ExecEngineConfig(NautilusConfig, frozen=True): """ Configuration for ``ExecutionEngine`` instances. @@ -261,13 +261,13 @@ class ExecEngineConfig(NautilusConfig): debug: bool = False -class OrderEmulatorConfig(NautilusConfig): +class OrderEmulatorConfig(NautilusConfig, frozen=True): """ Configuration for ``OrderEmulator`` instances. """ -class StreamingConfig(NautilusConfig): +class StreamingConfig(NautilusConfig, frozen=True): """ Configuration for streaming live or backtest runs to the catalog in feather format. @@ -304,7 +304,7 @@ def as_catalog(self) -> ParquetDataCatalog: ) -class ActorConfig(NautilusConfig, kw_only=True): +class ActorConfig(NautilusConfig, kw_only=True, frozen=True): """ The base model for all actor configurations. @@ -319,7 +319,7 @@ class ActorConfig(NautilusConfig, kw_only=True): component_id: Optional[str] = None -class ImportableActorConfig(NautilusConfig): +class ImportableActorConfig(NautilusConfig, frozen=True): """ Represents an actor configuration for one specific backtest run. @@ -369,7 +369,7 @@ def create(config: ImportableActorConfig): return actor_cls(config=config_cls(**config.config)) -class StrategyConfig(NautilusConfig, kw_only=True): +class StrategyConfig(NautilusConfig, kw_only=True, frozen=True): """ The base model for all trading strategy configurations. @@ -390,7 +390,7 @@ class StrategyConfig(NautilusConfig, kw_only=True): oms_type: Optional[str] = None -class ImportableStrategyConfig(NautilusConfig): +class ImportableStrategyConfig(NautilusConfig, frozen=True): """ Represents a trading strategy configuration for one specific backtest run. @@ -440,7 +440,7 @@ def create(config: ImportableStrategyConfig): return strategy_cls(config=config_cls(**config.config)) -class NautilusKernelConfig(NautilusConfig): +class NautilusKernelConfig(NautilusConfig, frozen=True): """ Configuration for core system ``NautilusKernel`` instances. @@ -499,7 +499,7 @@ class NautilusKernelConfig(NautilusConfig): bypass_logging: bool = False -class ImportableFactoryConfig(NautilusConfig): +class ImportableFactoryConfig(NautilusConfig, frozen=True): """ Represents an importable (json) Factory config. """ @@ -511,7 +511,7 @@ def create(self): return cls() -class ImportableConfig(NautilusConfig): +class ImportableConfig(NautilusConfig, frozen=True): """ Represents an importable (typically live data or execution) client configuration. """ From 386c9219d567d8569b4b084c6acfe11140372841 Mon Sep 17 00:00:00 2001 From: Chris Sellers Date: Wed, 15 Feb 2023 12:55:59 +1100 Subject: [PATCH 68/81] Reduce log noise in tests --- nautilus_trader/common/logging.pyx | 2 +- .../infrastructure/test_cache_database.py | 1 + .../backtest/test_backtest_engine.py | 18 ++++-- .../backtest/test_backtest_exchange.py | 1 + .../backtest/test_backtest_exchange_bitmex.py | 2 +- ...est_exchange_bracket_if_touched_entries.py | 1 + .../test_backtest_exchange_contingencies.py | 1 + .../backtest/test_backtest_exchange_l2_mbp.py | 1 + .../test_backtest_exchange_stop_limits.py | 1 + .../test_backtest_exchange_trailing_stops.py | 1 + .../backtest/test_backtest_matching_engine.py | 2 + .../backtest/test_backtest_modules.py | 4 +- .../unit_tests/backtest/test_backtest_node.py | 2 +- .../unit_tests/cache/test_cache_execution.py | 2 +- tests/unit_tests/common/test_common_actor.py | 1 + tests/unit_tests/common/test_common_config.py | 26 -------- .../unit_tests/common/test_common_logging.py | 59 +++++++++++++++---- .../common/test_common_providers.py | 2 +- .../common/test_common_throttler.py | 4 +- tests/unit_tests/data/test_data_engine.py | 1 + .../execution/test_execution_client.py | 2 +- .../execution/test_execution_emulator.py | 1 + .../execution/test_execution_emulator_list.py | 1 + .../execution/test_execution_engine.py | 1 + .../unit_tests/live/test_live_data_client.py | 2 +- .../unit_tests/live/test_live_data_engine.py | 2 +- .../live/test_live_execution_engine.py | 1 + .../unit_tests/live/test_live_risk_engine.py | 2 +- tests/unit_tests/msgbus/test_msgbus_bus.py | 2 +- .../persistence/test_streaming_engine.py | 1 + tests/unit_tests/portfolio/test_portfolio.py | 2 +- tests/unit_tests/risk/test_risk_engine.py | 1 + .../trading/test_trading_strategy.py | 1 + .../unit_tests/trading/test_trading_trader.py | 2 +- 34 files changed, 96 insertions(+), 57 deletions(-) diff --git a/nautilus_trader/common/logging.pyx b/nautilus_trader/common/logging.pyx index bce6dedb5e0d..b6f9e187f1b2 100644 --- a/nautilus_trader/common/logging.pyx +++ b/nautilus_trader/common/logging.pyx @@ -79,7 +79,7 @@ cdef class Logger: rate_limit : int, default 100_000 The maximum messages per second which can be flushed to stdout or stderr. bypass : bool - If the logger should be bypassed. + If the log output is bypassed. """ def __init__( diff --git a/tests/integration_tests/infrastructure/test_cache_database.py b/tests/integration_tests/infrastructure/test_cache_database.py index e42c98a1576f..b02d1bf2db5d 100644 --- a/tests/integration_tests/infrastructure/test_cache_database.py +++ b/tests/integration_tests/infrastructure/test_cache_database.py @@ -84,6 +84,7 @@ def setup(self): self.logger = Logger( clock=self.clock, level_stdout=LogLevel.DEBUG, + bypass=True, ) self.trader_id = TestIdStubs.trader_id() diff --git a/tests/unit_tests/backtest/test_backtest_engine.py b/tests/unit_tests/backtest/test_backtest_engine.py index f8f6e538729b..9ee3ef652e31 100644 --- a/tests/unit_tests/backtest/test_backtest_engine.py +++ b/tests/unit_tests/backtest/test_backtest_engine.py @@ -107,7 +107,7 @@ def teardown(self): self.engine.dispose() def test_initialization(self): - engine = BacktestEngine() + engine = BacktestEngine(BacktestEngineConfig(bypass_logging=True)) # Arrange, Act, Assert assert engine.run_id is None @@ -191,6 +191,7 @@ def test_backtest_engine_multiple_runs(self): engine = self.create_engine( config=BacktestEngineConfig( streaming=StreamingConfig(catalog_path="/", fs_protocol="memory"), + bypass_logging=True, ), ) engine.add_strategy(strategy) @@ -204,6 +205,7 @@ def test_backtest_engine_strategy_timestamps(self): engine = self.create_engine( config=BacktestEngineConfig( streaming=StreamingConfig(catalog_path="/", fs_protocol="memory"), + bypass_logging=True, ), ) engine.add_strategy(strategy) @@ -224,8 +226,12 @@ def test_set_instance_id(self): instance_id = UUID4().value # Act - engine = self.create_engine(config=BacktestEngineConfig(instance_id=instance_id)) - engine2 = self.create_engine(config=BacktestEngineConfig()) # Engine sets instance id + engine = self.create_engine( + config=BacktestEngineConfig(instance_id=instance_id, bypass_logging=True), + ) + engine2 = self.create_engine( + config=BacktestEngineConfig(bypass_logging=True), + ) # Engine sets instance id # Assert assert engine.kernel.instance_id.value == instance_id @@ -235,7 +241,7 @@ def test_set_instance_id(self): class TestBacktestEngineData: def setup(self): # Fixture Setup - self.engine = BacktestEngine() + self.engine = BacktestEngine(BacktestEngineConfig(bypass_logging=True)) self.engine.add_venue( venue=Venue("BINANCE"), oms_type=OmsType.NETTING, @@ -288,7 +294,7 @@ def test_add_generic_data_adds_to_engine(self, capsys): def test_add_instrument_when_no_venue_raises_exception(self): # Arrange - engine = BacktestEngine() + engine = BacktestEngine(BacktestEngineConfig(bypass_logging=True)) # Act, Assert with pytest.raises(InvalidConfiguration): @@ -513,7 +519,7 @@ class TestBacktestWithAddedBars: def setup(self): # Fixture Setup config = BacktestEngineConfig( - bypass_logging=False, + bypass_logging=True, run_analysis=False, ) self.engine = BacktestEngine(config=config) diff --git a/tests/unit_tests/backtest/test_backtest_exchange.py b/tests/unit_tests/backtest/test_backtest_exchange.py index 5df4b1fe0ac0..15823c64a176 100644 --- a/tests/unit_tests/backtest/test_backtest_exchange.py +++ b/tests/unit_tests/backtest/test_backtest_exchange.py @@ -77,6 +77,7 @@ def setup(self): self.logger = Logger( clock=self.clock, level_stdout=LogLevel.DEBUG, + bypass=True, ) self.trader_id = TestIdStubs.trader_id() diff --git a/tests/unit_tests/backtest/test_backtest_exchange_bitmex.py b/tests/unit_tests/backtest/test_backtest_exchange_bitmex.py index ddc4ef07af94..d53a196731ae 100644 --- a/tests/unit_tests/backtest/test_backtest_exchange_bitmex.py +++ b/tests/unit_tests/backtest/test_backtest_exchange_bitmex.py @@ -56,7 +56,7 @@ def setup(self): self.strategies = [MockStrategy(TestDataStubs.bartype_btcusdt_binance_100tick_last())] self.clock = TestClock() - self.logger = Logger(self.clock) + self.logger = Logger(self.clock, bypass=True) self.trader_id = TestIdStubs.trader_id() diff --git a/tests/unit_tests/backtest/test_backtest_exchange_bracket_if_touched_entries.py b/tests/unit_tests/backtest/test_backtest_exchange_bracket_if_touched_entries.py index f3dccb001f7b..1ace3b9259c4 100644 --- a/tests/unit_tests/backtest/test_backtest_exchange_bracket_if_touched_entries.py +++ b/tests/unit_tests/backtest/test_backtest_exchange_bracket_if_touched_entries.py @@ -60,6 +60,7 @@ def setup(self): self.logger = Logger( clock=self.clock, level_stdout=LogLevel.DEBUG, + bypass=True, ) self.trader_id = TestIdStubs.trader_id() diff --git a/tests/unit_tests/backtest/test_backtest_exchange_contingencies.py b/tests/unit_tests/backtest/test_backtest_exchange_contingencies.py index ee8a2f1ee10f..d5fb8f01c55d 100644 --- a/tests/unit_tests/backtest/test_backtest_exchange_contingencies.py +++ b/tests/unit_tests/backtest/test_backtest_exchange_contingencies.py @@ -56,6 +56,7 @@ def setup(self): self.logger = Logger( clock=self.clock, level_stdout=LogLevel.INFO, + bypass=True, ) self.trader_id = TestIdStubs.trader_id() diff --git a/tests/unit_tests/backtest/test_backtest_exchange_l2_mbp.py b/tests/unit_tests/backtest/test_backtest_exchange_l2_mbp.py index 93b643cf5d61..5b9fc62e0d41 100644 --- a/tests/unit_tests/backtest/test_backtest_exchange_l2_mbp.py +++ b/tests/unit_tests/backtest/test_backtest_exchange_l2_mbp.py @@ -62,6 +62,7 @@ def setup(self): self.logger = Logger( clock=self.clock, level_stdout=LogLevel.DEBUG, + bypass=True, ) self.trader_id = TestIdStubs.trader_id() diff --git a/tests/unit_tests/backtest/test_backtest_exchange_stop_limits.py b/tests/unit_tests/backtest/test_backtest_exchange_stop_limits.py index b29593d4727a..28c4c80630d2 100644 --- a/tests/unit_tests/backtest/test_backtest_exchange_stop_limits.py +++ b/tests/unit_tests/backtest/test_backtest_exchange_stop_limits.py @@ -58,6 +58,7 @@ def setup(self): self.logger = Logger( clock=self.clock, level_stdout=LogLevel.DEBUG, + bypass=True, ) self.trader_id = TestIdStubs.trader_id() diff --git a/tests/unit_tests/backtest/test_backtest_exchange_trailing_stops.py b/tests/unit_tests/backtest/test_backtest_exchange_trailing_stops.py index 714d1e91c60e..2c0564282838 100644 --- a/tests/unit_tests/backtest/test_backtest_exchange_trailing_stops.py +++ b/tests/unit_tests/backtest/test_backtest_exchange_trailing_stops.py @@ -63,6 +63,7 @@ def setup(self): self.logger = Logger( clock=self.clock, level_stdout=LogLevel.DEBUG, + bypass=True, ) self.trader_id = TestIdStubs.trader_id() diff --git a/tests/unit_tests/backtest/test_backtest_matching_engine.py b/tests/unit_tests/backtest/test_backtest_matching_engine.py index 62c53381f0d2..c25b044645ac 100644 --- a/tests/unit_tests/backtest/test_backtest_matching_engine.py +++ b/tests/unit_tests/backtest/test_backtest_matching_engine.py @@ -12,6 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. # ------------------------------------------------------------------------------------------------- + from nautilus_trader.backtest.data.providers import TestInstrumentProvider from nautilus_trader.backtest.matching_engine import OrderMatchingEngine from nautilus_trader.backtest.models import FillModel @@ -42,6 +43,7 @@ def setup(self): self.logger = Logger( clock=self.clock, level_stdout=LogLevel.DEBUG, + bypass=True, ) self.trader_id = TestIdStubs.trader_id() diff --git a/tests/unit_tests/backtest/test_backtest_modules.py b/tests/unit_tests/backtest/test_backtest_modules.py index e31fb585a183..5e6a3fbfbd27 100644 --- a/tests/unit_tests/backtest/test_backtest_modules.py +++ b/tests/unit_tests/backtest/test_backtest_modules.py @@ -12,6 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. # ------------------------------------------------------------------------------------------------- + import pandas as pd from nautilus_trader.backtest.data.providers import TestDataProvider @@ -23,6 +24,7 @@ from nautilus_trader.backtest.modules import SimulationModule from nautilus_trader.backtest.modules import SimulationModuleConfig from nautilus_trader.common.logging import LoggerAdapter +from nautilus_trader.config.backtest import BacktestEngineConfig from nautilus_trader.model.currencies import USD from nautilus_trader.model.enums import AccountType from nautilus_trader.model.enums import OmsType @@ -35,7 +37,7 @@ class TestSimulationModules: def create_engine(self, modules: list): - engine = BacktestEngine() + engine = BacktestEngine(BacktestEngineConfig(bypass_logging=True)) engine.add_venue( venue=Venue("SIM"), oms_type=OmsType.HEDGING, diff --git a/tests/unit_tests/backtest/test_backtest_node.py b/tests/unit_tests/backtest/test_backtest_node.py index 76eac97987d0..3384ec07a408 100644 --- a/tests/unit_tests/backtest/test_backtest_node.py +++ b/tests/unit_tests/backtest/test_backtest_node.py @@ -64,7 +64,7 @@ def setup(self): ] self.backtest_configs = [ BacktestRunConfig( - engine=BacktestEngineConfig(strategies=self.strategies), + engine=BacktestEngineConfig(strategies=self.strategies, bypass_logging=True), venues=[self.venue_config], data=[self.data_config], ), diff --git a/tests/unit_tests/cache/test_cache_execution.py b/tests/unit_tests/cache/test_cache_execution.py index 5b08e1b8a63e..8504e8a84d4a 100644 --- a/tests/unit_tests/cache/test_cache_execution.py +++ b/tests/unit_tests/cache/test_cache_execution.py @@ -72,7 +72,7 @@ class TestCache: def setup(self): # Fixture Setup self.clock = TestClock() - self.logger = Logger(self.clock) + self.logger = Logger(self.clock, bypass=True) self.trader_id = TestIdStubs.trader_id() self.account_id = TestIdStubs.account_id() diff --git a/tests/unit_tests/common/test_common_actor.py b/tests/unit_tests/common/test_common_actor.py index cd2e2c0609ca..9a051cfee9e2 100644 --- a/tests/unit_tests/common/test_common_actor.py +++ b/tests/unit_tests/common/test_common_actor.py @@ -68,6 +68,7 @@ def setup(self): self.logger = Logger( clock=self.clock, level_stdout=LogLevel.DEBUG, + bypass=True, ) self.trader_id = TestIdStubs.trader_id() diff --git a/tests/unit_tests/common/test_common_config.py b/tests/unit_tests/common/test_common_config.py index fccf04c8b77f..d4a18a04ff66 100644 --- a/tests/unit_tests/common/test_common_config.py +++ b/tests/unit_tests/common/test_common_config.py @@ -13,38 +13,12 @@ # limitations under the License. # ------------------------------------------------------------------------------------------------- -import pkgutil - -import pytest - -from nautilus_trader.config import ActorConfig from nautilus_trader.config import ActorFactory from nautilus_trader.config import ImportableActorConfig from nautilus_trader.test_kit.mocks.actors import MockActor class TestActorFactory: - @pytest.mark.skip(reason="Not implemented anymore") - def test_create_from_source(self): - # Arrange - config = ActorConfig( - component_id="MyActor", - ) - - source = pkgutil.get_data("tests.test_kit", "mocks.py") - importable = ImportableActorConfig( - module="MockActor", - source=source, - config=config, - ) - - # Act - strategy = ActorFactory.create(importable) - - # Assert - assert isinstance(strategy, MockActor) - assert repr(config) == "ActorConfig()" - def test_create_from_path(self): # Arrange config = dict( diff --git a/tests/unit_tests/common/test_common_logging.py b/tests/unit_tests/common/test_common_logging.py index 78335f36c6c0..0a1b4e8b2d99 100644 --- a/tests/unit_tests/common/test_common_logging.py +++ b/tests/unit_tests/common/test_common_logging.py @@ -70,7 +70,11 @@ def test_log_level_from_str(self, string, expected): class TestLoggerTests: def test_log_debug_messages_to_console(self): # Arrange - logger = Logger(clock=TestClock(), level_stdout=LogLevel.DEBUG) + logger = Logger( + clock=TestClock(), + level_stdout=LogLevel.DEBUG, + bypass=True, + ) logger_adapter = LoggerAdapter(component_name="TEST_LOGGER", logger=logger) # Act @@ -81,7 +85,11 @@ def test_log_debug_messages_to_console(self): def test_log_info_messages_to_console(self): # Arrange - logger = Logger(clock=TestClock(), level_stdout=LogLevel.INFO) + logger = Logger( + clock=TestClock(), + level_stdout=LogLevel.INFO, + bypass=True, + ) logger_adapter = LoggerAdapter(component_name="TEST_LOGGER", logger=logger) # Act @@ -92,7 +100,11 @@ def test_log_info_messages_to_console(self): def test_log_info_with_annotation_sends_to_stdout(self): # Arrange - logger = Logger(clock=TestClock(), level_stdout=LogLevel.INFO) + logger = Logger( + clock=TestClock(), + level_stdout=LogLevel.INFO, + bypass=True, + ) logger_adapter = LoggerAdapter(component_name="TEST_LOGGER", logger=logger) annotations = {"my_tag": "something"} @@ -105,7 +117,11 @@ def test_log_info_with_annotation_sends_to_stdout(self): def test_log_info_messages_to_console_with_blue_colour(self): # Arrange - logger = Logger(clock=TestClock(), level_stdout=LogLevel.INFO) + logger = Logger( + clock=TestClock(), + level_stdout=LogLevel.INFO, + bypass=True, + ) logger_adapter = LoggerAdapter(component_name="TEST_LOGGER", logger=logger) # Act @@ -116,7 +132,11 @@ def test_log_info_messages_to_console_with_blue_colour(self): def test_log_info_messages_to_console_with_green_colour(self): # Arrange - logger = Logger(clock=TestClock(), level_stdout=LogLevel.INFO) + logger = Logger( + clock=TestClock(), + level_stdout=LogLevel.INFO, + bypass=True, + ) logger_adapter = LoggerAdapter(component_name="TEST_LOGGER", logger=logger) # Act @@ -127,7 +147,11 @@ def test_log_info_messages_to_console_with_green_colour(self): def test_log_warning_messages_to_console(self): # Arrange - logger = Logger(clock=TestClock(), level_stdout=LogLevel.WARNING) + logger = Logger( + clock=TestClock(), + level_stdout=LogLevel.WARNING, + bypass=True, + ) logger_adapter = LoggerAdapter(component_name="TEST_LOGGER", logger=logger) # Act @@ -138,7 +162,11 @@ def test_log_warning_messages_to_console(self): def test_log_error_messages_to_console(self): # Arrange - logger = Logger(clock=TestClock(), level_stdout=LogLevel.ERROR) + logger = Logger( + clock=TestClock(), + level_stdout=LogLevel.ERROR, + bypass=True, + ) logger_adapter = LoggerAdapter(component_name="TEST_LOGGER", logger=logger) # Act @@ -149,7 +177,11 @@ def test_log_error_messages_to_console(self): def test_log_critical_messages_to_console(self): # Arrange - logger = Logger(clock=TestClock(), level_stdout=LogLevel.CRITICAL) + logger = Logger( + clock=TestClock(), + level_stdout=LogLevel.CRITICAL, + bypass=True, + ) logger_adapter = LoggerAdapter(component_name="TEST_LOGGER", logger=logger) # Act @@ -160,7 +192,11 @@ def test_log_critical_messages_to_console(self): def test_log_exception_messages_to_console(self): # Arrange - logger = Logger(clock=TestClock(), level_stdout=LogLevel.CRITICAL) + logger = Logger( + clock=TestClock(), + level_stdout=LogLevel.CRITICAL, + bypass=True, + ) logger_adapter = LoggerAdapter(component_name="TEST_LOGGER", logger=logger) # Act @@ -172,7 +208,10 @@ def test_log_exception_messages_to_console(self): def test_register_sink_sends_records_to_sink(self): # Arrange sink = [] - logger = Logger(clock=TestClock(), level_stdout=LogLevel.CRITICAL) + logger = Logger( + clock=TestClock(), + level_stdout=LogLevel.CRITICAL, + ) logger_adapter = LoggerAdapter(component_name="TEST_LOGGER", logger=logger) # Act diff --git a/tests/unit_tests/common/test_common_providers.py b/tests/unit_tests/common/test_common_providers.py index c134f85b83b3..ade4cfbfbe9e 100644 --- a/tests/unit_tests/common/test_common_providers.py +++ b/tests/unit_tests/common/test_common_providers.py @@ -30,7 +30,7 @@ def setup(self): clock = TestClock() self.provider = InstrumentProvider( venue=BITMEX, - logger=Logger(clock), + logger=Logger(clock, bypass=True), ) def test_get_all_when_no_instruments_returns_empty_dict(self): diff --git a/tests/unit_tests/common/test_common_throttler.py b/tests/unit_tests/common/test_common_throttler.py index f0bbd9e77178..595f5d9a20c1 100644 --- a/tests/unit_tests/common/test_common_throttler.py +++ b/tests/unit_tests/common/test_common_throttler.py @@ -24,7 +24,7 @@ class TestBufferingThrottler: def setup(self): # Fixture Setup self.clock = TestClock() - self.logger = Logger(self.clock) + self.logger = Logger(self.clock, bypass=True) self.handler = [] self.throttler = Throttler( @@ -165,7 +165,7 @@ class TestDroppingThrottler: def setup(self): # Fixture Setup self.clock = TestClock() - self.logger = Logger(self.clock) + self.logger = Logger(self.clock, bypass=True) self.handler = [] self.dropped = [] diff --git a/tests/unit_tests/data/test_data_engine.py b/tests/unit_tests/data/test_data_engine.py index b15ac7b015aa..48cb9852bb41 100644 --- a/tests/unit_tests/data/test_data_engine.py +++ b/tests/unit_tests/data/test_data_engine.py @@ -75,6 +75,7 @@ def setup(self): self.logger = Logger( clock=self.clock, level_stdout=LogLevel.DEBUG, + bypass=True, ) self.trader_id = TestIdStubs.trader_id() diff --git a/tests/unit_tests/execution/test_execution_client.py b/tests/unit_tests/execution/test_execution_client.py index 49a561c4eb33..4a06a801da16 100644 --- a/tests/unit_tests/execution/test_execution_client.py +++ b/tests/unit_tests/execution/test_execution_client.py @@ -40,7 +40,7 @@ class TestExecutionClient: def setup(self): # Fixture Setup self.clock = TestClock() - self.logger = Logger(self.clock) + self.logger = Logger(self.clock, bypass=True) self.trader_id = TestIdStubs.trader_id() diff --git a/tests/unit_tests/execution/test_execution_emulator.py b/tests/unit_tests/execution/test_execution_emulator.py index 894674e744f5..b8e8c39e4bd2 100644 --- a/tests/unit_tests/execution/test_execution_emulator.py +++ b/tests/unit_tests/execution/test_execution_emulator.py @@ -76,6 +76,7 @@ def setup(self): self.logger = Logger( clock=TestClock(), level_stdout=LogLevel.DEBUG, + bypass=True, ) self.trader_id = TestIdStubs.trader_id() diff --git a/tests/unit_tests/execution/test_execution_emulator_list.py b/tests/unit_tests/execution/test_execution_emulator_list.py index ef709919f9a2..e9e907820adc 100644 --- a/tests/unit_tests/execution/test_execution_emulator_list.py +++ b/tests/unit_tests/execution/test_execution_emulator_list.py @@ -64,6 +64,7 @@ def setup(self): self.logger = Logger( clock=TestClock(), level_stdout=LogLevel.DEBUG, + bypass=True, ) self.trader_id = TestIdStubs.trader_id() diff --git a/tests/unit_tests/execution/test_execution_engine.py b/tests/unit_tests/execution/test_execution_engine.py index c498eec785c5..0df7d135eb1a 100644 --- a/tests/unit_tests/execution/test_execution_engine.py +++ b/tests/unit_tests/execution/test_execution_engine.py @@ -74,6 +74,7 @@ def setup(self): self.logger = Logger( clock=TestClock(), level_stdout=LogLevel.DEBUG, + bypass=True, ) self.trader_id = TestIdStubs.trader_id() diff --git a/tests/unit_tests/live/test_live_data_client.py b/tests/unit_tests/live/test_live_data_client.py index 27e460c77839..e27c0a06fca0 100644 --- a/tests/unit_tests/live/test_live_data_client.py +++ b/tests/unit_tests/live/test_live_data_client.py @@ -44,7 +44,7 @@ def setup(self): self.loop.set_debug(True) self.clock = LiveClock() - self.logger = Logger(self.clock) + self.logger = Logger(self.clock, bypass=True) self.trader_id = TestIdStubs.trader_id() diff --git a/tests/unit_tests/live/test_live_data_engine.py b/tests/unit_tests/live/test_live_data_engine.py index 3772e876c7b1..76a551d58fa0 100644 --- a/tests/unit_tests/live/test_live_data_engine.py +++ b/tests/unit_tests/live/test_live_data_engine.py @@ -54,7 +54,7 @@ def setup(self): self.loop.set_debug(True) self.clock = LiveClock() - self.logger = Logger(self.clock) + self.logger = Logger(self.clock, bypass=True) self.trader_id = TestIdStubs.trader_id() diff --git a/tests/unit_tests/live/test_live_execution_engine.py b/tests/unit_tests/live/test_live_execution_engine.py index 2f01415264a6..b8219253b500 100644 --- a/tests/unit_tests/live/test_live_execution_engine.py +++ b/tests/unit_tests/live/test_live_execution_engine.py @@ -83,6 +83,7 @@ def setup(self): self.logger = Logger( clock=self.clock, level_stdout=LogLevel.DEBUG, + bypass=True, ) self.trader_id = TestIdStubs.trader_id() diff --git a/tests/unit_tests/live/test_live_risk_engine.py b/tests/unit_tests/live/test_live_risk_engine.py index 478f31ec3dd1..2270757fc347 100644 --- a/tests/unit_tests/live/test_live_risk_engine.py +++ b/tests/unit_tests/live/test_live_risk_engine.py @@ -56,7 +56,7 @@ def setup(self): self.loop.set_debug(True) self.clock = LiveClock() - self.logger = Logger(self.clock) + self.logger = Logger(self.clock, bypass=True) self.trader_id = TestIdStubs.trader_id() self.account_id = TestIdStubs.account_id() diff --git a/tests/unit_tests/msgbus/test_msgbus_bus.py b/tests/unit_tests/msgbus/test_msgbus_bus.py index 7ee9f926efe7..9a768a29dedb 100644 --- a/tests/unit_tests/msgbus/test_msgbus_bus.py +++ b/tests/unit_tests/msgbus/test_msgbus_bus.py @@ -29,7 +29,7 @@ class TestMessageBus: def setup(self): # Fixture Setup self.clock = TestClock() - self.logger = Logger(self.clock) + self.logger = Logger(self.clock, bypass=True) self.trader_id = TestIdStubs.trader_id() diff --git a/tests/unit_tests/persistence/test_streaming_engine.py b/tests/unit_tests/persistence/test_streaming_engine.py index 2ffe57e47675..02e17419f786 100644 --- a/tests/unit_tests/persistence/test_streaming_engine.py +++ b/tests/unit_tests/persistence/test_streaming_engine.py @@ -12,6 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. # ------------------------------------------------------------------------------------------------- + import os import fsspec diff --git a/tests/unit_tests/portfolio/test_portfolio.py b/tests/unit_tests/portfolio/test_portfolio.py index 0703eb057bc0..f5e5649b4fce 100644 --- a/tests/unit_tests/portfolio/test_portfolio.py +++ b/tests/unit_tests/portfolio/test_portfolio.py @@ -71,7 +71,7 @@ class TestPortfolio: def setup(self): # Fixture Setup self.clock = TestClock() - self.logger = Logger(self.clock) + self.logger = Logger(self.clock, bypass=True) self.trader_id = TestIdStubs.trader_id() diff --git a/tests/unit_tests/risk/test_risk_engine.py b/tests/unit_tests/risk/test_risk_engine.py index 9b43b361813e..7a8bc6d2ecb8 100644 --- a/tests/unit_tests/risk/test_risk_engine.py +++ b/tests/unit_tests/risk/test_risk_engine.py @@ -72,6 +72,7 @@ def setup(self): self.logger = Logger( clock=self.clock, level_stdout=LogLevel.DEBUG, + bypass=True, ) self.trader_id = TestIdStubs.trader_id() diff --git a/tests/unit_tests/trading/test_trading_strategy.py b/tests/unit_tests/trading/test_trading_strategy.py index b473bd52ca7a..cb79beb6dda6 100644 --- a/tests/unit_tests/trading/test_trading_strategy.py +++ b/tests/unit_tests/trading/test_trading_strategy.py @@ -76,6 +76,7 @@ def setup(self): self.logger = Logger( clock=self.clock, level_stdout=LogLevel.DEBUG, + bypass=True, ) self.trader_id = TestIdStubs.trader_id() diff --git a/tests/unit_tests/trading/test_trading_trader.py b/tests/unit_tests/trading/test_trading_trader.py index 79d76672f17e..f9edb994be14 100644 --- a/tests/unit_tests/trading/test_trading_trader.py +++ b/tests/unit_tests/trading/test_trading_trader.py @@ -56,7 +56,7 @@ class TestTrader: def setup(self): # Fixture Setup self.clock = TestClock() - self.logger = Logger(self.clock) + self.logger = Logger(self.clock, bypass=True) self.trader_id = TestIdStubs.trader_id() From 991f132d906b470cb7501d5a6bf701e8a95401a3 Mon Sep 17 00:00:00 2001 From: Chris Sellers Date: Wed, 15 Feb 2023 13:51:21 +1100 Subject: [PATCH 69/81] Fix type hint --- nautilus_trader/persistence/catalog/parquet.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nautilus_trader/persistence/catalog/parquet.py b/nautilus_trader/persistence/catalog/parquet.py index 89283d5ffb2c..5a97d33d247f 100644 --- a/nautilus_trader/persistence/catalog/parquet.py +++ b/nautilus_trader/persistence/catalog/parquet.py @@ -74,7 +74,7 @@ class ParquetDataCatalog(BaseDataCatalog): def __init__( self, path: str, - fs_protocol: str = "file", + fs_protocol: Optional[str] = "file", fs_storage_options: Optional[dict] = None, ): self.fs_protocol = fs_protocol From afee8a6263637939cf27fa0dac410092dc583ee5 Mon Sep 17 00:00:00 2001 From: Chris Sellers Date: Wed, 15 Feb 2023 13:53:13 +1100 Subject: [PATCH 70/81] Make frozen config classes consistent --- nautilus_trader/config/backtest.py | 2 +- nautilus_trader/config/live.py | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/nautilus_trader/config/backtest.py b/nautilus_trader/config/backtest.py index 8cd7784e7c11..05a663291a7f 100644 --- a/nautilus_trader/config/backtest.py +++ b/nautilus_trader/config/backtest.py @@ -150,7 +150,7 @@ def load( } -class BacktestEngineConfig(NautilusKernelConfig): +class BacktestEngineConfig(NautilusKernelConfig, frozen=True): """ Configuration for ``BacktestEngine`` instances. diff --git a/nautilus_trader/config/live.py b/nautilus_trader/config/live.py index 889c98c97868..d7f786ae60e1 100644 --- a/nautilus_trader/config/live.py +++ b/nautilus_trader/config/live.py @@ -28,7 +28,7 @@ from nautilus_trader.config.validation import PositiveInt -class LiveDataEngineConfig(DataEngineConfig): +class LiveDataEngineConfig(DataEngineConfig, frozen=True): """ Configuration for ``LiveDataEngine`` instances. @@ -41,7 +41,7 @@ class LiveDataEngineConfig(DataEngineConfig): qsize: PositiveInt = 10000 -class LiveRiskEngineConfig(RiskEngineConfig): +class LiveRiskEngineConfig(RiskEngineConfig, frozen=True): """ Configuration for ``LiveRiskEngine`` instances. @@ -54,7 +54,7 @@ class LiveRiskEngineConfig(RiskEngineConfig): qsize: PositiveInt = 10000 -class LiveExecEngineConfig(ExecEngineConfig): +class LiveExecEngineConfig(ExecEngineConfig, frozen=True): """ Configuration for ``LiveExecEngine`` instances. @@ -82,7 +82,7 @@ class LiveExecEngineConfig(ExecEngineConfig): qsize: PositiveInt = 10000 -class RoutingConfig(NautilusConfig): +class RoutingConfig(NautilusConfig, frozen=True): """ Configuration for live client message routing. @@ -99,7 +99,7 @@ class RoutingConfig(NautilusConfig): venues: Optional[frozenset[str]] = None -class LiveDataClientConfig(NautilusConfig): +class LiveDataClientConfig(NautilusConfig, frozen=True): """ Configuration for ``LiveDataClient`` instances. @@ -118,7 +118,7 @@ class LiveDataClientConfig(NautilusConfig): routing: RoutingConfig = RoutingConfig() -class LiveExecClientConfig(NautilusConfig): +class LiveExecClientConfig(NautilusConfig, frozen=True): """ Configuration for ``LiveExecutionClient`` instances. @@ -134,7 +134,7 @@ class LiveExecClientConfig(NautilusConfig): routing: RoutingConfig = RoutingConfig() -class TradingNodeConfig(NautilusKernelConfig): +class TradingNodeConfig(NautilusKernelConfig, frozen=True): """ Configuration for ``TradingNode`` instances. From f98223d4623ac0aec6a7b5b230da912d4b1267a9 Mon Sep 17 00:00:00 2001 From: Chris Sellers Date: Wed, 15 Feb 2023 14:50:44 +1100 Subject: [PATCH 71/81] Binance minor cleanup --- nautilus_trader/adapters/binance/common/data.py | 5 ----- nautilus_trader/adapters/binance/futures/data.py | 9 +++++---- .../adapters/binance/futures/execution.py | 13 +++++++------ nautilus_trader/adapters/binance/spot/data.py | 9 +++++---- nautilus_trader/adapters/binance/spot/execution.py | 13 +++++++------ 5 files changed, 24 insertions(+), 25 deletions(-) diff --git a/nautilus_trader/adapters/binance/common/data.py b/nautilus_trader/adapters/binance/common/data.py index 467f76d9b6f4..5dfc72fceecf 100644 --- a/nautilus_trader/adapters/binance/common/data.py +++ b/nautilus_trader/adapters/binance/common/data.py @@ -123,11 +123,6 @@ def __init__( logger=logger, ) - if account_type not in BinanceAccountType: - raise RuntimeError( # pragma: no cover (design-time error) - f"invalid `BinanceAccountType`, was {account_type}", # pragma: no cover - ) - self._binance_account_type = account_type self._use_agg_trade_ticks = use_agg_trade_ticks self._log.info(f"Account type: {self._binance_account_type.value}.", LogColor.BLUE) diff --git a/nautilus_trader/adapters/binance/futures/data.py b/nautilus_trader/adapters/binance/futures/data.py index 07ffc5111ce8..47410005c0a6 100644 --- a/nautilus_trader/adapters/binance/futures/data.py +++ b/nautilus_trader/adapters/binance/futures/data.py @@ -30,6 +30,7 @@ from nautilus_trader.common.clock import LiveClock from nautilus_trader.common.logging import Logger from nautilus_trader.common.providers import InstrumentProvider +from nautilus_trader.core.correctness import PyCondition from nautilus_trader.model.data.base import DataType from nautilus_trader.model.data.base import GenericData from nautilus_trader.model.data.tick import TradeTick @@ -81,10 +82,10 @@ def __init__( base_url_ws: Optional[str] = None, use_agg_trade_ticks: bool = False, ): - if not account_type.is_futures: - raise RuntimeError( # pragma: no cover (design-time error) - f"`BinanceAccountType` not FUTURES_USDT or FUTURES_COIN, was {account_type}", # pragma: no cover - ) + PyCondition.true( + account_type.is_futures, + "account_type was not FUTURES_USDT or FUTURES_COIN", + ) # Futures HTTP API self._futures_http_market = BinanceFuturesMarketHttpAPI(client, account_type) diff --git a/nautilus_trader/adapters/binance/futures/execution.py b/nautilus_trader/adapters/binance/futures/execution.py index 1a9b65211a84..d14223d480f1 100644 --- a/nautilus_trader/adapters/binance/futures/execution.py +++ b/nautilus_trader/adapters/binance/futures/execution.py @@ -38,6 +38,7 @@ from nautilus_trader.common.clock import LiveClock from nautilus_trader.common.enums import LogColor from nautilus_trader.common.logging import Logger +from nautilus_trader.core.correctness import PyCondition from nautilus_trader.core.datetime import millis_to_nanos from nautilus_trader.core.uuid import UUID4 from nautilus_trader.execution.reports import PositionStatusReport @@ -94,10 +95,10 @@ def __init__( clock_sync_interval_secs: int = 0, warn_gtd_to_gtc: bool = True, ): - if not account_type.is_futures: - raise RuntimeError( # pragma: no cover (design-time error) - f"`BinanceAccountType` not FUTURES_USDT or FUTURES_COIN, was {account_type}", # pragma: no cover - ) + PyCondition.true( + account_type.is_futures, + "account_type was not FUTURES_USDT or FUTURES_COIN", + ) # Futures HTTP API self._futures_http_account = BinanceFuturesAccountHttpAPI(client, clock, account_type) @@ -174,7 +175,7 @@ async def _update_account_state(self) -> None: async def _get_binance_position_status_reports( self, - symbol: str = None, + symbol: Optional[str] = None, ) -> list[PositionStatusReport]: reports: list[PositionStatusReport] = [] # Check Binance for all active positions @@ -196,7 +197,7 @@ async def _get_binance_position_status_reports( async def _get_binance_active_position_symbols( self, - symbol: str = None, + symbol: Optional[str] = None, ) -> list[str]: # Check Binance for all active positions active_symbols: list[str] = [] diff --git a/nautilus_trader/adapters/binance/spot/data.py b/nautilus_trader/adapters/binance/spot/data.py index 89e4b10b2880..3d33068c462b 100644 --- a/nautilus_trader/adapters/binance/spot/data.py +++ b/nautilus_trader/adapters/binance/spot/data.py @@ -29,6 +29,7 @@ from nautilus_trader.common.clock import LiveClock from nautilus_trader.common.logging import Logger from nautilus_trader.common.providers import InstrumentProvider +from nautilus_trader.core.correctness import PyCondition from nautilus_trader.model.data.tick import TradeTick from nautilus_trader.model.identifiers import InstrumentId from nautilus_trader.model.orderbook.data import OrderBookData @@ -78,10 +79,10 @@ def __init__( base_url_ws: Optional[str] = None, use_agg_trade_ticks: bool = False, ): - if not account_type.is_spot_or_margin: - raise RuntimeError( # pragma: no cover (design-time error) - f"`BinanceAccountType` not SPOT, MARGIN_CROSS or MARGIN_ISOLATED, was {account_type}", # pragma: no cover - ) + PyCondition.true( + account_type.is_spot_or_margin, + "account_type was not SPOT, MARGIN_CROSS or MARGIN_ISOLATED", + ) # Spot HTTP API self._spot_http_market = BinanceSpotMarketHttpAPI(client, account_type) diff --git a/nautilus_trader/adapters/binance/spot/execution.py b/nautilus_trader/adapters/binance/spot/execution.py index c3ee7a5454b0..e191e8a6aed3 100644 --- a/nautilus_trader/adapters/binance/spot/execution.py +++ b/nautilus_trader/adapters/binance/spot/execution.py @@ -35,6 +35,7 @@ from nautilus_trader.common.clock import LiveClock from nautilus_trader.common.enums import LogColor from nautilus_trader.common.logging import Logger +from nautilus_trader.core.correctness import PyCondition from nautilus_trader.core.datetime import millis_to_nanos from nautilus_trader.execution.reports import PositionStatusReport from nautilus_trader.model.enums import OrderType @@ -89,10 +90,10 @@ def __init__( clock_sync_interval_secs: int = 0, warn_gtd_to_gtc: bool = True, ): - if not account_type.is_spot_or_margin: - raise RuntimeError( # pragma: no cover (design-time error) - f"`BinanceAccountType` not SPOT, MARGIN_CROSS or MARGIN_ISOLATED, was {account_type}", # pragma: no cover - ) + PyCondition.true( + account_type.is_spot_or_margin, + "account_type was not SPOT, MARGIN_CROSS or MARGIN_ISOLATED", + ) # Spot HTTP API self._spot_http_account = BinanceSpotAccountHttpAPI(client, clock, account_type) @@ -162,14 +163,14 @@ async def _update_account_state(self) -> None: async def _get_binance_position_status_reports( self, - symbol: str = None, + symbol: Optional[str] = None, ) -> list[PositionStatusReport]: # Never cash positions return [] async def _get_binance_active_position_symbols( self, - symbol: str = None, + symbol: Optional[str] = None, ) -> list[str]: # Never cash positions return [] From 38485323366e8b873b431ae58dd1cc8f57e527b2 Mon Sep 17 00:00:00 2001 From: Chris Sellers Date: Thu, 16 Feb 2023 05:47:51 +1100 Subject: [PATCH 72/81] Binance spot provider no longer requests fees --- .../adapters/binance/spot/http/wallet.py | 2 +- .../adapters/binance/spot/providers.py | 18 ++++++++++++------ 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/nautilus_trader/adapters/binance/spot/http/wallet.py b/nautilus_trader/adapters/binance/spot/http/wallet.py index cc568aaeaa8b..1ce3abe3d921 100644 --- a/nautilus_trader/adapters/binance/spot/http/wallet.py +++ b/nautilus_trader/adapters/binance/spot/http/wallet.py @@ -120,7 +120,7 @@ async def query_spot_trade_fees( fees = await self._endpoint_spot_trade_fee._get( parameters=self._endpoint_spot_trade_fee.GetParameters( timestamp=self._timestamp(), - symbol=BinanceSymbol(symbol), + symbol=BinanceSymbol(symbol) if symbol is not None else None, recvWindow=recv_window, ), ) diff --git a/nautilus_trader/adapters/binance/spot/providers.py b/nautilus_trader/adapters/binance/spot/providers.py index 99e4dba22038..62dca9c916bf 100644 --- a/nautilus_trader/adapters/binance/spot/providers.py +++ b/nautilus_trader/adapters/binance/spot/providers.py @@ -97,8 +97,10 @@ async def load_all_async(self, filters: Optional[dict] = None) -> None: # Get current commission rates try: - response = await self._http_wallet.query_spot_trade_fees() - fees_dict: dict[str, BinanceSpotTradeFee] = {fee.symbol: fee for fee in response} + # response = await self._http_wallet.query_spot_trade_fees() + # fees_dict: dict[str, BinanceSpotTradeFee] = {fee.symbol: fee for fee in response} + # TODO: Requests for testnet seem to fail auth + fees_dict: dict[str, BinanceSpotTradeFee] = {} except BinanceClientError as e: self._log.error( "Cannot load instruments: API key authentication failed " @@ -133,8 +135,10 @@ async def load_ids_async( # Get current commission rates try: - response = await self._http_wallet.query_spot_trade_fees() - fees_dict: dict[str, BinanceSpotTradeFee] = {fee.symbol: fee for fee in response} + # response = await self._http_wallet.query_spot_trade_fees() + # fees_dict: dict[str, BinanceSpotTradeFee] = {fee.symbol: fee for fee in response} + # TODO: Requests for testnet seem to fail auth + fees_dict: dict[str, BinanceSpotTradeFee] = {} except BinanceClientError as e: self._log.error( "Cannot load instruments: API key authentication failed " @@ -170,8 +174,10 @@ async def load_async(self, instrument_id: InstrumentId, filters: Optional[dict] # Get current commission rates try: - trade_fees = await self._http_wallet.query_spot_trade_fees(symbol=symbol) - fees_dict: dict[str, BinanceSpotTradeFee] = {fee.symbol: fee for fee in trade_fees} + # trade_fees = await self._http_wallet.query_spot_trade_fees(symbol=symbol) + # fees_dict: dict[str, BinanceSpotTradeFee] = {fee.symbol: fee for fee in trade_fees} + # TODO: Requests for testnet seem to fail auth + fees_dict: dict[str, BinanceSpotTradeFee] = {} except BinanceClientError as e: self._log.error( "Cannot load instruments: API key authentication failed " From 4459ede64e5adcad9901ea023f7d258d12302dd0 Mon Sep 17 00:00:00 2001 From: Chris Sellers Date: Thu, 16 Feb 2023 18:42:55 +1100 Subject: [PATCH 73/81] Update dependencies --- nautilus_core/Cargo.lock | 20 +++++++++--------- poetry.lock | 44 ++++++++++++++++++++-------------------- 2 files changed, 32 insertions(+), 32 deletions(-) diff --git a/nautilus_core/Cargo.lock b/nautilus_core/Cargo.lock index bef272cb6312..0e7af44e1bf2 100644 --- a/nautilus_core/Cargo.lock +++ b/nautilus_core/Cargo.lock @@ -410,9 +410,9 @@ dependencies = [ [[package]] name = "cxx" -version = "1.0.90" +version = "1.0.91" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90d59d9acd2a682b4e40605a242f6670eaa58c5957471cbf85e8aa6a0b97a5e8" +checksum = "86d3488e7665a7a483b57e25bdd90d0aeb2bc7608c8d0346acf2ad3f1caf1d62" dependencies = [ "cc", "cxxbridge-flags", @@ -422,9 +422,9 @@ dependencies = [ [[package]] name = "cxx-build" -version = "1.0.90" +version = "1.0.91" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebfa40bda659dd5c864e65f4c9a2b0aff19bea56b017b9b77c73d3766a453a38" +checksum = "48fcaf066a053a41a81dfb14d57d99738b767febb8b735c3016e469fac5da690" dependencies = [ "cc", "codespan-reporting", @@ -437,15 +437,15 @@ dependencies = [ [[package]] name = "cxxbridge-flags" -version = "1.0.90" +version = "1.0.91" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "457ce6757c5c70dc6ecdbda6925b958aae7f959bda7d8fb9bde889e34a09dc03" +checksum = "a2ef98b8b717a829ca5603af80e1f9e2e48013ab227b68ef37872ef84ee479bf" [[package]] name = "cxxbridge-macro" -version = "1.0.90" +version = "1.0.91" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebf883b7aacd7b2aeb2a7b338648ee19f57c140d4ee8e52c68979c6b2f7f2263" +checksum = "086c685979a698443656e5cf7856c95c642295a38599f12fb1ff76fb28d19892" dependencies = [ "proc-macro2", "quote", @@ -1020,9 +1020,9 @@ dependencies = [ [[package]] name = "once_cell" -version = "1.17.0" +version = "1.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f61fba1741ea2b3d6a1e3178721804bb716a68a6aeba1149b5d52e3d464ea66" +checksum = "b7e5500299e16ebb147ae15a00a942af264cf3688f47923b8fc2cd5858f23ad3" [[package]] name = "oorandom" diff --git a/poetry.lock b/poetry.lock index f055337cf3ab..fe46a9946fc6 100644 --- a/poetry.lock +++ b/poetry.lock @@ -2337,14 +2337,14 @@ use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] [[package]] name = "setuptools" -version = "67.2.0" +version = "67.3.2" description = "Easily download, build, install, upgrade, and uninstall Python packages" category = "dev" optional = false python-versions = ">=3.7" files = [ - {file = "setuptools-67.2.0-py3-none-any.whl", hash = "sha256:16ccf598aab3b506593c17378473978908a2734d7336755a8769b480906bec1c"}, - {file = "setuptools-67.2.0.tar.gz", hash = "sha256:b440ee5f7e607bb8c9de15259dba2583dd41a38879a7abc1d43a71c59524da48"}, + {file = "setuptools-67.3.2-py3-none-any.whl", hash = "sha256:bb6d8e508de562768f2027902929f8523932fcd1fb784e6d573d2cafac995a48"}, + {file = "setuptools-67.3.2.tar.gz", hash = "sha256:95f00380ef2ffa41d9bba85d95b27689d923c93dfbafed4aecd7cf988a25e012"}, ] [package.extras] @@ -2378,14 +2378,14 @@ files = [ [[package]] name = "soupsieve" -version = "2.3.2.post1" +version = "2.4" description = "A modern CSS selector implementation for Beautiful Soup." category = "dev" optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" files = [ - {file = "soupsieve-2.3.2.post1-py3-none-any.whl", hash = "sha256:3b2503d3c7084a42b1ebd08116e5f81aadfaea95863628c80a3b774a11b7c759"}, - {file = "soupsieve-2.3.2.post1.tar.gz", hash = "sha256:fc53893b3da2c33de295667a0e19f078c14bf86544af307354de5fcf12a3f30d"}, + {file = "soupsieve-2.4-py3-none-any.whl", hash = "sha256:49e5368c2cda80ee7e84da9dbe3e110b70a4575f196efb74e51b94549d921955"}, + {file = "soupsieve-2.4.tar.gz", hash = "sha256:e28dba9ca6c7c00173e34e4ba57448f0688bb681b7c5e8bf4971daafc093d69a"}, ] [[package]] @@ -2724,14 +2724,14 @@ files = [ [[package]] name = "types-redis" -version = "4.5.1.0" +version = "4.5.1.1" description = "Typing stubs for redis" category = "dev" optional = false python-versions = "*" files = [ - {file = "types-redis-4.5.1.0.tar.gz", hash = "sha256:6f6fb1cfeee3708112dec3609a042774f96f2cfcb4709d267c11f51a6976da0a"}, - {file = "types_redis-4.5.1.0-py3-none-any.whl", hash = "sha256:dac6ea398c57a53213b70727be7c8e3a788ded3c3880e94bf74e85c22aa63c7e"}, + {file = "types-redis-4.5.1.1.tar.gz", hash = "sha256:c072e4824855f46d0a968509c3e0fa4789fc13b62d472064527bad3d1815aeed"}, + {file = "types_redis-4.5.1.1-py3-none-any.whl", hash = "sha256:081dfeec730df6e3f32ccbdafe3198873b7c02516c22d79cc2a40efdd69a3963"}, ] [package.dependencies] @@ -2740,14 +2740,14 @@ types-pyOpenSSL = "*" [[package]] name = "types-requests" -version = "2.28.11.12" +version = "2.28.11.13" description = "Typing stubs for requests" category = "dev" optional = false python-versions = "*" files = [ - {file = "types-requests-2.28.11.12.tar.gz", hash = "sha256:fd530aab3fc4f05ee36406af168f0836e6f00f1ee51a0b96b7311f82cb675230"}, - {file = "types_requests-2.28.11.12-py3-none-any.whl", hash = "sha256:dbc2933635860e553ffc59f5e264264981358baffe6342b925e3eb8261f866ee"}, + {file = "types-requests-2.28.11.13.tar.gz", hash = "sha256:3fd332842e8759ea5f7eb7789df8aa772ba155216ccf10ef4aa3b0e5b42e1b46"}, + {file = "types_requests-2.28.11.13-py3-none-any.whl", hash = "sha256:94896f6f8e9f3db11e422c6e3e4abbc5d7ccace853eac74b23bdd65eeee3cdee"}, ] [package.dependencies] @@ -2755,38 +2755,38 @@ types-urllib3 = "<1.27" [[package]] name = "types-toml" -version = "0.10.8.3" +version = "0.10.8.4" description = "Typing stubs for toml" category = "dev" optional = false python-versions = "*" files = [ - {file = "types-toml-0.10.8.3.tar.gz", hash = "sha256:f37244eff4cd7eace9cb70d0bac54d3eba77973aa4ef26c271ac3d1c6503a48e"}, - {file = "types_toml-0.10.8.3-py3-none-any.whl", hash = "sha256:a2286a053aea6ab6ff814659272b1d4a05d86a1dd52b807a87b23511993b46c5"}, + {file = "types-toml-0.10.8.4.tar.gz", hash = "sha256:c8748dd225b28eb80ce712e2d7d61b57599815e7b48d07ef53df51ed148fa6b1"}, + {file = "types_toml-0.10.8.4-py3-none-any.whl", hash = "sha256:306b1bb8b5bbc5f1b60387dbcc4b489e79f8490ce20e93af5f422a68b470d94b"}, ] [[package]] name = "types-urllib3" -version = "1.26.25.5" +version = "1.26.25.6" description = "Typing stubs for urllib3" category = "dev" optional = false python-versions = "*" files = [ - {file = "types-urllib3-1.26.25.5.tar.gz", hash = "sha256:5630e578246d170d91ebe3901788cd28d53c4e044dc2e2488e3b0d55fb6895d8"}, - {file = "types_urllib3-1.26.25.5-py3-none-any.whl", hash = "sha256:e8f25c8bb85cde658c72ee931e56e7abd28803c26032441eea9ff4a4df2b0c31"}, + {file = "types-urllib3-1.26.25.6.tar.gz", hash = "sha256:35586727cbd7751acccf2c0f34a88baffc092f435ab62458f10776466590f2d5"}, + {file = "types_urllib3-1.26.25.6-py3-none-any.whl", hash = "sha256:a6c23c41bd03e542eaee5423a018f833077b51c4bf9ceb5aa544e12b812d5604"}, ] [[package]] name = "typing-extensions" -version = "4.4.0" +version = "4.5.0" description = "Backported and Experimental Type Hints for Python 3.7+" category = "dev" optional = false python-versions = ">=3.7" files = [ - {file = "typing_extensions-4.4.0-py3-none-any.whl", hash = "sha256:16fa4864408f655d35ec496218b85f79b3437c829e93320c7c9215ccfd92489e"}, - {file = "typing_extensions-4.4.0.tar.gz", hash = "sha256:1511434bb92bf8dd198c12b1cc812e800d4181cfcb867674e0f8279cc93087aa"}, + {file = "typing_extensions-4.5.0-py3-none-any.whl", hash = "sha256:fb33085c39dd998ac16d1431ebc293a8b3eedd00fd4a32de0ff79002c19511b4"}, + {file = "typing_extensions-4.5.0.tar.gz", hash = "sha256:5cb5f4a79139d699607b3ef622a1dedafa84e115ab0024e0d9c044a9479ca7cb"}, ] [[package]] From ba9a9901afeef353ebfac54f1d75ace48e3ef475 Mon Sep 17 00:00:00 2001 From: Chris Sellers Date: Thu, 16 Feb 2023 18:44:07 +1100 Subject: [PATCH 74/81] Add warnings to Binance providers --- .../adapters/binance/futures/providers.py | 6 ++++++ nautilus_trader/adapters/binance/spot/providers.py | 12 ++++++++++++ 2 files changed, 18 insertions(+) diff --git a/nautilus_trader/adapters/binance/futures/providers.py b/nautilus_trader/adapters/binance/futures/providers.py index de17c03878c9..c8df2fb6504e 100644 --- a/nautilus_trader/adapters/binance/futures/providers.py +++ b/nautilus_trader/adapters/binance/futures/providers.py @@ -102,6 +102,9 @@ async def load_all_async(self, filters: Optional[dict] = None) -> None: # Get exchange info for all assets exchange_info = await self._http_market.query_futures_exchange_info() + self._log.warning( + "Currently not requesting actual trade fees. All instruments will have zero fees.", + ) for symbol_info in exchange_info.symbols: fee: Optional[BinanceFuturesCommissionRate] = None # TODO(cs): This won't work for 174 instruments, we'll have to pre-request these @@ -151,6 +154,9 @@ async def load_ids_async( info.symbol: info for info in exchange_info.symbols } + self._log.warning( + "Currently not requesting actual trade fees. All instruments will have zero fees.", + ) for symbol in symbols: fee: Optional[BinanceFuturesCommissionRate] = None # TODO(cs): This won't work for 174 instruments, we'll have to pre-request these diff --git a/nautilus_trader/adapters/binance/spot/providers.py b/nautilus_trader/adapters/binance/spot/providers.py index 62dca9c916bf..a7e50d34f5c0 100644 --- a/nautilus_trader/adapters/binance/spot/providers.py +++ b/nautilus_trader/adapters/binance/spot/providers.py @@ -100,6 +100,10 @@ async def load_all_async(self, filters: Optional[dict] = None) -> None: # response = await self._http_wallet.query_spot_trade_fees() # fees_dict: dict[str, BinanceSpotTradeFee] = {fee.symbol: fee for fee in response} # TODO: Requests for testnet seem to fail auth + self._log.warning( + "Currently not requesting actual trade fees. " + "All instruments will have zero fees.", + ) fees_dict: dict[str, BinanceSpotTradeFee] = {} except BinanceClientError as e: self._log.error( @@ -138,6 +142,10 @@ async def load_ids_async( # response = await self._http_wallet.query_spot_trade_fees() # fees_dict: dict[str, BinanceSpotTradeFee] = {fee.symbol: fee for fee in response} # TODO: Requests for testnet seem to fail auth + self._log.warning( + "Currently not requesting actual trade fees. " + "All instruments will have zero fees.", + ) fees_dict: dict[str, BinanceSpotTradeFee] = {} except BinanceClientError as e: self._log.error( @@ -177,6 +185,10 @@ async def load_async(self, instrument_id: InstrumentId, filters: Optional[dict] # trade_fees = await self._http_wallet.query_spot_trade_fees(symbol=symbol) # fees_dict: dict[str, BinanceSpotTradeFee] = {fee.symbol: fee for fee in trade_fees} # TODO: Requests for testnet seem to fail auth + self._log.warning( + "Currently not requesting actual trade fees. " + "All instruments will have zero fees.", + ) fees_dict: dict[str, BinanceSpotTradeFee] = {} except BinanceClientError as e: self._log.error( From d62cea7dfdadf9662776531989aa75b5257e572a Mon Sep 17 00:00:00 2001 From: Chris Sellers Date: Thu, 16 Feb 2023 20:01:33 +1100 Subject: [PATCH 75/81] Update release notes --- RELEASES.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/RELEASES.md b/RELEASES.md index 22b68d0d9aff..d9f290a2c85b 100644 --- a/RELEASES.md +++ b/RELEASES.md @@ -1,6 +1,6 @@ # NautilusTrader 1.169.0 Beta -Released on TBD (UTC). +Released on 16th February 2023 (UTC). ### Breaking Changes - `NautilusConfig` objects now _pseudo-immutable_ from new msgspec 0.13.0 From 34d710775012e91573b929ec7ed42c86bd208d53 Mon Sep 17 00:00:00 2001 From: Chris Sellers Date: Fri, 17 Feb 2023 07:22:57 +1100 Subject: [PATCH 76/81] Skip flaky test on Windows --- tests/unit_tests/core/test_core_inspect.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/unit_tests/core/test_core_inspect.py b/tests/unit_tests/core/test_core_inspect.py index 0ed3860079ac..89186277b462 100644 --- a/tests/unit_tests/core/test_core_inspect.py +++ b/tests/unit_tests/core/test_core_inspect.py @@ -13,6 +13,8 @@ # limitations under the License. # ------------------------------------------------------------------------------------------------- +import sys + import pandas as pd import pytest @@ -41,6 +43,7 @@ def test_is_nautilus_class(cls, is_nautilus): assert is_nautilus_class(cls=cls) is is_nautilus +@pytest.mark.skipif(sys.platform == "win32", reason="Failing on windows") def test_get_size_of(): # Arrange, Act result1 = get_size_of(0) From d09f3685b6f6342d400a7534e4c17b88796e92c1 Mon Sep 17 00:00:00 2001 From: Chris Sellers Date: Fri, 17 Feb 2023 12:09:52 +1100 Subject: [PATCH 77/81] Skip redundant and flaky test --- tests/unit_tests/core/test_core_inspect.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/unit_tests/core/test_core_inspect.py b/tests/unit_tests/core/test_core_inspect.py index 89186277b462..8e3a27bd098a 100644 --- a/tests/unit_tests/core/test_core_inspect.py +++ b/tests/unit_tests/core/test_core_inspect.py @@ -13,8 +13,6 @@ # limitations under the License. # ------------------------------------------------------------------------------------------------- -import sys - import pandas as pd import pytest @@ -43,7 +41,7 @@ def test_is_nautilus_class(cls, is_nautilus): assert is_nautilus_class(cls=cls) is is_nautilus -@pytest.mark.skipif(sys.platform == "win32", reason="Failing on windows") +@pytest.mark.skip(reason="Flaky and probably being removed") def test_get_size_of(): # Arrange, Act result1 = get_size_of(0) From 1c2708885309d4a8a5fd4de43eecbd276438a410 Mon Sep 17 00:00:00 2001 From: Chris Sellers Date: Sat, 18 Feb 2023 09:47:28 +1100 Subject: [PATCH 78/81] Update dependencies --- RELEASES.md | 2 +- poetry.lock | 91 +++++++++++++++++++++++++------------------------- pyproject.toml | 4 +-- 3 files changed, 48 insertions(+), 49 deletions(-) diff --git a/RELEASES.md b/RELEASES.md index d9f290a2c85b..05d5e329e791 100644 --- a/RELEASES.md +++ b/RELEASES.md @@ -1,6 +1,6 @@ # NautilusTrader 1.169.0 Beta -Released on 16th February 2023 (UTC). +Released on 18th February 2023 (UTC). ### Breaking Changes - `NautilusConfig` objects now _pseudo-immutable_ from new msgspec 0.13.0 diff --git a/poetry.lock b/poetry.lock index fe46a9946fc6..0b2b7a7fb69c 100644 --- a/poetry.lock +++ b/poetry.lock @@ -806,29 +806,28 @@ pyflakes = ">=3.0.0,<3.1.0" [[package]] name = "frozendict" -version = "2.3.4" +version = "2.3.5" description = "A simple immutable dictionary" category = "main" optional = false python-versions = ">=3.6" files = [ - {file = "frozendict-2.3.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4a3b32d47282ae0098b9239a6d53ec539da720258bd762d62191b46f2f87c5fc"}, - {file = "frozendict-2.3.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:84c9887179a245a66a50f52afa08d4d92ae0f269839fab82285c70a0fa0dd782"}, - {file = "frozendict-2.3.4-cp310-cp310-win_amd64.whl", hash = "sha256:b98a0d65a59af6da03f794f90b0c3085a7ee14e7bf8f0ef36b079ee8aa992439"}, - {file = "frozendict-2.3.4-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:3d8042b7dab5e992e30889c9b71b781d5feef19b372d47d735e4d7d45846fd4a"}, - {file = "frozendict-2.3.4-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:25a6d2e8b7cf6b6e5677a1a4b53b4073e5d9ec640d1db30dc679627668d25e90"}, - {file = "frozendict-2.3.4-cp36-cp36m-win_amd64.whl", hash = "sha256:dbbe1339ac2646523e0bb00d1896085d1f70de23780e4927ca82b36ab8a044d3"}, - {file = "frozendict-2.3.4-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:95bac22f7f09d81f378f2b3f672b7a50a974ca180feae1507f5e21bc147e8bc8"}, - {file = "frozendict-2.3.4-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dae686722c144b333c4dbdc16323a5de11406d26b76d2be1cc175f90afacb5ba"}, - {file = "frozendict-2.3.4-cp37-cp37m-win_amd64.whl", hash = "sha256:389f395a74eb16992217ac1521e689c1dea2d70113bcb18714669ace1ed623b9"}, - {file = "frozendict-2.3.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:ccb6450a416c9cc9acef7683e637e28356e3ceeabf83521f74cc2718883076b7"}, - {file = "frozendict-2.3.4-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aca59108b77cadc13ba7dfea7e8f50811208c7652a13dc6c7f92d7782a24d299"}, - {file = "frozendict-2.3.4-cp38-cp38-win_amd64.whl", hash = "sha256:3ec86ebf143dd685184215c27ec416c36e0ba1b80d81b1b9482f7d380c049b4e"}, - {file = "frozendict-2.3.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5809e6ff6b7257043a486f7a3b73a7da71cf69a38980b4171e4741291d0d9eb3"}, - {file = "frozendict-2.3.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7c550ed7fdf1962984bec21630c584d722b3ee5d5f57a0ae2527a0121dc0414a"}, - {file = "frozendict-2.3.4-cp39-cp39-win_amd64.whl", hash = "sha256:3e93aebc6e69a8ef329bbe9afb8342bd33c7b5c7a0c480cb9f7e60b0cbe48072"}, - {file = "frozendict-2.3.4-py3-none-any.whl", hash = "sha256:d722f3d89db6ae35ef35ecc243c40c800eb344848c83dba4798353312cd37b15"}, - {file = "frozendict-2.3.4.tar.gz", hash = "sha256:15b4b18346259392b0d27598f240e9390fafbff882137a9c48a1e0104fb17f78"}, + {file = "frozendict-2.3.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:fa08c3f361e26c698c22f008804cac4a5b51437c12feafb983daadac12f66ead"}, + {file = "frozendict-2.3.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c9b8cbed40c96fce53e5a31ff2db30ca2c56992ba033555b08c22d099c3576ec"}, + {file = "frozendict-2.3.5-cp310-cp310-win_amd64.whl", hash = "sha256:64a00bcad55ff122293b0d362856dce0b248e894f1dcb0a0f68227a5ba9e4be6"}, + {file = "frozendict-2.3.5-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:08f8efd6fbe885e6217d210302cdc12cb8134aeac2b83db898511bc5e34719c5"}, + {file = "frozendict-2.3.5-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:26a2c371d23f148886864a5b82f1e5eefed35ce145b5d59dcfd3d66c9391bb45"}, + {file = "frozendict-2.3.5-cp36-cp36m-win_amd64.whl", hash = "sha256:de96ccf6e574482c9537ffa68b2cb381537a5a085483001d4a2b93847089bc04"}, + {file = "frozendict-2.3.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:1dbe11318b423fb3591e08d8b832d27dfd7b74dc20486d3384b8e05d6de2bcf7"}, + {file = "frozendict-2.3.5-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:30af9f39a5e29edca96b09c8d0a17fc78a0efd5f31f74d5eebb4c9a28d03032f"}, + {file = "frozendict-2.3.5-cp37-cp37m-win_amd64.whl", hash = "sha256:d1677e53d370ba44a07fbcc036fa24d4ae5693f0ed785496caf49e12a238d41f"}, + {file = "frozendict-2.3.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1411ef255b7a55fc337022ba158acf1391cd0d9a5c13142abbb7367936ab6f78"}, + {file = "frozendict-2.3.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a4a1c8febc23f3c81c2b94d70268b5b760ed7e5e81c90c3baa22bf144db3d2f9"}, + {file = "frozendict-2.3.5-cp38-cp38-win_amd64.whl", hash = "sha256:210a59a5267ae79b5d92cd50310cd5bcb122f1783a3d9016ad6db9cc179d4fbe"}, + {file = "frozendict-2.3.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:21dd627c5bdcdf0743d49f7667dd186234baa85db91517de8cb80d3bda7018d9"}, + {file = "frozendict-2.3.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d58ca5f9094725c2f44b09fe4e71f7ddd250d5cdaca7219c674bd691373fed3a"}, + {file = "frozendict-2.3.5-cp39-cp39-win_amd64.whl", hash = "sha256:f407d9d661d77896b7a6dae6ab7545c913e65d23a312cf2893406432069408db"}, + {file = "frozendict-2.3.5.tar.gz", hash = "sha256:65d7e3995c9174b77d7d80514d7062381750491e112bbeb44323368baa3e636a"}, ] [[package]] @@ -1547,38 +1546,38 @@ files = [ [[package]] name = "mypy" -version = "1.0.0" +version = "1.0.1" description = "Optional static typing for Python" category = "dev" optional = false python-versions = ">=3.7" files = [ - {file = "mypy-1.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e0626db16705ab9f7fa6c249c017c887baf20738ce7f9129da162bb3075fc1af"}, - {file = "mypy-1.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1ace23f6bb4aec4604b86c4843276e8fa548d667dbbd0cb83a3ae14b18b2db6c"}, - {file = "mypy-1.0.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:87edfaf344c9401942883fad030909116aa77b0fa7e6e8e1c5407e14549afe9a"}, - {file = "mypy-1.0.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:0ab090d9240d6b4e99e1fa998c2d0aa5b29fc0fb06bd30e7ad6183c95fa07593"}, - {file = "mypy-1.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:7cc2c01dfc5a3cbddfa6c13f530ef3b95292f926329929001d45e124342cd6b7"}, - {file = "mypy-1.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:14d776869a3e6c89c17eb943100f7868f677703c8a4e00b3803918f86aafbc52"}, - {file = "mypy-1.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bb2782a036d9eb6b5a6efcdda0986774bf798beef86a62da86cb73e2a10b423d"}, - {file = "mypy-1.0.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5cfca124f0ac6707747544c127880893ad72a656e136adc935c8600740b21ff5"}, - {file = "mypy-1.0.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:8845125d0b7c57838a10fd8925b0f5f709d0e08568ce587cc862aacce453e3dd"}, - {file = "mypy-1.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:01b1b9e1ed40544ef486fa8ac022232ccc57109f379611633ede8e71630d07d2"}, - {file = "mypy-1.0.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:c7cf862aef988b5fbaa17764ad1d21b4831436701c7d2b653156a9497d92c83c"}, - {file = "mypy-1.0.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5cd187d92b6939617f1168a4fe68f68add749902c010e66fe574c165c742ed88"}, - {file = "mypy-1.0.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:4e5175026618c178dfba6188228b845b64131034ab3ba52acaffa8f6c361f805"}, - {file = "mypy-1.0.0-cp37-cp37m-win_amd64.whl", hash = "sha256:2f6ac8c87e046dc18c7d1d7f6653a66787a4555085b056fe2d599f1f1a2a2d21"}, - {file = "mypy-1.0.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:7306edca1c6f1b5fa0bc9aa645e6ac8393014fa82d0fa180d0ebc990ebe15964"}, - {file = "mypy-1.0.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:3cfad08f16a9c6611e6143485a93de0e1e13f48cfb90bcad7d5fde1c0cec3d36"}, - {file = "mypy-1.0.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:67cced7f15654710386e5c10b96608f1ee3d5c94ca1da5a2aad5889793a824c1"}, - {file = "mypy-1.0.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:a86b794e8a56ada65c573183756eac8ac5b8d3d59daf9d5ebd72ecdbb7867a43"}, - {file = "mypy-1.0.0-cp38-cp38-win_amd64.whl", hash = "sha256:50979d5efff8d4135d9db293c6cb2c42260e70fb010cbc697b1311a4d7a39ddb"}, - {file = "mypy-1.0.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3ae4c7a99e5153496243146a3baf33b9beff714464ca386b5f62daad601d87af"}, - {file = "mypy-1.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:5e398652d005a198a7f3c132426b33c6b85d98aa7dc852137a2a3be8890c4072"}, - {file = "mypy-1.0.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:be78077064d016bc1b639c2cbcc5be945b47b4261a4f4b7d8923f6c69c5c9457"}, - {file = "mypy-1.0.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:92024447a339400ea00ac228369cd242e988dd775640755fa4ac0c126e49bb74"}, - {file = "mypy-1.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:fe523fcbd52c05040c7bee370d66fee8373c5972171e4fbc323153433198592d"}, - {file = "mypy-1.0.0-py3-none-any.whl", hash = "sha256:2efa963bdddb27cb4a0d42545cd137a8d2b883bd181bbc4525b568ef6eca258f"}, - {file = "mypy-1.0.0.tar.gz", hash = "sha256:f34495079c8d9da05b183f9f7daec2878280c2ad7cc81da686ef0b484cea2ecf"}, + {file = "mypy-1.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:71a808334d3f41ef011faa5a5cd8153606df5fc0b56de5b2e89566c8093a0c9a"}, + {file = "mypy-1.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:920169f0184215eef19294fa86ea49ffd4635dedfdea2b57e45cb4ee85d5ccaf"}, + {file = "mypy-1.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:27a0f74a298769d9fdc8498fcb4f2beb86f0564bcdb1a37b58cbbe78e55cf8c0"}, + {file = "mypy-1.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:65b122a993d9c81ea0bfde7689b3365318a88bde952e4dfa1b3a8b4ac05d168b"}, + {file = "mypy-1.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:5deb252fd42a77add936b463033a59b8e48eb2eaec2976d76b6878d031933fe4"}, + {file = "mypy-1.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2013226d17f20468f34feddd6aae4635a55f79626549099354ce641bc7d40262"}, + {file = "mypy-1.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:48525aec92b47baed9b3380371ab8ab6e63a5aab317347dfe9e55e02aaad22e8"}, + {file = "mypy-1.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c96b8a0c019fe29040d520d9257d8c8f122a7343a8307bf8d6d4a43f5c5bfcc8"}, + {file = "mypy-1.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:448de661536d270ce04f2d7dddaa49b2fdba6e3bd8a83212164d4174ff43aa65"}, + {file = "mypy-1.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:d42a98e76070a365a1d1c220fcac8aa4ada12ae0db679cb4d910fabefc88b994"}, + {file = "mypy-1.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:e64f48c6176e243ad015e995de05af7f22bbe370dbb5b32bd6988438ec873919"}, + {file = "mypy-1.0.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5fdd63e4f50e3538617887e9aee91855368d9fc1dea30da743837b0df7373bc4"}, + {file = "mypy-1.0.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:dbeb24514c4acbc78d205f85dd0e800f34062efcc1f4a4857c57e4b4b8712bff"}, + {file = "mypy-1.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:a2948c40a7dd46c1c33765718936669dc1f628f134013b02ff5ac6c7ef6942bf"}, + {file = "mypy-1.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:5bc8d6bd3b274dd3846597855d96d38d947aedba18776aa998a8d46fabdaed76"}, + {file = "mypy-1.0.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:17455cda53eeee0a4adb6371a21dd3dbf465897de82843751cf822605d152c8c"}, + {file = "mypy-1.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e831662208055b006eef68392a768ff83596035ffd6d846786578ba1714ba8f6"}, + {file = "mypy-1.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:e60d0b09f62ae97a94605c3f73fd952395286cf3e3b9e7b97f60b01ddfbbda88"}, + {file = "mypy-1.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:0af4f0e20706aadf4e6f8f8dc5ab739089146b83fd53cb4a7e0e850ef3de0bb6"}, + {file = "mypy-1.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:24189f23dc66f83b839bd1cce2dfc356020dfc9a8bae03978477b15be61b062e"}, + {file = "mypy-1.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:93a85495fb13dc484251b4c1fd7a5ac370cd0d812bbfc3b39c1bafefe95275d5"}, + {file = "mypy-1.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5f546ac34093c6ce33f6278f7c88f0f147a4849386d3bf3ae193702f4fe31407"}, + {file = "mypy-1.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:c6c2ccb7af7154673c591189c3687b013122c5a891bb5651eca3db8e6c6c55bd"}, + {file = "mypy-1.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:15b5a824b58c7c822c51bc66308e759243c32631896743f030daf449fe3677f3"}, + {file = "mypy-1.0.1-py3-none-any.whl", hash = "sha256:eda5c8b9949ed411ff752b9a01adda31afe7eae1e53e946dbdf9db23865e66c4"}, + {file = "mypy-1.0.1.tar.gz", hash = "sha256:28cea5a6392bb43d266782983b5a4216c25544cd7d80be681a155ddcdafd152d"}, ] [package.dependencies] @@ -3044,4 +3043,4 @@ redis = ["hiredis", "redis"] [metadata] lock-version = "2.0" python-versions = ">=3.9,<3.12" -content-hash = "fc0b0183b433afdcea59bc0b8de982fde28a1c5efdbf9df98925dba8b61b63fa" +content-hash = "72eeb6d454820c45a046ee65d3623c8a0060314c81e57aecf4948f785d69e5c9" diff --git a/pyproject.toml b/pyproject.toml index bda38982f57f..eba0b4a66ac8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,7 +36,7 @@ include = [ requires = [ "setuptools", "poetry-core>=1.4.0", - "numpy>=1.24.1", + "numpy>=1.24.2", "Cython==3.0.0a11", ] build-backend = "poetry.core.masonry.api" @@ -51,7 +51,7 @@ cython = "==3.0.0a11" aiodns = "^3.0.0" aiohttp = "^3.8.4" click = "^8.1.3" -frozendict = "^2.3.4" +frozendict = "^2.3.5" fsspec = ">=2023.1.0" msgspec = "^0.13.1" numpy = "^1.24.2" From 2361600c1ca6fbc166ceccaec3b983c589b3085d Mon Sep 17 00:00:00 2001 From: Chris Sellers Date: Sat, 18 Feb 2023 09:56:48 +1100 Subject: [PATCH 79/81] Cleanup tests --- tests/unit_tests/common/test_common_events.py | 33 ++----------------- tests/unit_tests/data/test_data_client.py | 2 +- 2 files changed, 3 insertions(+), 32 deletions(-) diff --git a/tests/unit_tests/common/test_common_events.py b/tests/unit_tests/common/test_common_events.py index e75a43cd8e0a..a86989ab400d 100644 --- a/tests/unit_tests/common/test_common_events.py +++ b/tests/unit_tests/common/test_common_events.py @@ -12,7 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. # ------------------------------------------------------------------------------------------------- -from dataclasses import dataclass + import pytest @@ -57,7 +57,7 @@ def test_serializing_component_state_changed_with_unserializable_config_raises_h ): # Arrange - class MyType(ActorConfig): + class MyType(ActorConfig, frozen=True): values: list[int] config = {"key": MyType(values=[1, 2, 3])} @@ -103,32 +103,3 @@ def test_trading_state_changed(self): repr(event) == f"TradingStateChanged(trader_id=TESTER-000, state=HALTED, config={{'max_order_submit_rate': '100/00:00:01'}}, event_id={uuid}, ts_init=0)" # noqa ) - - @pytest.mark.skip(reason="msgspec no longer raises an exception") - def test_serializing_trading_state_changed_with_unserializable_config_raises_helpful_exception( - self, - ): - # Arrange - - @dataclass - class MyType: - values: list[int] - - config = {"key": MyType(values=[1, 2, 3])} - event = TradingStateChanged( - trader_id=TestIdStubs.trader_id(), - state=TradingState.HALTED, - config=config, - event_id=UUID4(), - ts_event=0, - ts_init=0, - ) - - # Act - with pytest.raises(TypeError) as e: - TradingStateChanged.to_dict(event) - - # Assert - expected = "Serialization failed: `Encoding objects of type MyType is unsupported`. You can register a new serializer for `MyType` through `nautilus_trader.config.backtest.register_json_encoding`." # noqa - msg = e.value.args[0] - assert msg == expected diff --git a/tests/unit_tests/data/test_data_client.py b/tests/unit_tests/data/test_data_client.py index 6c6a732b9bd3..abc9296fb2c0 100644 --- a/tests/unit_tests/data/test_data_client.py +++ b/tests/unit_tests/data/test_data_client.py @@ -168,7 +168,7 @@ class TestMarketDataClient: def setup(self): # Fixture Setup self.clock = TestClock() - self.logger = Logger(self.clock) + self.logger = Logger(self.clock, bypass=True) self.trader_id = TestIdStubs.trader_id() From 1381785191eb7125fd220e0e401a71dc34f120c8 Mon Sep 17 00:00:00 2001 From: Chris Sellers Date: Sat, 18 Feb 2023 09:57:17 +1100 Subject: [PATCH 80/81] Small Binance cleanup --- nautilus_trader/adapters/binance/common/execution.py | 7 +++++-- nautilus_trader/live/data_client.py | 8 ++++---- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/nautilus_trader/adapters/binance/common/execution.py b/nautilus_trader/adapters/binance/common/execution.py index 523318692135..93618ee665c4 100644 --- a/nautilus_trader/adapters/binance/common/execution.py +++ b/nautilus_trader/adapters/binance/common/execution.py @@ -350,14 +350,14 @@ def _get_cache_active_symbols(self) -> list[str]: async def _get_binance_position_status_reports( self, - symbol: str = None, + symbol: Optional[str] = None, ) -> list[str]: # Implement in child class raise NotImplementedError async def _get_binance_active_position_symbols( self, - symbol: str = None, + symbol: Optional[str] = None, ) -> list[str]: # Implement in child class raise NotImplementedError @@ -454,6 +454,9 @@ async def generate_trade_reports( # continue # if end is not None and timestamp > end: # continue + if trade.symbol is None: + self.log.warning(f"No symbol for trade {trade}.") + continue report = trade.parse_to_trade_report( account_id=self.account_id, instrument_id=self._get_cached_instrument_id(trade.symbol), diff --git a/nautilus_trader/live/data_client.py b/nautilus_trader/live/data_client.py index 3d4260de9f09..d657011d6c6b 100644 --- a/nautilus_trader/live/data_client.py +++ b/nautilus_trader/live/data_client.py @@ -445,7 +445,7 @@ def subscribe_order_book_deltas( instrument_id: InstrumentId, book_type: BookType, depth: Optional[int] = None, - kwargs: dict[str, Any] = None, + kwargs: Optional[dict[str, Any]] = None, ) -> None: self.create_task( self._subscribe_order_book_deltas( @@ -463,7 +463,7 @@ def subscribe_order_book_snapshots( instrument_id: InstrumentId, book_type: BookType, depth: Optional[int] = None, - kwargs: dict = None, + kwargs: Optional[dict[str, Any]] = None, ) -> None: self.create_task( self._subscribe_order_book_snapshots( @@ -707,7 +707,7 @@ async def _subscribe_order_book_deltas( instrument_id: InstrumentId, book_type: BookType, depth: Optional[int] = None, - kwargs: dict[str, Any] = None, + kwargs: Optional[dict[str, Any]] = None, ) -> None: raise NotImplementedError( # pragma: no cover "implement the `_subscribe_order_book_deltas` coroutine", # pragma: no cover @@ -718,7 +718,7 @@ async def _subscribe_order_book_snapshots( instrument_id: InstrumentId, book_type: BookType, depth: Optional[int] = None, - kwargs: dict[str, Any] = None, + kwargs: Optional[dict[str, Any]] = None, ) -> None: raise NotImplementedError( # pragma: no cover "implement the `_subscribe_order_book_snapshots` coroutine", # pragma: no cover From 7e0e9d0a6915e9f7d63aa99f7c1d7719cb93fee7 Mon Sep 17 00:00:00 2001 From: Chris Sellers Date: Sat, 18 Feb 2023 10:30:26 +1100 Subject: [PATCH 81/81] Improve typing --- nautilus_trader/config/common.py | 2 +- nautilus_trader/config/live.py | 5 ++--- nautilus_trader/live/node_builder.py | 4 ++-- nautilus_trader/system/kernel.py | 2 +- 4 files changed, 6 insertions(+), 7 deletions(-) diff --git a/nautilus_trader/config/common.py b/nautilus_trader/config/common.py index db28a4625552..9ccb1a338d13 100644 --- a/nautilus_trader/config/common.py +++ b/nautilus_trader/config/common.py @@ -513,7 +513,7 @@ def create(self): class ImportableConfig(NautilusConfig, frozen=True): """ - Represents an importable (typically live data or execution) client configuration. + Represents an importable (typically live data client or live execution client) configuration. """ path: str diff --git a/nautilus_trader/config/live.py b/nautilus_trader/config/live.py index d7f786ae60e1..a4d731a5909e 100644 --- a/nautilus_trader/config/live.py +++ b/nautilus_trader/config/live.py @@ -154,9 +154,9 @@ class TradingNodeConfig(NautilusKernelConfig, frozen=True): The live execution engine configuration. streaming : StreamingConfig, optional The configuration for streaming to feather files. - data_clients : dict[str, ImportableConfig], optional + data_clients : dict[str, ImportableConfig | LiveDataClientConfig], optional The data client configurations. - exec_clients : dict[str, ImportableConfig], optional + exec_clients : dict[str, ImportableConfig | LiveExecClientConfig], optional The execution client configurations. strategies : list[ImportableStrategyConfig] The strategy configurations for the node. @@ -178,7 +178,6 @@ class TradingNodeConfig(NautilusKernelConfig, frozen=True): The timeout for all engine clients to disconnect. timeout_post_stop : PositiveFloat (seconds) The timeout after stopping the node to await residual events before final shutdown. - """ environment: Environment = Environment.LIVE diff --git a/nautilus_trader/live/node_builder.py b/nautilus_trader/live/node_builder.py index 6f1d12153106..641d1e2df5e9 100644 --- a/nautilus_trader/live/node_builder.py +++ b/nautilus_trader/live/node_builder.py @@ -143,7 +143,7 @@ def build_data_clients(self, config: dict[str, ImportableConfig]): Parameters ---------- - config : dict[str, object] + config : dict[str, ImportableConfig | LiveDataClientConfig] The data clients configuration. """ @@ -191,7 +191,7 @@ def build_exec_clients(self, config: dict[str, ImportableConfig]): Parameters ---------- - config : dict[str, object] + config : dict[str, ImportableConfig | LiveExecClientConfig] The execution clients configuration. """ diff --git a/nautilus_trader/system/kernel.py b/nautilus_trader/system/kernel.py index ac0b43e66230..4601b88eb968 100644 --- a/nautilus_trader/system/kernel.py +++ b/nautilus_trader/system/kernel.py @@ -229,7 +229,7 @@ def __init__( # noqa (too complex) # Setup loop (if live) if environment == Environment.LIVE: - self._loop: asyncio.AbstractEventLoop = loop or asyncio.get_event_loop() + self._loop: Optional[asyncio.AbstractEventLoop] = loop or asyncio.get_event_loop() if loop is not None: self._executor = concurrent.futures.ThreadPoolExecutor() self._loop.set_default_executor(self.executor)