From 6e663ceb476c496c80caa95828b739ddecddd850 Mon Sep 17 00:00:00 2001 From: Filip Macek Date: Fri, 20 Oct 2023 09:34:15 +0200 Subject: [PATCH] Add CryptoFuture instrument for Rust (#1276) --- nautilus_core/Cargo.lock | 1 + nautilus_core/model/Cargo.toml | 1 + .../model/src/instruments/crypto_future.rs | 261 ++++++++++++++++-- nautilus_trader/test_kit/rust/instruments.py | 37 +++ .../instruments/test_crypto_future_pyo3.py | 58 ++++ 5 files changed, 330 insertions(+), 28 deletions(-) create mode 100644 tests/unit_tests/model/instruments/test_crypto_future_pyo3.py diff --git a/nautilus_core/Cargo.lock b/nautilus_core/Cargo.lock index b62cf7651f19..5eb2f35683c5 100644 --- a/nautilus_core/Cargo.lock +++ b/nautilus_core/Cargo.lock @@ -2066,6 +2066,7 @@ version = "0.10.0" dependencies = [ "anyhow", "cbindgen", + "chrono", "criterion", "derive_builder", "evalexpr", diff --git a/nautilus_core/model/Cargo.toml b/nautilus_core/model/Cargo.toml index eb29166004c3..ac655b32c86a 100644 --- a/nautilus_core/model/Cargo.toml +++ b/nautilus_core/model/Cargo.toml @@ -23,6 +23,7 @@ serde_json = { workspace = true } strum = { workspace = true } thiserror = { workspace = true } ustr = { workspace = true } +chrono = { workspace = true } derive_builder = "0.12.0" evalexpr = "11.1.0" indexmap = "2.0.2" diff --git a/nautilus_core/model/src/instruments/crypto_future.rs b/nautilus_core/model/src/instruments/crypto_future.rs index 53d2ee95e84a..926865ad34f3 100644 --- a/nautilus_core/model/src/instruments/crypto_future.rs +++ b/nautilus_core/model/src/instruments/crypto_future.rs @@ -15,18 +15,25 @@ #![allow(dead_code)] // Allow for development -use std::hash::{Hash, Hasher}; +use std::{ + collections::hash_map::DefaultHasher, + hash::{Hash, Hasher}, +}; -use nautilus_core::time::UnixNanos; -use pyo3::prelude::*; -use rust_decimal::Decimal; +use anyhow::Result; +use nautilus_core::{ + python::{serialization::from_dict_pyo3, to_pyvalue_err}, + time::UnixNanos, +}; +use pyo3::{basic::CompareOp, prelude::*, types::PyDict}; +use rust_decimal::{prelude::ToPrimitive, Decimal}; use serde::{Deserialize, Serialize}; use super::Instrument; use crate::{ enums::{AssetClass, AssetType}, identifiers::{instrument_id::InstrumentId, symbol::Symbol}, - types::{currency::Currency, price::Price, quantity::Quantity}, + types::{currency::Currency, money::Money, price::Price, quantity::Quantity}, }; #[repr(C)] @@ -38,67 +45,75 @@ use crate::{ pub struct CryptoFuture { pub id: InstrumentId, pub raw_symbol: Symbol, - pub underlying: String, + pub underlying: Currency, + pub quote_currency: Currency, + pub settlement_currency: Currency, pub expiration: UnixNanos, - pub currency: Currency, pub price_precision: u8, pub size_precision: u8, pub price_increment: Price, pub size_increment: Quantity, + pub margin_init: Decimal, + pub margin_maint: Decimal, + pub maker_fee: Decimal, + pub taker_fee: Decimal, pub lot_size: Option, pub max_quantity: Option, pub min_quantity: Option, + pub max_notional: Option, + pub min_notional: Option, pub max_price: Option, pub min_price: Option, - pub margin_init: Decimal, - pub margin_maint: Decimal, - pub maker_fee: Decimal, - pub taker_fee: Decimal, } impl CryptoFuture { - #[must_use] #[allow(clippy::too_many_arguments)] pub fn new( id: InstrumentId, raw_symbol: Symbol, - underlying: String, + underlying: Currency, + quote_currency: Currency, + settlement_currency: Currency, expiration: UnixNanos, - currency: Currency, price_precision: u8, size_precision: u8, price_increment: Price, size_increment: Quantity, + margin_init: Decimal, + margin_maint: Decimal, + maker_fee: Decimal, + taker_fee: Decimal, lot_size: Option, max_quantity: Option, min_quantity: Option, + max_notional: Option, + min_notional: Option, max_price: Option, min_price: Option, - margin_init: Decimal, - margin_maint: Decimal, - maker_fee: Decimal, - taker_fee: Decimal, - ) -> Self { - Self { + ) -> Result { + Ok(Self { id, raw_symbol, underlying, + quote_currency, + settlement_currency, expiration, - currency, price_precision, size_precision, price_increment, size_increment, + margin_init, + margin_maint, + maker_fee, + taker_fee, lot_size, max_quantity, min_quantity, + max_notional, + min_notional, max_price, min_price, - margin_init, - margin_maint, - maker_fee, - taker_fee, - } + }) } } @@ -134,7 +149,7 @@ impl Instrument for CryptoFuture { } fn quote_currency(&self) -> &Currency { - &self.currency + &self.quote_currency } fn base_currency(&self) -> Option<&Currency> { @@ -142,7 +157,7 @@ impl Instrument for CryptoFuture { } fn settlement_currency(&self) -> &Currency { - &self.currency + &self.settlement_currency } fn is_inverse(&self) -> bool { @@ -206,3 +221,193 @@ impl Instrument for CryptoFuture { self.taker_fee } } + +#[cfg(feature = "python")] +#[pymethods] +impl CryptoFuture { + #[allow(clippy::too_many_arguments)] + #[new] + fn py_new( + id: InstrumentId, + raw_symbol: Symbol, + underlying: Currency, + quote_currency: Currency, + settlement_currency: Currency, + expiration: UnixNanos, + price_precision: u8, + size_precision: u8, + price_increment: Price, + size_increment: Quantity, + margin_init: Decimal, + margin_maint: Decimal, + maker_fee: Decimal, + taker_fee: Decimal, + lot_size: Option, + max_quantity: Option, + min_quantity: Option, + max_notional: Option, + min_notional: Option, + max_price: Option, + min_price: Option, + ) -> PyResult { + Self::new( + id, + raw_symbol, + underlying, + quote_currency, + settlement_currency, + expiration, + price_precision, + size_precision, + price_increment, + size_increment, + margin_init, + margin_maint, + maker_fee, + taker_fee, + lot_size, + max_quantity, + min_quantity, + max_notional, + min_notional, + max_price, + min_price, + ) + .map_err(to_pyvalue_err) + } + + fn __hash__(&self) -> isize { + let mut hasher = DefaultHasher::new(); + self.hash(&mut hasher); + hasher.finish() as isize + } + + fn __richcmp__(&self, other: &Self, op: CompareOp, py: Python<'_>) -> Py { + match op { + CompareOp::Eq => self.eq(other).into_py(py), + _ => panic!("Not implemented"), + } + } + + #[staticmethod] + #[pyo3(name = "from_dict")] + fn py_from_dict(py: Python<'_>, values: Py) -> PyResult { + from_dict_pyo3(py, values) + } + #[pyo3(name = "to_dict")] + fn py_to_dict(&self, py: Python<'_>) -> PyResult { + let dict = PyDict::new(py); + dict.set_item("type", stringify!(CryptoPerpetual))?; + dict.set_item("id", self.id.to_string())?; + dict.set_item("raw_symbol", self.raw_symbol.to_string())?; + dict.set_item("underlying", self.underlying.code.to_string())?; + dict.set_item("quote_currency", self.quote_currency.code.to_string())?; + dict.set_item( + "settlement_currency", + self.settlement_currency.code.to_string(), + )?; + dict.set_item("expiration", self.expiration.to_i64())?; + dict.set_item("price_precision", self.price_precision)?; + dict.set_item("size_precision", self.size_precision)?; + dict.set_item("price_increment", self.price_increment.to_string())?; + dict.set_item("size_increment", self.size_increment.to_string())?; + dict.set_item("margin_init", self.margin_init.to_f64())?; + dict.set_item("margin_maint", self.margin_maint.to_f64())?; + dict.set_item("maker_fee", self.margin_init.to_f64())?; + dict.set_item("taker_fee", self.margin_init.to_f64())?; + match self.lot_size { + Some(value) => dict.set_item("lot_size", value.to_string())?, + None => dict.set_item("lot_size", py.None())?, + } + match self.max_quantity { + Some(value) => dict.set_item("max_quantity", value.to_string())?, + None => dict.set_item("max_quantity", py.None())?, + } + match self.min_quantity { + Some(value) => dict.set_item("min_quantity", value.to_string())?, + None => dict.set_item("min_quantity", py.None())?, + } + match self.max_notional { + Some(value) => dict.set_item("max_notional", value.to_string())?, + None => dict.set_item("max_notional", py.None())?, + } + match self.min_notional { + Some(value) => dict.set_item("min_notional", value.to_string())?, + None => dict.set_item("min_notional", py.None())?, + } + match self.max_price { + Some(value) => dict.set_item("max_price", value.to_string())?, + None => dict.set_item("max_price", py.None())?, + } + match self.min_price { + Some(value) => dict.set_item("min_price", value.to_string())?, + None => dict.set_item("min_price", py.None())?, + } + Ok(dict.into()) + } +} + +//////////////////////////////////////////////////////////////////////////////// +// Stubs +//////////////////////////////////////////////////////////////////////////////// +#[cfg(test)] +pub mod stubs { + use std::str::FromStr; + + use chrono::{TimeZone, Utc}; + use nautilus_core::time::UnixNanos; + use rstest::fixture; + use rust_decimal::Decimal; + + use crate::{ + identifiers::{instrument_id::InstrumentId, symbol::Symbol}, + instruments::crypto_future::CryptoFuture, + types::{currency::Currency, money::Money, price::Price, quantity::Quantity}, + }; + + #[fixture] + pub fn crypto_future_btcusdt() -> CryptoFuture { + let expiration = Utc.with_ymd_and_hms(2014, 7, 8, 0, 0, 0).unwrap(); + CryptoFuture::new( + InstrumentId::from("ETHUSDT-123.BINANCE"), + Symbol::from("BTCUSDT"), + Currency::from("BTC"), + Currency::from("USDT"), + Currency::from("USDT"), + expiration.timestamp_nanos_opt().unwrap() as UnixNanos, + 2, + 6, + Price::from("0.01"), + Quantity::from("0.000001"), + Decimal::from_str("0.0").unwrap(), + Decimal::from_str("0.0").unwrap(), + Decimal::from_str("0.001").unwrap(), + Decimal::from_str("0.001").unwrap(), + None, + Some(Quantity::from("9000.0")), + Some(Quantity::from("0.000001")), + None, + Some(Money::new(10.00, Currency::from("USDT")).unwrap()), + Some(Price::from("1000000.00")), + Some(Price::from("0.01")), + ) + .unwrap() + } +} + +//////////////////////////////////////////////////////////////////////////////// +// Tests +//////////////////////////////////////////////////////////////////////////////// +#[cfg(test)] +mod tests { + use rstest::rstest; + + use super::stubs::*; + use crate::instruments::crypto_future::CryptoFuture; + + #[rstest] + fn test_equality(crypto_future_btcusdt: CryptoFuture) { + let cloned = crypto_future_btcusdt.clone(); + assert_eq!(crypto_future_btcusdt, cloned); + } +} diff --git a/nautilus_trader/test_kit/rust/instruments.py b/nautilus_trader/test_kit/rust/instruments.py index 95cd6e4ed3b8..ed554e67ba6b 100644 --- a/nautilus_trader/test_kit/rust/instruments.py +++ b/nautilus_trader/test_kit/rust/instruments.py @@ -13,6 +13,13 @@ # limitations under the License. # ------------------------------------------------------------------------------------------------- +from datetime import datetime +from typing import Optional + +import pandas as pd +import pytz + +from nautilus_trader.core.nautilus_pyo3.model import CryptoFuture from nautilus_trader.core.nautilus_pyo3.model import CryptoPerpetual from nautilus_trader.core.nautilus_pyo3.model import InstrumentId from nautilus_trader.core.nautilus_pyo3.model import Money @@ -47,3 +54,33 @@ def ethusdt_perp_binance() -> CryptoPerpetual: Price.from_str("15000.0"), Price.from_str("1.0"), ) + + @staticmethod + def btcusdt_future_binance(expiry: Optional[pd.Timestamp] = None) -> CryptoFuture: + if expiry is None: + expiry = pd.Timestamp(datetime(2022, 3, 25), tz=pytz.UTC) + nanos_expiry = int(expiry.timestamp() * 1e9) + instrument_id_str = f"BTCUSDT_{expiry.strftime('%y%m%d')}.BINANCE" + return CryptoFuture( + InstrumentId.from_str(instrument_id_str), + Symbol("BTCUSDT"), + TestTypesProviderPyo3.currency_btc(), + TestTypesProviderPyo3.currency_usdt(), + TestTypesProviderPyo3.currency_usdt(), + nanos_expiry, + 2, + 6, + Price.from_str("0.01"), + Quantity.from_str("0.000001"), + 0.0, + 0.0, + 0.001, + 0.001, + None, + Quantity.from_str("9000"), + Quantity.from_str("0.00001"), + None, + Money(10.0, TestTypesProviderPyo3.currency_usdt()), + Price.from_str("1000000.0"), + Price.from_str("0.01"), + ) diff --git a/tests/unit_tests/model/instruments/test_crypto_future_pyo3.py b/tests/unit_tests/model/instruments/test_crypto_future_pyo3.py new file mode 100644 index 000000000000..1638a4c81e8f --- /dev/null +++ b/tests/unit_tests/model/instruments/test_crypto_future_pyo3.py @@ -0,0 +1,58 @@ +# ------------------------------------------------------------------------------------------------- +# 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.core.nautilus_pyo3.model import CryptoFuture +from nautilus_trader.test_kit.rust.instruments import TestInstrumentProviderPyo3 + + +crypto_future_btcusdt = TestInstrumentProviderPyo3.btcusdt_future_binance() + + +class TestCryptoFuture: + def test_equality(self): + item_1 = TestInstrumentProviderPyo3.btcusdt_future_binance() + item_2 = TestInstrumentProviderPyo3.btcusdt_future_binance() + assert item_1 == item_2 + + def test_hash(self): + assert hash(crypto_future_btcusdt) == hash(crypto_future_btcusdt) + + def test_to_dict(self): + result = crypto_future_btcusdt.to_dict() + assert CryptoFuture.from_dict(result) == crypto_future_btcusdt + assert result == { + "type": "CryptoPerpetual", + "id": "BTCUSDT_220325.BINANCE", + "raw_symbol": "BTCUSDT", + "underlying": "BTC", + "quote_currency": "USDT", + "settlement_currency": "USDT", + "expiration": 1648166400000000000, + "price_precision": 2, + "size_precision": 6, + "price_increment": "0.01", + "size_increment": "0.000001", + "margin_maint": 0.0, + "margin_init": 0.0, + "maker_fee": 0.0, + "taker_fee": 0.0, + "lot_size": None, + "max_notional": None, + "max_price": "1000000.0", + "max_quantity": "9000", + "min_notional": "10.00000000 USDT", + "min_price": "0.01", + "min_quantity": "0.00001", + }