Easily connect to Binance WebSocket streams with Rust

In this article, we will look at how to connect to Binance WebSocket streams with Rust. We will be using the tungstenite library to make a WebSocket connection. For this tutorial, we will only use the public market data channels, so there is no need to register for an API key.

Go to my Github to find the complete project here.

This article is one of the first steps in a series of articles for building a triangle arbitrage dashboard using Binance cryptocurrency trading streams.

Other articles in this series:

I also have a similar article for connecting to the KuCoin futures WebSocket streams: Kucoin API with Rust how to get symbol ticker data.

Prerequisites

To follow along with the tutorial a basic understanding of Rust is required to follow along with this tutorial, although some concepts are explained in this tutorial, I do not really go into depth.

Setting up the Binance Websocket project

To start our project to connect to Binance WebSocket streams with rust, we first use the cargo cli to create a new project:

cargo new binance-websocket-tutorial

Add the required dependencies to the cargo.toml file:

[dependencies]
serde = { version = "1.0", features = ["derive"]}
serde_json = "1.0"
tungstenite = { version="0.14.0", features = ["rustls-tls"]}
url = "2.1.0"

We will use serde to deserialize the data coming from the WebSocket stream into struct instances. We will use tungstenite to create and maintain the WebSocket connection. Since Binance uses a secure connection we have to make sure to enable TLS in the tungstenite features. Lastly, url is used to parse the connection string.

Making a WebSocket connection

We will be connecting to the public market data streams, so there is no need to have an API key.

First get the necessary things into the namespace:

use tungstenite::connect;
use url::Url;

Add a static variable for the Binance WebSocket API endpoint:

static BINANCE_WS_API: &str = "wss://stream.binance.com:9443";

Before we subscribe to a stream and get into processing the data, we’ll do the bare minimum to test the connection:

use tungstenite::connect;
use url::Url;

static BINANCE_WS_API: &str = "wss://stream.binance.com:9443";
fn main() {
    let binance_url = format!("{}/ws", BINANCE_WS_API);
    let (mut socket, response) =
        connect(Url::parse(&binance_url).unwrap()).expect("Can't connect.");

    println!("Connected to binance stream.");
    println!("HTTP status code: {}", response.status());
    println!("Response headers:");
    for (ref header, header_value) in response.headers() {
        println!("- {}: {:?}", header, header_value);
    }
}

Running this program with cargo run should result in output similar to:

Connected to binance stream.
HTTP status code: 101 Switching Protocols
Response headers:
- date: "<date time>"
- connection: "upgrade"
- upgrade: "websocket"
- sec-websocket-accept: "BIVXGBnpvA2/22qEP/ZZuZaY4kU="

Congratulations! We have a connection with the Binance WebSocket stream. In the next section, we will subscribe to a stream and process the data.

Processing Binance WebSocket stream data raw

We will subscribe to a single raw stream for ETH-BTC partial book depth, we do this by using the endpoint /ws/ethbtc@depth5@100ms. These parameters indicate that we will get the top 5 bids and asks for ETH-BTC every 100ms. So, let’s change the binance_url:

let binance_url = format!("{}/ws/ethbtc@depth5@100ms", BINANCE_WS_API);

We will process the messages in a simple infinite loop and using serde.

loop {
    let msg = socket.read_message().expect("Error reading message");
    let msg = match msg {
        tungstenite::Message::Text(s) => s,
        _ => {
             panic!("Error getting text");
        }
     };

     let parsed_data: serde_json::Value = serde_json::from_str(&msg).expect("Unable to parse message");
     println!("{:?}", parsed_data);
}

When we run the program it will result in the JSON data being printed to the terminal that looks like this:

