diff --git a/Cargo.lock b/Cargo.lock index bc6c40b9c..e588ccb0a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -338,6 +338,22 @@ version = "1.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" +[[package]] +name = "bitmex-client" +version = "0.1.0" +dependencies = [ + "anyhow", + "hex", + "reqwest", + "ring", + "serde", + "serde_json", + "serde_urlencoded", + "time 0.3.20", + "tokio", + "uuid", +] + [[package]] name = "bitmex-stream" version = "0.1.0" diff --git a/crates/bitmex-client/Cargo.toml b/crates/bitmex-client/Cargo.toml new file mode 100644 index 000000000..b4f8bc6de --- /dev/null +++ b/crates/bitmex-client/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "bitmex-client" +version = "0.1.0" +edition = "2021" + +[dependencies] +anyhow = "1" +hex = "0.4" +reqwest = { version = "0.11", features = ["json"] } +ring = "0.16" +serde = { version = "1", features = ["derive"] } +serde_json = { version = "1" } +serde_urlencoded = "0.7" +time = { version = "0.3", features = ["serde", "serde-well-known"] } +tokio = { version = "1", features = ["macros", "rt-multi-thread"] } +uuid = { version = "1.1", features = ["serde"] } diff --git a/crates/bitmex-client/examples/example.rs b/crates/bitmex-client/examples/example.rs new file mode 100644 index 000000000..8459c3ee4 --- /dev/null +++ b/crates/bitmex-client/examples/example.rs @@ -0,0 +1,26 @@ +use bitmex_client::client::Client; +use bitmex_client::models::ContractSymbol; +use bitmex_client::models::Network; +use bitmex_client::models::Side; + +#[tokio::main] +async fn main() { + let api_key = "some_api_key"; + let api_secret = "some_secret"; + + let client = Client::new(Network::Testnet).with_credentials(api_key, api_secret); + let _order = client + .create_order( + ContractSymbol::XbtUsd, + 100, + Side::Buy, + Some("example".to_string()), + ) + .await + .expect("To be able to post order"); + + let _positions = client + .positions() + .await + .expect("To be able to get positions"); +} diff --git a/crates/bitmex-client/src/client.rs b/crates/bitmex-client/src/client.rs new file mode 100644 index 000000000..b223b3986 --- /dev/null +++ b/crates/bitmex-client/src/client.rs @@ -0,0 +1,265 @@ +use crate::models::ContractSymbol; +use crate::models::GetPositionRequest; +use crate::models::Network; +use crate::models::OrdType; +use crate::models::Order; +use crate::models::Position; +use crate::models::PostOrderRequest; +use crate::models::Request; +use crate::models::Side; +use anyhow::bail; +use anyhow::Result; +use hex::encode as hexify; +use reqwest::Method; +use reqwest::Response; +use reqwest::Url; +use reqwest::{self}; +use ring::hmac; +use serde::de::DeserializeOwned; +use serde::Deserialize; +use serde::Serialize; +use serde_json::from_str; +use serde_json::to_string as to_jstring; +use serde_urlencoded::to_string as to_ustring; +use std::ops::Add; +use std::time::Duration; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; + +#[derive(Clone)] +pub struct Client { + url: String, + credentials: Option, + client: reqwest::Client, +} + +impl Client { + pub fn new(network: Network) -> Self { + Self { + client: reqwest::Client::new(), + url: network.to_url(), + credentials: None, + } + } + + pub fn with_credentials(self, api_key: impl ToString, secret: impl ToString) -> Self { + Self { + credentials: Some(Credentials::new(api_key.to_string(), secret.to_string())), + ..self + } + } + + pub fn is_signed_in(&self) -> bool { + self.credentials.is_some() + } + + pub async fn create_order( + &self, + symbol: ContractSymbol, + quantity: i32, + side: Side, + text: Option, + ) -> Result { + let order = self + .send_request(PostOrderRequest { + symbol, + side: Some(side), + order_qty: Some(quantity), + ord_type: Some(OrdType::Market), + text, + }) + .await?; + Ok(order) + } + + /// Retrieve the position information for all contract symbols. + pub async fn positions(&self) -> Result> { + let positions = self.send_request(GetPositionRequest).await?; + Ok(positions) + } + + async fn send_request(&self, req: R) -> Result + where + R: Request, + R::Response: DeserializeOwned, + { + let url = format!("{}{}", self.url, R::ENDPOINT); + let mut url = Url::parse(&url)?; + + if matches!(R::METHOD, Method::GET | Method::DELETE) && R::HAS_PAYLOAD { + url.set_query(Some(&to_ustring(&req)?)); + } + + let body = match R::METHOD { + Method::PUT | Method::POST => to_jstring(&req)?, + _ => "".to_string(), + }; + + let mut builder = self.client.request(R::METHOD, url.clone()); + + if R::SIGNED { + let credentials = match &self.credentials { + None => { + bail!("Bitmex client not signed in") + } + Some(credentials) => credentials, + }; + + let start = SystemTime::now(); + let expires = start + .duration_since(UNIX_EPOCH) + .expect("Time went backwards") + .add(Duration::from_secs(5)) + .as_secs(); + let (key, signature) = credentials.signature(R::METHOD, expires, &url, &body); + builder = builder + .header("api-expires", expires) + .header("api-key", key) + .header("api-signature", signature) + } + + let resp = builder + .header("content-type", "application/json") + .body(body) + .send() + .await?; + + let response = self.handle_response(resp).await?; + + Ok(response) + } + + async fn handle_response(&self, resp: Response) -> Result { + let status = resp.status(); + let content = resp.text().await?; + if status.is_success() { + match from_str::(&content) { + Ok(ret) => Ok(ret), + Err(e) => { + bail!("Cannot deserialize '{}'. '{}'", content, e); + } + } + } else { + match from_str::(&content) { + Ok(ret) => bail!("Bitmex error: {:?}", ret), + Err(e) => { + bail!("Cannot deserialize error '{}'. '{}'", content, e); + } + } + } + } +} + +#[derive(Clone, Debug)] +struct Credentials { + api_key: String, + secret: String, +} + +impl Credentials { + fn new(api_key: impl Into, secret: impl Into) -> Self { + Self { + api_key: api_key.into(), + secret: secret.into(), + } + } + + fn signature(&self, method: Method, expires: u64, url: &Url, body: &str) -> (&str, String) { + // Signature: hex(HMAC_SHA256(apiSecret, verb + path + expires + data)) + let signed_key = hmac::Key::new(hmac::HMAC_SHA256, self.secret.as_bytes()); + let sign_message = match url.query() { + Some(query) => format!( + "{}{}?{}{}{}", + method.as_str(), + url.path(), + query, + expires, + body + ), + None => format!("{}{}{}{}", method.as_str(), url.path(), expires, body), + }; + + let signature = hexify(hmac::sign(&signed_key, sign_message.as_bytes())); + (self.api_key.as_str(), signature) + } +} + +#[cfg(test)] +mod test { + use super::Credentials; + use anyhow::Result; + use reqwest::Method; + use reqwest::Url; + + #[test] + fn test_signature_get() -> Result<()> { + let tr = Credentials::new( + "LAqUlngMIQkIUjXMUreyu3qn", + "chNOOS4KvNXR_Xq4k4c9qsfoKWvnDecLATCRlcBwyKDYnWgO", + ); + let (_, sig) = tr.signature( + Method::GET, + 1518064236, + &Url::parse("http://a.com/api/v1/instrument")?, + "", + ); + assert_eq!( + sig, + "c7682d435d0cfe87c16098df34ef2eb5a549d4c5a3c2b1f0f77b8af73423bf00" + ); + Ok(()) + } + + #[test] + fn test_signature_get_param() -> Result<()> { + let tr = Credentials::new( + "LAqUlngMIQkIUjXMUreyu3qn", + "chNOOS4KvNXR_Xq4k4c9qsfoKWvnDecLATCRlcBwyKDYnWgO", + ); + let (_, sig) = tr.signature( + Method::GET, + 1518064237, + &Url::parse_with_params( + "http://a.com/api/v1/instrument", + &[("filter", r#"{"symbol": "XBTM15"}"#)], + )?, + "", + ); + assert_eq!( + sig, + "e2f422547eecb5b3cb29ade2127e21b858b235b386bfa45e1c1756eb3383919f" + ); + Ok(()) + } + + #[test] + fn test_signature_post() -> Result<()> { + let credentials = Credentials::new( + "LAqUlngMIQkIUjXMUreyu3qn", + "chNOOS4KvNXR_Xq4k4c9qsfoKWvnDecLATCRlcBwyKDYnWgO", + ); + let (_, sig) = credentials.signature( + Method::POST, + 1518064238, + &Url::parse("http://a.com/api/v1/order")?, + r#"{"symbol":"XBTM15","price":219.0,"clOrdID":"mm_bitmex_1a/oemUeQ4CAJZgP3fjHsA","orderQty":98}"#, + ); + assert_eq!( + sig, + "1749cd2ccae4aa49048ae09f0b95110cee706e0944e6a14ad0b3a8cb45bd336b" + ); + Ok(()) + } +} + +// The error response from bitmex; +#[derive(Deserialize, Serialize, Debug, Clone)] +pub(crate) struct BitMEXErrorResponse { + pub(crate) error: BitMEXErrorMessage, +} + +#[derive(Deserialize, Serialize, Debug, Clone)] +pub(crate) struct BitMEXErrorMessage { + pub(crate) message: String, + pub(crate) name: String, +} diff --git a/crates/bitmex-client/src/lib.rs b/crates/bitmex-client/src/lib.rs new file mode 100644 index 000000000..04f3e94ba --- /dev/null +++ b/crates/bitmex-client/src/lib.rs @@ -0,0 +1,2 @@ +pub mod client; +pub mod models; diff --git a/crates/bitmex-client/src/models.rs b/crates/bitmex-client/src/models.rs new file mode 100644 index 000000000..be5e45abb --- /dev/null +++ b/crates/bitmex-client/src/models.rs @@ -0,0 +1,221 @@ +use reqwest::Method; +use serde::de::DeserializeOwned; +use serde::Deserialize; +use serde::Serialize; +use time::OffsetDateTime; +use uuid::Uuid; + +pub enum Network { + Mainnet, + Testnet, +} + +impl Network { + pub fn to_url(&self) -> String { + match self { + Network::Mainnet => "https://www.bitmex.com/api/v1".to_string(), + Network::Testnet => "https://testnet.bitmex.com/api/v1".to_string(), + } + } +} + +pub trait Request: Serialize { + const METHOD: Method; + const SIGNED: bool = false; + const ENDPOINT: &'static str; + const HAS_PAYLOAD: bool = true; + type Response: DeserializeOwned; + + #[inline] + fn no_payload(&self) -> bool { + !Self::HAS_PAYLOAD + } +} + +/// Placement, Cancellation, Amending, and History +#[derive(Clone, Debug, Deserialize)] +pub struct Order { + #[serde(rename = "orderID")] + pub order_id: Uuid, + pub account: Option, + pub symbol: Option, + pub side: Option, + #[serde(rename = "orderQty")] + pub order_qty: Option, + pub price: Option, + #[serde(rename = "displayQty")] + pub display_qty: Option, + #[serde(rename = "pegPriceType")] + pub peg_price_type: Option, + #[serde(rename = "ordType")] + pub ord_type: Option, + #[serde(rename = "ordStatus")] + pub ord_status: Option, + pub text: Option, + #[serde(rename = "transactTime", with = "time::serde::rfc3339::option")] + pub transact_time: Option, + #[serde(with = "time::serde::rfc3339::option")] + pub timestamp: Option, +} + +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] +pub enum Side { + Buy, + Sell, + #[serde(rename = "")] + Unknown, // BitMEX sometimes has empty side due to unknown reason +} + +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] +pub enum OrderStatus { + Filled, + Open, + New, + #[serde(other)] + Unknown, +} + +#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)] +pub enum ExecType { + Funding, + Trade, + #[serde(other)] + Unknown, +} + +/// http://fixwiki.org/fixwiki/PegPriceType +#[derive(Clone, Copy, Debug, Deserialize, Serialize)] +pub enum PegPriceType { + LastPeg, + OpeningPeg, + MidPricePeg, + MarketPeg, + PrimaryPeg, + PegToVWAP, + TrailingStopPeg, + PegToLimitPrice, + ShortSaleMinPricePeg, + #[serde(rename = "")] + Unknown, // BitMEX sometimes has empty due to unknown reason +} + +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] +pub enum OrdType { + Market, + Limit, + Stop, + StopLimit, + MarketIfTouched, + LimitIfTouched, + MarketWithLeftOverAsLimit, + Pegged, +} + +/// https://www.onixs.biz/fix-dictionary/5.0.SP2/tagNum_59.html +#[derive(Clone, Copy, Debug, Deserialize, Serialize)] +pub enum TimeInForce { + Day, + GoodTillCancel, + AtTheOpening, + ImmediateOrCancel, + FillOrKill, + GoodTillCrossing, + GoodTillDate, + AtTheClose, + GoodThroughCrossing, + AtCrossing, +} + +#[derive(Clone, Copy, Debug, Deserialize, Serialize)] +pub enum ExecInst { + ParticipateDoNotInitiate, + AllOrNone, + MarkPrice, + IndexPrice, + LastPrice, + Close, + ReduceOnly, + Fixed, + #[serde(rename = "")] + Unknown, // BitMEX sometimes has empty due to unknown reason +} + +#[derive(Clone, Copy, Debug, Deserialize, Serialize)] +pub enum ContingencyType { + OneCancelsTheOther, + OneTriggersTheOther, + OneUpdatesTheOtherAbsolute, + OneUpdatesTheOtherProportional, + #[serde(rename = "")] + Unknown, // BitMEX sometimes has empty due to unknown reason +} + +/// Create a new order. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct PostOrderRequest { + /// Instrument symbol. e.g. 'XBTUSD'. + pub symbol: ContractSymbol, + /// Order side. Valid options: Buy, Sell. Defaults to 'Buy' unless `orderQty` is negative. + pub side: Option, + /// Order quantity in units of the instrument (i.e. contracts). + #[serde(rename = "orderQty", skip_serializing_if = "Option::is_none")] + pub order_qty: Option, + /// Order type. Valid options: Market, Limit, Stop, StopLimit, MarketIfTouched, LimitIfTouched, + /// Pegged. Defaults to 'Limit' when `price` is specified. Defaults to 'Stop' when `stopPx` is + /// specified. Defaults to 'StopLimit' when `price` and `stopPx` are specified. + #[serde(rename = "ordType", skip_serializing_if = "Option::is_none")] + pub ord_type: Option, + /// Optional order annotation. e.g. 'Take profit'. + #[serde(skip_serializing_if = "Option::is_none")] + pub text: Option, +} + +impl Request for PostOrderRequest { + const METHOD: Method = Method::POST; + const SIGNED: bool = true; + const ENDPOINT: &'static str = "/order"; + const HAS_PAYLOAD: bool = true; + type Response = Order; +} + +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Hash, Eq)] +pub enum ContractSymbol { + #[serde(rename = "XBTUSD")] + XbtUsd, +} + +/// Get your positions. +#[derive(Clone, Debug, Serialize, Default)] +pub struct GetPositionRequest; + +impl Request for GetPositionRequest { + const METHOD: Method = Method::GET; + const SIGNED: bool = true; + const ENDPOINT: &'static str = "/position"; + const HAS_PAYLOAD: bool = true; + type Response = Vec; +} + +/// Summary of Open and Closed Positions +#[derive(Clone, Debug, Deserialize)] +pub struct Position { + pub account: i64, + pub symbol: ContractSymbol, + pub currency: String, + pub underlying: Option, + #[serde(rename = "quoteCurrency")] + pub quote_currency: Option, + pub leverage: Option, + #[serde(rename = "crossMargin")] + pub cross_margin: Option, + #[serde(rename = "currentQty")] + pub current_qty: Option, + #[serde(rename = "maintMargin")] + pub maint_margin: Option, + #[serde(rename = "unrealisedPnl")] + pub unrealised_pnl: Option, + #[serde(rename = "liquidationPrice")] + pub liquidation_price: Option, + #[serde(with = "time::serde::rfc3339::option")] + pub timestamp: Option, +}