Object({"asks": Array([Array([String("0.06104200"), String("2.50000000")]), Array([String("0.06104300"), String("20.00000000")]), Array([String("0.06104500"), String("0.28000000")]), Array([String("0.06104600"), String("7.57800000")]), Array([String("0.06104800"), String("14.91300000")])]), "bids": Array([Array([String("0.06104100"), String("6.62100000")]), Array([String("0.06103800"), String("3.14700000")]), Array([String("0.06103700"), String("36.36800000")]), Array([String("0.06103300"), String("0.00900000")]), Array([String("0.06103200"), String("0.15600000")])]), "lastUpdateId": Number(3641039042)})

This is just a dump of the data directly to the std out. Let’s make it a little bit easier to look at. If we wanted to print some data about the lowest ask, for example, we could do it like this:

println!("best ask: {}, ask size: {}", parsed_data["asks"][0][0], parsed_data["asks"][0][1]);

Processing Binance WebSocket stream data with structs

For easier handling of the data, it is better to deserialize the data to a struct. To know what kind of struct to write we have to look at the structure of the data. Earlier we printed the raw structure, but we can also look it up in the Binance API documentation, of course. The structure of the data we are getting from the depth stream is as follows:

{
  "lastUpdateId": 160,  // Last update ID
  "bids": [             // Bids to be updated
    [
      "0.0024",         // Price level to be updated
      "10"              // Quantity
    ]
  ],
  "asks": [             // Asks to be updated
    [
      "0.0026",         // Price level to be updated
      "100"            // Quantity
    ]
  ]
}

We have a number attribute in lastUpdateId, and two arrays of string arrays in bids and asks.

Implementing the data structs

Let’s create a file called: models.rs, and add some structs representing the data:

use serde::de;
use serde::{Deserialize, Deserializer};

#[derive(Debug, Deserialize)]
pub struct OfferData {
    #[serde(deserialize_with = "de_float_from_str")]
    pub price: f32,
    #[serde(deserialize_with = "de_float_from_str")]
    pub size: f32,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DepthStreamData {
    pub last_update_id: usize,
    pub bids: Vec<OfferData>,
    pub asks: Vec<OfferData>,
}

pub fn de_float_from_str<'a, D>(deserializer: D) -> Result<f32, D::Error>
where
    D: Deserializer<'a>,
{
    let str_val = String::deserialize(deserializer)?;
    str_val.parse::<f32>().map_err(de::Error::custom)
}

Here we have two structs OfferData and DepthStreamData. The first one, OfferData, represents a single bid or ask, which is a String array of length 2 in the raw data. The serde library will automatically map the String array to this struct. We add a derive attribute to the struct for useful default implementations of certain functionalities, Debug allows for easy printing of the struct and, Deserialize is a must for deserializing the object. We add an attribute to the fields: [serde(deserialize_with = "de_float_from_str")] to have the strings converted to f32 during deserialization by the function: de_float_from_str. This function will convert the strings to floats. With these float values we can do calculations in the future, this is covered in a tutorial series: Crypto triangle arbitrage dashboard: how to, part 1.

The next struct is also pretty straightforward: DepthStreamData. We add a very useful attribute here from serde: #[serde(rename_all = "camelCase")], which will result in camel case attributes being renamed to Rust’s preferred snake case style. So that lastUpdateId will be mapped to last_update_id. The bids and asks will be Vec<T> of OfferData.

Using the data structs to process data

Finally, we will use the previously created data structs to handle the data in a more readable way:

use tungstenite::connect;
use url::Url;

mod models;

static BINANCE_WS_API: &str = "wss://stream.binance.com:9443";
fn main() {
    let binance_url = format!("{}/ws/ethbtc@depth5@100ms", BINANCE_WS_API);
    let (mut socket, response) =
        connect(Url::parse(&binance_url).unwrap()).expect("Can't connect.");

    println!("Connected to binance stream.");
    println!("HTTP status code: {}", response.status());
    println!("Response headers:");
    for (ref header, ref header_value) in response.headers() {
        println!("- {}: {:?}", header, header_value);
    }

    loop {
        let msg = socket.read_message().expect("Error reading message");
        let msg = match msg {
            tungstenite::Message::Text(s) => s,
            _ => {
                panic!("Error getting text");
            }
        };

        let parsed: models::DepthStreamData = serde_json::from_str(&msg).expect("Can't parse");
        for i in 0..parsed.asks.len() {
            println!(
                "{}. ask: {}, size: {}",
                i, parsed.asks[i].price, parsed.asks[i].size
            );
        }
    }
}

We’ve added a few lines here to deserialize the JSON data to our structs. We have to include the module models see line 4. Line 28 deserializes the msg to DepthStreamData, then we just loop through the length of the asks vector and print each item’s price and size. Running the program will result in output like this:

0. ask: 0.060257, size: 1.202
1. ask: 0.060258, size: 25.25
2. ask: 0.060261, size: 9.127
3. ask: 0.060263, size: 7.742
4. ask: 0.060265, size: 0.126

We can now subscribe and process data from one stream, but what about multiple streams? Let’s look at that in the next section.

Process multiple Binance WebSocket streams

Subscribing to and processing data from multiple streams is just as easy as doing it for one stream. All we need is to update the URL to the endpoint we’re connecting to and add a new parent struct to handle the data structure that wraps the data we saw in the previous section. See the documentation here.

Let’s add a struct to represent the wrapper, note that this only works if we subscribe to multiple depth streams, not when subscribing to multiple different types of streams:

#[derive(Debug, Deserialize)]
pub struct DepthStreamWrapper {
    pub stream: String,
    pub data: DepthStreamData,
}

Next, we change the binance_url to:

let binance_url = format!("{}/stream?streams=ethbtc@depth5@100ms/bnbeth@depth5@100ms",
        BINANCE_WS_API
    );

And finally, the code that prints out our results needs to be updated to:

let parsed: models::DepthStreamWrapper = serde_json::from_str(&msg).expect("Can't parse");
        for i in 0..parsed.data.asks.len() {
            println!(
                "{}: {}. ask: {}, size: {}",
                parsed.stream, i, parsed.data.asks[i].price, parsed.data.asks[i].size
            );
        }

The output will look like this:

ethbtc@depth5@100ms: 0. ask: 0.060306, size: 2.4
ethbtc@depth5@100ms: 1. ask: 0.060307, size: 24
ethbtc@depth5@100ms: 2. ask: 0.060308, size: 13.4
ethbtc@depth5@100ms: 3. ask: 0.060311, size: 0.084
ethbtc@depth5@100ms: 4. ask: 0.060314, size: 7.736
bnbeth@depth5@100ms: 0. ask: 0.15478, size: 14.166
bnbeth@depth5@100ms: 1. ask: 0.15479, size: 0.48
bnbeth@depth5@100ms: 2. ask: 0.15481, size: 33.188
bnbeth@depth5@100ms: 3. ask: 0.15482, size: 3.674
bnbeth@depth5@100ms: 4. ask: 0.15483, size: 6.5

Conclusion

We are now able to connect to the Binance WebSocket streams with Rust and are able to receive and print the data as fast as it comes in. In a future tutorial, we will build a backend WebSocket server that passes the data along to a client, so that we, for example, can build our own dashboard displaying bid and ask data.

The complete project for connecting to Binance WebSocket streams can be found on my Github here.

As mention at the start of this article, this article is one of the first steps in a series of articles for understanding how to build a triangle arbitrage dashboard using data from the Binance cryptocurrency trading streams.

Please consider checking out the other articles in this series:

Please follow me on Twitter to get notified on new Rust programming and technical indicator plotting related articles:

Comments (2)
  • Great tutorial! Just wanted to add by this point in time, the last_update_id is larger than the largest u32 value, so I changed that in the struct ‘DepthStreamData”s type for the field value for last_update_id from u32 to type usize, and this tutorial worked out for me.

Leave a Reply

Your email address will not be published. Required fields are marked *