From 096eb020a28564848fd6bbc98872babdbab92e6d Mon Sep 17 00:00:00 2001 From: Keir Date: Mon, 1 Jul 2024 21:17:02 +0100 Subject: [PATCH 01/10] chore: update deps --- Cargo.toml | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 7cf0dcb0..136ec7b6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "barter" -version = "0.8.13" +version = "0.8.14" authors = ["Just A Stream <93921983+just-a-stream@users.noreply.github.com>"] edition = "2021" license = "MIT" @@ -15,32 +15,32 @@ categories = ["accessibility", "simulation"] [dependencies] # Barter Ecosystem -barter-data = "0.7.0" -barter-integration = "0.5.3" +barter-data = "0.7.1" +barter-integration = "0.7.2" # Logging -tracing = "0.1.36" +tracing = "0.1.40" # Async -tokio = { version = "1.20.1", features = ["sync"] } -tokio-stream = { version = "0.1.9", features = ["sync"] } -futures = "0.3.21" +tokio = { version = "1.38.0", features = ["sync"] } +tokio-stream = { version = "0.1.15", features = ["sync"] } +futures = "0.3.30" # Error -thiserror = "1.0.32" +thiserror = "1.0.61" # SerDe -serde = { version = "1.0.143", features = ["derive"] } -serde_json = "1.0.83" +serde = { version = "1.0.203", features = ["derive"] } +serde_json = "1.0.120" # Persistence -redis = "0.22.2" +redis = "0.25.4" # Strategy ta = "0.5.0" # Misc -uuid = {version = "1.2.2", features = ["v4", "serde"]} -chrono = {version = "0.4.21", features = ["serde"]} +uuid = {version = "1.9.1", features = ["v4", "serde"]} +chrono = {version = "0.4.38", features = ["serde"]} prettytable-rs = "0.10.0" -parking_lot = "0.12.1" +parking_lot = "0.12.3" From 50f136a65bc2abcd75e3f7fbe79bdd5657a1594b Mon Sep 17 00:00:00 2001 From: Keir Date: Sun, 7 Jul 2024 21:20:41 +0100 Subject: [PATCH 02/10] refactor: move barter-rs repo to workspace (prep for monorepo) --- Cargo.toml | 35 ++++---------- barter/Cargo.toml | 46 +++++++++++++++++++ LICENSE => barter/LICENSE | 0 README.md => barter/README.md | 0 .../examples}/data/candles_1h.json | 0 .../examples}/engine_with_historic_candles.rs | 0 .../examples}/engine_with_live_trades.rs | 0 {src => barter/src}/data/error.rs | 0 {src => barter/src}/data/historical.rs | 0 {src => barter/src}/data/live.rs | 0 {src => barter/src}/data/mod.rs | 0 {src => barter/src}/engine/error.rs | 0 {src => barter/src}/engine/mod.rs | 0 {src => barter/src}/engine/trader.rs | 0 {src => barter/src}/event.rs | 0 {src => barter/src}/execution/error.rs | 0 {src => barter/src}/execution/mod.rs | 0 {src => barter/src}/execution/simulated.rs | 0 {src => barter/src}/lib.rs | 0 {src => barter/src}/portfolio/allocator.rs | 0 {src => barter/src}/portfolio/error.rs | 0 {src => barter/src}/portfolio/mod.rs | 0 {src => barter/src}/portfolio/portfolio.rs | 0 {src => barter/src}/portfolio/position.rs | 0 .../src}/portfolio/repository/error.rs | 0 .../src}/portfolio/repository/in_memory.rs | 0 .../src}/portfolio/repository/mod.rs | 0 .../src}/portfolio/repository/redis.rs | 0 {src => barter/src}/portfolio/risk.rs | 0 {src => barter/src}/statistic/algorithm.rs | 0 {src => barter/src}/statistic/dispersion.rs | 0 {src => barter/src}/statistic/error.rs | 0 .../src}/statistic/metric/drawdown.rs | 0 {src => barter/src}/statistic/metric/mod.rs | 0 {src => barter/src}/statistic/metric/ratio.rs | 0 {src => barter/src}/statistic/mod.rs | 0 {src => barter/src}/statistic/summary/data.rs | 0 .../src}/statistic/summary/drawdown.rs | 0 {src => barter/src}/statistic/summary/mod.rs | 0 {src => barter/src}/statistic/summary/pnl.rs | 0 .../src}/statistic/summary/trading.rs | 0 {src => barter/src}/strategy/example.rs | 0 {src => barter/src}/strategy/mod.rs | 0 {tests => barter/tests}/integration.rs | 0 44 files changed, 54 insertions(+), 27 deletions(-) create mode 100644 barter/Cargo.toml rename LICENSE => barter/LICENSE (100%) rename README.md => barter/README.md (100%) rename {examples => barter/examples}/data/candles_1h.json (100%) rename {examples => barter/examples}/engine_with_historic_candles.rs (100%) rename {examples => barter/examples}/engine_with_live_trades.rs (100%) rename {src => barter/src}/data/error.rs (100%) rename {src => barter/src}/data/historical.rs (100%) rename {src => barter/src}/data/live.rs (100%) rename {src => barter/src}/data/mod.rs (100%) rename {src => barter/src}/engine/error.rs (100%) rename {src => barter/src}/engine/mod.rs (100%) rename {src => barter/src}/engine/trader.rs (100%) rename {src => barter/src}/event.rs (100%) rename {src => barter/src}/execution/error.rs (100%) rename {src => barter/src}/execution/mod.rs (100%) rename {src => barter/src}/execution/simulated.rs (100%) rename {src => barter/src}/lib.rs (100%) rename {src => barter/src}/portfolio/allocator.rs (100%) rename {src => barter/src}/portfolio/error.rs (100%) rename {src => barter/src}/portfolio/mod.rs (100%) rename {src => barter/src}/portfolio/portfolio.rs (100%) rename {src => barter/src}/portfolio/position.rs (100%) rename {src => barter/src}/portfolio/repository/error.rs (100%) rename {src => barter/src}/portfolio/repository/in_memory.rs (100%) rename {src => barter/src}/portfolio/repository/mod.rs (100%) rename {src => barter/src}/portfolio/repository/redis.rs (100%) rename {src => barter/src}/portfolio/risk.rs (100%) rename {src => barter/src}/statistic/algorithm.rs (100%) rename {src => barter/src}/statistic/dispersion.rs (100%) rename {src => barter/src}/statistic/error.rs (100%) rename {src => barter/src}/statistic/metric/drawdown.rs (100%) rename {src => barter/src}/statistic/metric/mod.rs (100%) rename {src => barter/src}/statistic/metric/ratio.rs (100%) rename {src => barter/src}/statistic/mod.rs (100%) rename {src => barter/src}/statistic/summary/data.rs (100%) rename {src => barter/src}/statistic/summary/drawdown.rs (100%) rename {src => barter/src}/statistic/summary/mod.rs (100%) rename {src => barter/src}/statistic/summary/pnl.rs (100%) rename {src => barter/src}/statistic/summary/trading.rs (100%) rename {src => barter/src}/strategy/example.rs (100%) rename {src => barter/src}/strategy/mod.rs (100%) rename {tests => barter/tests}/integration.rs (100%) diff --git a/Cargo.toml b/Cargo.toml index 136ec7b6..94cf32cc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,23 +1,10 @@ -[package] -name = "barter" -version = "0.8.14" -authors = ["Just A Stream <93921983+just-a-stream@users.noreply.github.com>"] -edition = "2021" -license = "MIT" -documentation = "https://docs.rs/barter/" -repository = "https://github.com/barter-rs/barter-rs" -readme = "README.md" -description = "Framework for building event-driven live-trading & backtesting engines" -keywords = ["trading", "backtesting", "crypto", "stocks", "investment"] -categories = ["accessibility", "simulation"] - -# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html - -[dependencies] -# Barter Ecosystem -barter-data = "0.7.1" -barter-integration = "0.7.2" +[workspace] +resolver = "2" +members = [ + "barter", +] +[workspace.dependencies] # Logging tracing = "0.1.40" @@ -33,14 +20,8 @@ thiserror = "1.0.61" serde = { version = "1.0.203", features = ["derive"] } serde_json = "1.0.120" -# Persistence -redis = "0.25.4" - -# Strategy -ta = "0.5.0" - # Misc -uuid = {version = "1.9.1", features = ["v4", "serde"]} -chrono = {version = "0.4.38", features = ["serde"]} +uuid = { version = "1.9.1", features = ["v4", "serde"]} +chrono = { version = "0.4.38", features = ["serde"]} prettytable-rs = "0.10.0" parking_lot = "0.12.3" diff --git a/barter/Cargo.toml b/barter/Cargo.toml new file mode 100644 index 00000000..72865d70 --- /dev/null +++ b/barter/Cargo.toml @@ -0,0 +1,46 @@ +[package] +name = "barter" +version = "0.8.14" +authors = ["Just A Stream <93921983+just-a-stream@users.noreply.github.com>"] +edition = "2021" +license = "MIT" +documentation = "https://docs.rs/barter/" +repository = "https://github.com/barter-rs/barter-rs" +readme = "README.md" +description = "Framework for building event-driven live-trading & backtesting engines" +keywords = ["trading", "backtesting", "crypto", "stocks", "investment"] +categories = ["accessibility", "simulation"] + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +# Barter Ecosystem +barter-data = "0.7.1" +barter-integration = "0.7.2" + +# Logging +tracing = { workspace = true } + +# Async +tokio = { workspace = true, features = ["sync"] } +tokio-stream = { workspace = true, features = ["sync"] } +futures = { workspace = true } + +# Error +thiserror = { workspace = true } + +# SerDe +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } + +# Persistence +redis = "0.25.4" + +# Strategy +ta = "0.5.0" + +# Misc +uuid = { workspace = true, features = ["v4", "serde"] } +chrono = { workspace = true, features = ["serde"]} +parking_lot = { workspace = true } +prettytable-rs = "0.10.0" diff --git a/LICENSE b/barter/LICENSE similarity index 100% rename from LICENSE rename to barter/LICENSE diff --git a/README.md b/barter/README.md similarity index 100% rename from README.md rename to barter/README.md diff --git a/examples/data/candles_1h.json b/barter/examples/data/candles_1h.json similarity index 100% rename from examples/data/candles_1h.json rename to barter/examples/data/candles_1h.json diff --git a/examples/engine_with_historic_candles.rs b/barter/examples/engine_with_historic_candles.rs similarity index 100% rename from examples/engine_with_historic_candles.rs rename to barter/examples/engine_with_historic_candles.rs diff --git a/examples/engine_with_live_trades.rs b/barter/examples/engine_with_live_trades.rs similarity index 100% rename from examples/engine_with_live_trades.rs rename to barter/examples/engine_with_live_trades.rs diff --git a/src/data/error.rs b/barter/src/data/error.rs similarity index 100% rename from src/data/error.rs rename to barter/src/data/error.rs diff --git a/src/data/historical.rs b/barter/src/data/historical.rs similarity index 100% rename from src/data/historical.rs rename to barter/src/data/historical.rs diff --git a/src/data/live.rs b/barter/src/data/live.rs similarity index 100% rename from src/data/live.rs rename to barter/src/data/live.rs diff --git a/src/data/mod.rs b/barter/src/data/mod.rs similarity index 100% rename from src/data/mod.rs rename to barter/src/data/mod.rs diff --git a/src/engine/error.rs b/barter/src/engine/error.rs similarity index 100% rename from src/engine/error.rs rename to barter/src/engine/error.rs diff --git a/src/engine/mod.rs b/barter/src/engine/mod.rs similarity index 100% rename from src/engine/mod.rs rename to barter/src/engine/mod.rs diff --git a/src/engine/trader.rs b/barter/src/engine/trader.rs similarity index 100% rename from src/engine/trader.rs rename to barter/src/engine/trader.rs diff --git a/src/event.rs b/barter/src/event.rs similarity index 100% rename from src/event.rs rename to barter/src/event.rs diff --git a/src/execution/error.rs b/barter/src/execution/error.rs similarity index 100% rename from src/execution/error.rs rename to barter/src/execution/error.rs diff --git a/src/execution/mod.rs b/barter/src/execution/mod.rs similarity index 100% rename from src/execution/mod.rs rename to barter/src/execution/mod.rs diff --git a/src/execution/simulated.rs b/barter/src/execution/simulated.rs similarity index 100% rename from src/execution/simulated.rs rename to barter/src/execution/simulated.rs diff --git a/src/lib.rs b/barter/src/lib.rs similarity index 100% rename from src/lib.rs rename to barter/src/lib.rs diff --git a/src/portfolio/allocator.rs b/barter/src/portfolio/allocator.rs similarity index 100% rename from src/portfolio/allocator.rs rename to barter/src/portfolio/allocator.rs diff --git a/src/portfolio/error.rs b/barter/src/portfolio/error.rs similarity index 100% rename from src/portfolio/error.rs rename to barter/src/portfolio/error.rs diff --git a/src/portfolio/mod.rs b/barter/src/portfolio/mod.rs similarity index 100% rename from src/portfolio/mod.rs rename to barter/src/portfolio/mod.rs diff --git a/src/portfolio/portfolio.rs b/barter/src/portfolio/portfolio.rs similarity index 100% rename from src/portfolio/portfolio.rs rename to barter/src/portfolio/portfolio.rs diff --git a/src/portfolio/position.rs b/barter/src/portfolio/position.rs similarity index 100% rename from src/portfolio/position.rs rename to barter/src/portfolio/position.rs diff --git a/src/portfolio/repository/error.rs b/barter/src/portfolio/repository/error.rs similarity index 100% rename from src/portfolio/repository/error.rs rename to barter/src/portfolio/repository/error.rs diff --git a/src/portfolio/repository/in_memory.rs b/barter/src/portfolio/repository/in_memory.rs similarity index 100% rename from src/portfolio/repository/in_memory.rs rename to barter/src/portfolio/repository/in_memory.rs diff --git a/src/portfolio/repository/mod.rs b/barter/src/portfolio/repository/mod.rs similarity index 100% rename from src/portfolio/repository/mod.rs rename to barter/src/portfolio/repository/mod.rs diff --git a/src/portfolio/repository/redis.rs b/barter/src/portfolio/repository/redis.rs similarity index 100% rename from src/portfolio/repository/redis.rs rename to barter/src/portfolio/repository/redis.rs diff --git a/src/portfolio/risk.rs b/barter/src/portfolio/risk.rs similarity index 100% rename from src/portfolio/risk.rs rename to barter/src/portfolio/risk.rs diff --git a/src/statistic/algorithm.rs b/barter/src/statistic/algorithm.rs similarity index 100% rename from src/statistic/algorithm.rs rename to barter/src/statistic/algorithm.rs diff --git a/src/statistic/dispersion.rs b/barter/src/statistic/dispersion.rs similarity index 100% rename from src/statistic/dispersion.rs rename to barter/src/statistic/dispersion.rs diff --git a/src/statistic/error.rs b/barter/src/statistic/error.rs similarity index 100% rename from src/statistic/error.rs rename to barter/src/statistic/error.rs diff --git a/src/statistic/metric/drawdown.rs b/barter/src/statistic/metric/drawdown.rs similarity index 100% rename from src/statistic/metric/drawdown.rs rename to barter/src/statistic/metric/drawdown.rs diff --git a/src/statistic/metric/mod.rs b/barter/src/statistic/metric/mod.rs similarity index 100% rename from src/statistic/metric/mod.rs rename to barter/src/statistic/metric/mod.rs diff --git a/src/statistic/metric/ratio.rs b/barter/src/statistic/metric/ratio.rs similarity index 100% rename from src/statistic/metric/ratio.rs rename to barter/src/statistic/metric/ratio.rs diff --git a/src/statistic/mod.rs b/barter/src/statistic/mod.rs similarity index 100% rename from src/statistic/mod.rs rename to barter/src/statistic/mod.rs diff --git a/src/statistic/summary/data.rs b/barter/src/statistic/summary/data.rs similarity index 100% rename from src/statistic/summary/data.rs rename to barter/src/statistic/summary/data.rs diff --git a/src/statistic/summary/drawdown.rs b/barter/src/statistic/summary/drawdown.rs similarity index 100% rename from src/statistic/summary/drawdown.rs rename to barter/src/statistic/summary/drawdown.rs diff --git a/src/statistic/summary/mod.rs b/barter/src/statistic/summary/mod.rs similarity index 100% rename from src/statistic/summary/mod.rs rename to barter/src/statistic/summary/mod.rs diff --git a/src/statistic/summary/pnl.rs b/barter/src/statistic/summary/pnl.rs similarity index 100% rename from src/statistic/summary/pnl.rs rename to barter/src/statistic/summary/pnl.rs diff --git a/src/statistic/summary/trading.rs b/barter/src/statistic/summary/trading.rs similarity index 100% rename from src/statistic/summary/trading.rs rename to barter/src/statistic/summary/trading.rs diff --git a/src/strategy/example.rs b/barter/src/strategy/example.rs similarity index 100% rename from src/strategy/example.rs rename to barter/src/strategy/example.rs diff --git a/src/strategy/mod.rs b/barter/src/strategy/mod.rs similarity index 100% rename from src/strategy/mod.rs rename to barter/src/strategy/mod.rs diff --git a/tests/integration.rs b/barter/tests/integration.rs similarity index 100% rename from tests/integration.rs rename to barter/tests/integration.rs From 3945cc5c87698e096d8b98695f6fdc0a98b66013 Mon Sep 17 00:00:00 2001 From: Keir Date: Mon, 8 Jul 2024 16:59:59 +0100 Subject: [PATCH 03/10] chore: copy-paste barter ecosystem crates into barter-rs repo workspace barter-integration-rs: old repo url = https://github.com/barter-rs/barter-integration-rs commit sha = b8eaa9b98120369378ec41af0cafffa8246e6c4f barter-data-rs: old repo url = https://github.com/barter-rs/barter-data-rs commit sha = 715d2b1bd05e61ab3519efd56606d2a3d53cbf01 barter-execution-rs: old repo url = https://github.com/barter-rs/barter-execution-rs commit sha = 071e1ce6a799dbbe56ffeb0d6bd99c3d69223a12 barter-macro-rs: old repo url = https://github.com/barter-rs/barter-macro-rs commit sha = 01a67cf9a06a51be6c754c931a56a7c488887c49 --- Cargo.toml | 47 +- barter-data/Cargo.toml | 51 + barter-data/LICENSE | 25 + barter-data/README.md | 198 ++++ .../dynamic_multi_stream_multi_exchange.rs | 90 ++ .../examples/multi_stream_multi_exchange.rs | 98 ++ .../examples/order_books_l1_streams.rs | 68 ++ .../order_books_l1_streams_multi_exchange.rs | 66 ++ .../examples/order_books_l2_streams.rs | 68 ++ barter-data/examples/public_trades_streams.rs | 68 ++ .../public_trades_streams_multi_exchange.rs | 127 +++ barter-data/src/error.rs | 72 ++ barter-data/src/event.rs | 134 +++ barter-data/src/exchange/binance/book/l1.rs | 166 +++ barter-data/src/exchange/binance/book/l2.rs | 169 +++ barter-data/src/exchange/binance/book/mod.rs | 53 + barter-data/src/exchange/binance/channel.rs | 87 ++ .../src/exchange/binance/futures/l2.rs | 611 ++++++++++ .../exchange/binance/futures/liquidation.rs | 165 +++ .../src/exchange/binance/futures/mod.rs | 52 + barter-data/src/exchange/binance/market.rs | 54 + barter-data/src/exchange/binance/mod.rs | 153 +++ barter-data/src/exchange/binance/spot/l2.rs | 602 ++++++++++ barter-data/src/exchange/binance/spot/mod.rs | 39 + .../src/exchange/binance/subscription.rs | 126 +++ barter-data/src/exchange/binance/trade.rs | 253 +++++ barter-data/src/exchange/bitfinex/channel.rs | 32 + barter-data/src/exchange/bitfinex/market.rs | 47 + barter-data/src/exchange/bitfinex/message.rs | 219 ++++ barter-data/src/exchange/bitfinex/mod.rs | 109 ++ .../src/exchange/bitfinex/subscription.rs | 358 ++++++ barter-data/src/exchange/bitfinex/trade.rs | 117 ++ .../src/exchange/bitfinex/validator.rs | 149 +++ barter-data/src/exchange/bitmex/channel.rs | 32 + barter-data/src/exchange/bitmex/market.rs | 45 + barter-data/src/exchange/bitmex/message.rs | 42 + barter-data/src/exchange/bitmex/mod.rs | 111 ++ .../src/exchange/bitmex/subscription.rs | 139 +++ barter-data/src/exchange/bitmex/trade.rs | 205 ++++ barter-data/src/exchange/bybit/channel.rs | 34 + barter-data/src/exchange/bybit/futures/mod.rs | 22 + barter-data/src/exchange/bybit/market.rs | 47 + barter-data/src/exchange/bybit/message.rs | 157 +++ barter-data/src/exchange/bybit/mod.rs | 149 +++ barter-data/src/exchange/bybit/spot/mod.rs | 22 + .../src/exchange/bybit/subscription.rs | 176 +++ barter-data/src/exchange/bybit/trade.rs | 333 ++++++ barter-data/src/exchange/coinbase/channel.rs | 32 + barter-data/src/exchange/coinbase/market.rs | 43 + barter-data/src/exchange/coinbase/mod.rs | 81 ++ .../src/exchange/coinbase/subscription.rs | 163 +++ barter-data/src/exchange/coinbase/trade.rs | 144 +++ barter-data/src/exchange/gateio/channel.rs | 52 + barter-data/src/exchange/gateio/future/mod.rs | 68 ++ barter-data/src/exchange/gateio/market.rs | 83 ++ barter-data/src/exchange/gateio/message.rs | 72 ++ barter-data/src/exchange/gateio/mod.rs | 125 ++ barter-data/src/exchange/gateio/option/mod.rs | 39 + .../src/exchange/gateio/perpetual/mod.rs | 70 ++ .../src/exchange/gateio/perpetual/trade.rs | 140 +++ barter-data/src/exchange/gateio/spot/mod.rs | 43 + barter-data/src/exchange/gateio/spot/trade.rs | 104 ++ .../src/exchange/gateio/subscription.rs | 130 +++ barter-data/src/exchange/kraken/book/l1.rs | 197 ++++ barter-data/src/exchange/kraken/book/mod.rs | 2 + barter-data/src/exchange/kraken/channel.rs | 43 + barter-data/src/exchange/kraken/market.rs | 43 + barter-data/src/exchange/kraken/message.rs | 169 +++ barter-data/src/exchange/kraken/mod.rs | 98 ++ .../src/exchange/kraken/subscription.rs | 166 +++ barter-data/src/exchange/kraken/trade.rs | 293 +++++ barter-data/src/exchange/mod.rs | 283 +++++ barter-data/src/exchange/okx/channel.rs | 32 + barter-data/src/exchange/okx/market.rs | 76 ++ barter-data/src/exchange/okx/mod.rs | 87 ++ barter-data/src/exchange/okx/subscription.rs | 156 +++ barter-data/src/exchange/okx/trade.rs | 193 ++++ barter-data/src/exchange/subscription.rs | 83 ++ barter-data/src/instrument.rs | 85 ++ barter-data/src/lib.rs | 271 +++++ barter-data/src/streams/builder/dynamic.rs | 531 +++++++++ barter-data/src/streams/builder/mod.rs | 232 ++++ barter-data/src/streams/builder/multi.rs | 110 ++ barter-data/src/streams/consumer.rs | 123 ++ barter-data/src/streams/mod.rs | 72 ++ barter-data/src/subscriber/mapper.rs | 72 ++ barter-data/src/subscriber/mod.rs | 93 ++ barter-data/src/subscriber/validator.rs | 123 ++ barter-data/src/subscription/book.rs | 923 +++++++++++++++ barter-data/src/subscription/candle.rs | 24 + barter-data/src/subscription/liquidation.rs | 22 + barter-data/src/subscription/mod.rs | 486 ++++++++ barter-data/src/subscription/trade.rs | 22 + barter-data/src/transformer/book.rs | 156 +++ barter-data/src/transformer/mod.rs | 32 + barter-data/src/transformer/stateless.rs | 80 ++ barter-execution/Cargo.toml | 47 + barter-execution/LICENSE | 25 + barter-execution/README.md | 78 ++ barter-execution/src/error.rs | 22 + barter-execution/src/execution/binance/mod.rs | 1 + barter-execution/src/execution/ftx/mod.rs | 1 + barter-execution/src/execution/mod.rs | 5 + barter-execution/src/lib.rs | 187 +++ barter-execution/src/model/balance.rs | 134 +++ barter-execution/src/model/mod.rs | 55 + barter-execution/src/model/order.rs | 472 ++++++++ barter-execution/src/model/trade.rs | 54 + .../src/simulated/exchange/account/balance.rs | 204 ++++ .../src/simulated/exchange/account/mod.rs | 432 +++++++ .../src/simulated/exchange/account/order.rs | 832 ++++++++++++++ .../src/simulated/exchange/mod.rs | 85 ++ .../src/simulated/execution/mod.rs | 106 ++ barter-execution/src/simulated/mod.rs | 38 + barter-execution/tests/simulated_exchange.rs | 1008 +++++++++++++++++ barter-execution/tests/util/mod.rs | 162 +++ barter-integration/Cargo.toml | 50 + barter-integration/LICENSE | 25 + barter-integration/README.md | 367 ++++++ .../examples/signed_get_request.rs | 154 +++ .../examples/simple_websocket_integration.rs | 108 ++ barter-integration/src/de.rs | 98 ++ barter-integration/src/error.rs | 71 ++ barter-integration/src/lib.rs | 153 +++ barter-integration/src/metric.rs | 109 ++ .../src/model/instrument/kind.rs | 109 ++ .../src/model/instrument/mod.rs | 165 +++ .../src/model/instrument/symbol.rs | 54 + barter-integration/src/model/mod.rs | 245 ++++ barter-integration/src/protocol/http/mod.rs | 87 ++ .../src/protocol/http/private/encoder.rs | 35 + .../src/protocol/http/private/mod.rs | 131 +++ .../src/protocol/http/public/mod.rs | 16 + .../src/protocol/http/rest/client.rs | 141 +++ .../src/protocol/http/rest/mod.rs | 42 + barter-integration/src/protocol/mod.rs | 25 + barter-integration/src/protocol/websocket.rs | 158 +++ barter-macro/Cargo.toml | 23 + barter-macro/LICENSE | 21 + barter-macro/src/lib.rs | 126 +++ barter/Cargo.toml | 7 +- barter/src/engine/trader.rs | 4 +- barter/src/portfolio/portfolio.rs | 4 +- barter/src/portfolio/repository/in_memory.rs | 8 +- barter/src/portfolio/repository/redis.rs | 6 +- 145 files changed, 19422 insertions(+), 22 deletions(-) create mode 100644 barter-data/Cargo.toml create mode 100644 barter-data/LICENSE create mode 100644 barter-data/README.md create mode 100644 barter-data/examples/dynamic_multi_stream_multi_exchange.rs create mode 100644 barter-data/examples/multi_stream_multi_exchange.rs create mode 100644 barter-data/examples/order_books_l1_streams.rs create mode 100644 barter-data/examples/order_books_l1_streams_multi_exchange.rs create mode 100644 barter-data/examples/order_books_l2_streams.rs create mode 100644 barter-data/examples/public_trades_streams.rs create mode 100644 barter-data/examples/public_trades_streams_multi_exchange.rs create mode 100644 barter-data/src/error.rs create mode 100644 barter-data/src/event.rs create mode 100644 barter-data/src/exchange/binance/book/l1.rs create mode 100644 barter-data/src/exchange/binance/book/l2.rs create mode 100644 barter-data/src/exchange/binance/book/mod.rs create mode 100644 barter-data/src/exchange/binance/channel.rs create mode 100644 barter-data/src/exchange/binance/futures/l2.rs create mode 100644 barter-data/src/exchange/binance/futures/liquidation.rs create mode 100644 barter-data/src/exchange/binance/futures/mod.rs create mode 100644 barter-data/src/exchange/binance/market.rs create mode 100644 barter-data/src/exchange/binance/mod.rs create mode 100644 barter-data/src/exchange/binance/spot/l2.rs create mode 100644 barter-data/src/exchange/binance/spot/mod.rs create mode 100644 barter-data/src/exchange/binance/subscription.rs create mode 100644 barter-data/src/exchange/binance/trade.rs create mode 100644 barter-data/src/exchange/bitfinex/channel.rs create mode 100644 barter-data/src/exchange/bitfinex/market.rs create mode 100644 barter-data/src/exchange/bitfinex/message.rs create mode 100644 barter-data/src/exchange/bitfinex/mod.rs create mode 100644 barter-data/src/exchange/bitfinex/subscription.rs create mode 100644 barter-data/src/exchange/bitfinex/trade.rs create mode 100644 barter-data/src/exchange/bitfinex/validator.rs create mode 100644 barter-data/src/exchange/bitmex/channel.rs create mode 100644 barter-data/src/exchange/bitmex/market.rs create mode 100644 barter-data/src/exchange/bitmex/message.rs create mode 100644 barter-data/src/exchange/bitmex/mod.rs create mode 100644 barter-data/src/exchange/bitmex/subscription.rs create mode 100644 barter-data/src/exchange/bitmex/trade.rs create mode 100644 barter-data/src/exchange/bybit/channel.rs create mode 100644 barter-data/src/exchange/bybit/futures/mod.rs create mode 100644 barter-data/src/exchange/bybit/market.rs create mode 100644 barter-data/src/exchange/bybit/message.rs create mode 100644 barter-data/src/exchange/bybit/mod.rs create mode 100644 barter-data/src/exchange/bybit/spot/mod.rs create mode 100644 barter-data/src/exchange/bybit/subscription.rs create mode 100644 barter-data/src/exchange/bybit/trade.rs create mode 100644 barter-data/src/exchange/coinbase/channel.rs create mode 100644 barter-data/src/exchange/coinbase/market.rs create mode 100644 barter-data/src/exchange/coinbase/mod.rs create mode 100644 barter-data/src/exchange/coinbase/subscription.rs create mode 100644 barter-data/src/exchange/coinbase/trade.rs create mode 100644 barter-data/src/exchange/gateio/channel.rs create mode 100644 barter-data/src/exchange/gateio/future/mod.rs create mode 100644 barter-data/src/exchange/gateio/market.rs create mode 100644 barter-data/src/exchange/gateio/message.rs create mode 100644 barter-data/src/exchange/gateio/mod.rs create mode 100644 barter-data/src/exchange/gateio/option/mod.rs create mode 100644 barter-data/src/exchange/gateio/perpetual/mod.rs create mode 100644 barter-data/src/exchange/gateio/perpetual/trade.rs create mode 100644 barter-data/src/exchange/gateio/spot/mod.rs create mode 100644 barter-data/src/exchange/gateio/spot/trade.rs create mode 100644 barter-data/src/exchange/gateio/subscription.rs create mode 100644 barter-data/src/exchange/kraken/book/l1.rs create mode 100644 barter-data/src/exchange/kraken/book/mod.rs create mode 100644 barter-data/src/exchange/kraken/channel.rs create mode 100644 barter-data/src/exchange/kraken/market.rs create mode 100644 barter-data/src/exchange/kraken/message.rs create mode 100644 barter-data/src/exchange/kraken/mod.rs create mode 100644 barter-data/src/exchange/kraken/subscription.rs create mode 100644 barter-data/src/exchange/kraken/trade.rs create mode 100644 barter-data/src/exchange/mod.rs create mode 100644 barter-data/src/exchange/okx/channel.rs create mode 100644 barter-data/src/exchange/okx/market.rs create mode 100644 barter-data/src/exchange/okx/mod.rs create mode 100644 barter-data/src/exchange/okx/subscription.rs create mode 100644 barter-data/src/exchange/okx/trade.rs create mode 100644 barter-data/src/exchange/subscription.rs create mode 100644 barter-data/src/instrument.rs create mode 100644 barter-data/src/lib.rs create mode 100644 barter-data/src/streams/builder/dynamic.rs create mode 100644 barter-data/src/streams/builder/mod.rs create mode 100644 barter-data/src/streams/builder/multi.rs create mode 100644 barter-data/src/streams/consumer.rs create mode 100644 barter-data/src/streams/mod.rs create mode 100644 barter-data/src/subscriber/mapper.rs create mode 100644 barter-data/src/subscriber/mod.rs create mode 100644 barter-data/src/subscriber/validator.rs create mode 100644 barter-data/src/subscription/book.rs create mode 100644 barter-data/src/subscription/candle.rs create mode 100644 barter-data/src/subscription/liquidation.rs create mode 100644 barter-data/src/subscription/mod.rs create mode 100644 barter-data/src/subscription/trade.rs create mode 100644 barter-data/src/transformer/book.rs create mode 100644 barter-data/src/transformer/mod.rs create mode 100644 barter-data/src/transformer/stateless.rs create mode 100644 barter-execution/Cargo.toml create mode 100644 barter-execution/LICENSE create mode 100644 barter-execution/README.md create mode 100644 barter-execution/src/error.rs create mode 100644 barter-execution/src/execution/binance/mod.rs create mode 100644 barter-execution/src/execution/ftx/mod.rs create mode 100644 barter-execution/src/execution/mod.rs create mode 100644 barter-execution/src/lib.rs create mode 100644 barter-execution/src/model/balance.rs create mode 100644 barter-execution/src/model/mod.rs create mode 100644 barter-execution/src/model/order.rs create mode 100644 barter-execution/src/model/trade.rs create mode 100644 barter-execution/src/simulated/exchange/account/balance.rs create mode 100644 barter-execution/src/simulated/exchange/account/mod.rs create mode 100644 barter-execution/src/simulated/exchange/account/order.rs create mode 100644 barter-execution/src/simulated/exchange/mod.rs create mode 100644 barter-execution/src/simulated/execution/mod.rs create mode 100644 barter-execution/src/simulated/mod.rs create mode 100644 barter-execution/tests/simulated_exchange.rs create mode 100644 barter-execution/tests/util/mod.rs create mode 100644 barter-integration/Cargo.toml create mode 100644 barter-integration/LICENSE create mode 100644 barter-integration/README.md create mode 100644 barter-integration/examples/signed_get_request.rs create mode 100644 barter-integration/examples/simple_websocket_integration.rs create mode 100644 barter-integration/src/de.rs create mode 100644 barter-integration/src/error.rs create mode 100644 barter-integration/src/lib.rs create mode 100644 barter-integration/src/metric.rs create mode 100644 barter-integration/src/model/instrument/kind.rs create mode 100644 barter-integration/src/model/instrument/mod.rs create mode 100644 barter-integration/src/model/instrument/symbol.rs create mode 100644 barter-integration/src/model/mod.rs create mode 100644 barter-integration/src/protocol/http/mod.rs create mode 100644 barter-integration/src/protocol/http/private/encoder.rs create mode 100644 barter-integration/src/protocol/http/private/mod.rs create mode 100644 barter-integration/src/protocol/http/public/mod.rs create mode 100644 barter-integration/src/protocol/http/rest/client.rs create mode 100644 barter-integration/src/protocol/http/rest/mod.rs create mode 100644 barter-integration/src/protocol/mod.rs create mode 100644 barter-integration/src/protocol/websocket.rs create mode 100644 barter-macro/Cargo.toml create mode 100644 barter-macro/LICENSE create mode 100644 barter-macro/src/lib.rs diff --git a/Cargo.toml b/Cargo.toml index 94cf32cc..47296c49 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,26 +2,57 @@ resolver = "2" members = [ "barter", + "barter-data", + "barter-integration", + "barter-execution", + "barter-macro" ] [workspace.dependencies] # Logging -tracing = "0.1.40" +tracing = { version = "0.1.40" } +tracing-subscriber = { version = "0.3.16" } # Async -tokio = { version = "1.38.0", features = ["sync"] } -tokio-stream = { version = "0.1.15", features = ["sync"] } -futures = "0.3.30" +tokio = { version = "1.38.0" } +tokio-stream = { version = "0.1.15" } +futures = { version = "0.3.30" } +async-trait = { version = "0.1.57" } +pin-project = { version = "1.1.5" } # Error -thiserror = "1.0.61" +thiserror = { version = "1.0.61" } # SerDe serde = { version = "1.0.203", features = ["derive"] } -serde_json = "1.0.120" +serde_json = { version = "1.0.120" } +serde_qs = { version = "0.13.0" } +serde_urlencoded = { version = "0.7.1" } + +# Protocol +url = { version = "2.3.1 " } +reqwest = { version = "0.12.4" } +tokio-tungstenite = { version = "0.21.0" } + +# Strategy +ta = { version = "0.5.0" } + +# Data Structures +vecmap-rs = { version = "0.2.1" } +parking_lot = { version = "0.12.3" } + +# Crytographic Signatures +hmac = { version = "0.12.1" } +sha2 = { version = "0.10.6" } +hex = { version = "0.4.3" } +base64 = { version = "0.22.0" } # Misc uuid = { version = "1.9.1", features = ["v4", "serde"]} chrono = { version = "0.4.38", features = ["serde"]} -prettytable-rs = "0.10.0" -parking_lot = "0.12.3" +derive_more = { version = "0.99.17" } +itertools = { version = "0.13.0" } +rust_decimal = { version = "1.29.1" } +rust_decimal_macros = { version = "1.29.1" } +bytes = { version = "1.5.0" } + diff --git a/barter-data/Cargo.toml b/barter-data/Cargo.toml new file mode 100644 index 00000000..5fe97166 --- /dev/null +++ b/barter-data/Cargo.toml @@ -0,0 +1,51 @@ +[package] +name = "barter-data" +version = "0.8.1" +authors = ["JustAStream"] +edition = "2021" +license = "MIT" +documentation = "https://docs.rs/barter-data/" +repository = "https://github.com/barter-rs/barter-rs" +readme = "README.md" +description = "High performance & normalised WebSocket intergration for leading cryptocurrency exchanges - batteries included." +keywords = ["trading", "backtesting", "crypto", "stocks", "investment"] +categories = ["accessibility", "simulation"] + +[dev-dependencies] +tracing-subscriber = { workspace = true, features = ["env-filter", "json"] } +rust_decimal = { workspace = true } +rust_decimal_macros = { workspace = true } + +[dependencies] +# Barter Ecosystem +barter-integration = { path = "../barter-integration", version = "0.7.3" } +barter-macro = { path = "../barter-macro", version = "0.1.1" } + +# Logging +tracing = { workspace = true } + +# Async +tokio = { workspace = true, features = ["sync", "macros", "rt-multi-thread"] } +tokio-stream = { workspace = true, features = ["sync"] } +futures = { workspace = true } +async-trait = { workspace = true } + +# Protocol +url = { workspace = true } +reqwest = { workspace = true } + +# Error +thiserror = { workspace = true } + +# SerDe +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } + +# Strategy +ta = { workspace = true } + +# Misc +chrono = { workspace = true, features = ["serde"]} +derive_more = { workspace = true } +itertools = { workspace = true } +vecmap-rs = { workspace = true } diff --git a/barter-data/LICENSE b/barter-data/LICENSE new file mode 100644 index 00000000..d33f83b2 --- /dev/null +++ b/barter-data/LICENSE @@ -0,0 +1,25 @@ +Copyright (c) 2021 Barter-Data Contributors + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/barter-data/README.md b/barter-data/README.md new file mode 100644 index 00000000..53ce12cc --- /dev/null +++ b/barter-data/README.md @@ -0,0 +1,198 @@ + +# Barter-Data +A high-performance WebSocket integration library for streaming public market data from leading cryptocurrency +exchanges - batteries included. It is: +* **Easy**: Barter-Data's simple StreamBuilder interface allows for easy & quick setup (see example below!). +* **Normalised**: Barter-Data's unified interface for consuming public WebSocket data means every Exchange returns a normalised data model. +* **Real-Time**: Barter-Data utilises real-time WebSocket integrations enabling the consumption of normalised tick-by-tick data. +* **Extensible**: Barter-Data is highly extensible, and therefore easy to contribute to with coding new integrations! + +**See: [`Barter`], [`Barter-Integration`], [`Barter-Execution`] & [`Barter-Macro`]** + +[![Crates.io][crates-badge]][crates-url] +[![MIT licensed][mit-badge]][mit-url] +[![Build Status][actions-badge]][actions-url] +[![Discord chat][discord-badge]][discord-url] + +[crates-badge]: https://img.shields.io/crates/v/barter-data.svg +[crates-url]: https://crates.io/crates/barter-data + +[mit-badge]: https://img.shields.io/badge/license-MIT-blue.svg +[mit-url]: https://gitlab.com/open-source-keir/financial-modelling/trading/barter-data-rs/-/blob/main/LICENCE + +[actions-badge]: https://gitlab.com/open-source-keir/financial-modelling/trading/barter-data-rs/badges/-/blob/main/pipeline.svg +[actions-url]: https://gitlab.com/open-source-keir/financial-modelling/trading/barter-data-rs/-/commits/main + +[discord-badge]: https://img.shields.io/discord/910237311332151317.svg?logo=discord&style=flat-square +[discord-url]: https://discord.gg/wE7RqhnQMV + +[API Documentation] | +[Chat] + +[`Barter`]: https://crates.io/crates/barter +[`Barter-Integration`]: https://crates.io/crates/barter-integration +[`Barter-Execution`]: https://crates.io/crates/barter-execution +[`Barter-Macro`]: https://crates.io/crates/barter-macro +[API Documentation]: https://docs.rs/barter-data/latest/barter_data +[Chat]: https://discord.gg/wE7RqhnQMV + +## Overview +Barter-Data is a high-performance WebSocket integration library for streaming public market data from leading cryptocurrency +exchanges. It presents an easy-to-use and extensible set of interfaces that can deliver normalised exchange data in real-time. + +From a user perspective, the major component is the `StreamBuilder` structures that assists in initialising an +arbitrary number of exchange `MarketStream`s using input `Subscription`s. Simply build your dream set of +`MarketStreams` and `Barter-Data` will do the rest! + +### Supported Exchange Subscriptions + +| Exchange | Constructor Code | InstrumentKinds | SubscriptionKinds | +|:-----------------------:|:--------------------------------:|:-------------------------------------------:|:------------------------------------------------:| +| **BinanceSpot** | `BinanceSpot::default()` | Spot | PublicTrades
OrderBooksL1
OrderBooksL2 | +| **BinanceFuturesUsd** | `BinanceFuturesUsd::default()` | Perpetual | PublicTrades
OrderBooksL1
OrderBooksL2 | +| **Bitfinex** | `Bitfinex` | Spot | PublicTrades | +| **Bitmex** | `Bitmex` | Perpetual | PublicTrades | +| **BybitSpot** | `BybitSpot::default()` | Spot | PublicTrades | +| **BybitPerpetualsUsd** | `BybitPerpetualsUsd::default()` | Perpetual | PublicTrades | +| **Coinbase** | `Coinbase` | Spot | PublicTrades | +| **GateioSpot** | `GateioSpot::default()` | Spot | PublicTrades | +| **GateioFuturesUsd** | `GateioFuturesUsd::default()` | Future | PublicTrades | +| **GateioFuturesBtc** | `GateioFuturesBtc::default()` | Future | PublicTrades | +| **GateioPerpetualsUsd** | `GateioPerpetualsUsd::default()` | Perpetual | PublicTrades | +| **GateioPerpetualsBtc** | `GateioPerpetualsBtc::default()` | Perpetual | PublicTrades | +| **GateioOptionsBtc** | `GateioOptions::default()` | Option | PublicTrades | +| **Kraken** | `Kraken` | Spot | PublicTrades
OrderBooksL1 | +| **Okx** | `Okx` | Spot
Future
Perpetual
Option | PublicTrades | + + +## Examples +See barter-data-rs/examples for a more comprehensive selection of examples! + +### Multi Exchange Public Trades +```rust,no_run +use barter_data::{ + exchange::{ + binance::{futures::BinanceFuturesUsd, spot::BinanceSpot}, + bitmex::Bitmex, + bybit::{futures::BybitPerpetualsUsd, spot::BybitSpot}, + coinbase::Coinbase, + gateio::{ + option::GateioOptions, + perpetual::{GateioPerpetualsBtc, GateioPerpetualsUsd}, + spot::GateioSpot, + }, + okx::Okx, + }, + streams::Streams, + subscription::trade::PublicTrades, +}; +use barter_integration::model::instrument::kind::{ + FutureContract, InstrumentKind, OptionContract, OptionExercise, OptionKind, +}; +use chrono::{TimeZone, Utc}; +use futures::StreamExt; +use tracing::info; + +#[tokio::main] +async fn main() { + // Initialise PublicTrades Streams for various exchanges + // '--> each call to StreamBuilder::subscribe() creates a separate WebSocket connection + let streams = Streams::::builder() + .subscribe([ + (BinanceSpot::default(), "btc", "usdt", InstrumentKind::Spot, PublicTrades), + (BinanceSpot::default(), "eth", "usdt", InstrumentKind::Spot, PublicTrades), + ]) + .subscribe([ + (BinanceFuturesUsd::default(), "btc", "usdt", InstrumentKind::Perpetual, PublicTrades), + (BinanceFuturesUsd::default(), "eth", "usdt", InstrumentKind::Perpetual, PublicTrades), + ]) + .subscribe([ + (Coinbase, "btc", "usd", InstrumentKind::Spot, PublicTrades), + (Coinbase, "eth", "usd", InstrumentKind::Spot, PublicTrades), + ]) + .subscribe([ + (GateioSpot::default(), "btc", "usdt", InstrumentKind::Spot, PublicTrades), + ]) + .subscribe([ + (GateioPerpetualsUsd::default(), "btc", "usdt", InstrumentKind::Perpetual, PublicTrades), + ]) + .subscribe([ + (GateioPerpetualsBtc::default(), "btc", "usd", InstrumentKind::Perpetual, PublicTrades), + ]) + .subscribe([ + (GateioOptions::default(), "btc", "usdt", InstrumentKind::Option(put_contract()), PublicTrades), + ]) + .subscribe([ + (Okx, "btc", "usdt", InstrumentKind::Spot, PublicTrades), + (Okx, "btc", "usdt", InstrumentKind::Perpetual, PublicTrades), + (Okx, "btc", "usd", InstrumentKind::Future(future_contract()), PublicTrades), + (Okx, "btc", "usd", InstrumentKind::Option(call_contract()), PublicTrades), + ]) + .subscribe([ + (BybitSpot::default(), "btc", "usdt", InstrumentKind::Spot, PublicTrades), + (BybitSpot::default(), "eth", "usdt", InstrumentKind::Spot, PublicTrades), + ]) + .subscribe([ + (BybitPerpetualsUsd::default(), "btc", "usdt", InstrumentKind::Perpetual, PublicTrades), + ]) + .subscribe([ + (Bitmex, "xbt", "usd", InstrumentKind::Perpetual, PublicTrades) + ]) + .init() + .await + .unwrap(); + + // Join all exchange PublicTrades streams into a single tokio_stream::StreamMap + // Notes: + // - Use `streams.select(ExchangeId)` to interact with the individual exchange streams! + // - Use `streams.join()` to join all exchange streams into a single mpsc::UnboundedReceiver! + let mut joined_stream = streams.join_map().await; + + while let Some((exchange, trade)) = joined_stream.next().await { + info!("Exchange: {exchange}, MarketEvent: {trade:?}"); + } +} +``` + +## Getting Help +Firstly, see if the answer to your question can be found in the [API Documentation]. If the answer is not there, I'd be +happy to help via [Chat] and try answer your question via Discord. + +## Contributing +Thanks in advance for helping to develop the Barter ecosystem! Please do get hesitate to get touch via the Discord +[Chat] to discuss development, new features, and the future roadmap. + +### Adding A New Exchange Connector +1. Add a new `Connector` trait implementation in src/exchange/.mod.rs (eg/ see exchange::okx::Okx). +2. Follow on from "Adding A New Subscription Kind For An Existing Exchange Connector" below! + +### Adding A New SubscriptionKind For An Existing Exchange Connector +1. Add a new `SubscriptionKind` trait implementation in src/subscription/.rs (eg/ see subscription::trade::PublicTrades). +2. Define the `SubscriptionKind::Event` data model (eg/ see subscription::trade::PublicTrade). +3. Define the `MarketStream` type the exchange `Connector` will initialise for the new `SubscriptionKind`:
+ ie/ `impl StreamSelector for { ... }` +4. Try to compile and follow the remaining steps! +5. Add a barter-data-rs/examples/_streams.rs example in the standard format :) + +## Related Projects +In addition to the Barter-Execution crate, the Barter project also maintains: +* [`Barter`]: High-performance, extensible & modular trading components with batteries-included. Contains a + pre-built trading Engine that can serve as a live-trading or backtesting system. +* [`Barter-Integration`]: High-performance, low-level framework for composing flexible web integrations. +* [`Barter-Execution`]: High-performance WebSocket integration library for streaming public market data from leading + cryptocurrency exchanges. +* [`Barter-Macro`]: Barter ecosystem macros. + +## Roadmap +* Add support for more exchanges (easy to help with!) +* Add support for more subscription kinds (easy to help with!) + +## Licence +This project is licensed under the [MIT license]. + +[MIT license]: https://gitlab.com/open-source-keir/financial-modelling/trading/barter-data-rs/-/blob/main/LICENSE + +### Contribution +Unless you explicitly state otherwise, any contribution intentionally submitted +for inclusion in Barter-Data by you, shall be licensed as MIT, without any additional +terms or conditions. diff --git a/barter-data/examples/dynamic_multi_stream_multi_exchange.rs b/barter-data/examples/dynamic_multi_stream_multi_exchange.rs new file mode 100644 index 00000000..f8ea6cbd --- /dev/null +++ b/barter-data/examples/dynamic_multi_stream_multi_exchange.rs @@ -0,0 +1,90 @@ +use barter_data::{ + event::{DataKind, MarketEvent}, + exchange::ExchangeId, + streams::builder::dynamic::DynamicStreams, + subscription::SubKind, +}; +use barter_integration::model::instrument::{kind::InstrumentKind, Instrument}; +use futures::StreamExt; +use tracing::info; + +#[rustfmt::skip] +#[tokio::main] +async fn main() { + // Initialise INFO Tracing log subscriber + init_logging(); + + use ExchangeId::*; + use InstrumentKind::*; + use SubKind::*; + + // Notes: + // - DynamicStream::init requires an IntoIterator. + // - Each "subscription batch" is an IntoIterator. + // - Every "subscription batch" will initialise at-least-one WebSocket stream under the hood. + // - If the "subscription batch" contains more-than-one ExchangeId and/or SubKind, the batch + // will be further split under the hood for compile-time reasons. + + // Initialise MarketEvent streams for various ExchangeIds and SubscriptionKinds + let streams = DynamicStreams::init([ + // Batch notes: + // Since batch contains 1 ExchangeId and 1 SubscriptionKind, so only 1 (1x1) WebSockets + // will be spawned for this batch. + vec![ + (BinanceSpot, "btc", "usdt", Spot, PublicTrades), + (BinanceSpot, "eth", "usdt", Spot, PublicTrades), + ], + + // Batch notes: + // Since batch contains 1 ExchangeId and 3 SubscriptionKinds, 3 (1x3) WebSocket connections + // will be spawned for this batch (back-end requires to further split). + vec![ + (BinanceFuturesUsd, "btc", "usdt", Perpetual, PublicTrades), + (BinanceFuturesUsd, "btc", "usdt", Perpetual, OrderBooksL1), + (BinanceFuturesUsd, "btc", "usdt", Perpetual, Liquidations), + + ], + + // Batch notes: + // Since batch contains 2 ExchangeIds and 1 SubscriptionKind, 2 (2x1) WebSocket connections + // will be spawned for this batch (back-end requires to further split). + vec![ + (Okx, "btc", "usdt", Spot, PublicTrades), + (Okx, "btc", "usdt", Perpetual, PublicTrades), + (Bitmex, "btc", "usdt", Perpetual, PublicTrades), + (Okx, "eth", "usdt", Spot, PublicTrades), + (Okx, "eth", "usdt", Perpetual, PublicTrades), + (Bitmex, "eth", "usdt", Perpetual, PublicTrades), + ], + ]).await.unwrap(); + + // Select all streams, mapping each SubscriptionKind `MarketEvent` into a unified + // `Output` (eg/ `MarketEvent`), where MarketEvent: Into + // Notes on other DynamicStreams methods: + // - Use `streams.select_trades(ExchangeId)` to return a stream of trades from a given exchange. + // - Use `streams.select_(ExchangeId)` to return a stream of T from a given exchange. + // - Use `streams.select_all_trades(ExchangeId)` to return a stream of trades from all exchanges + let mut merged = streams + .select_all::>(); + + while let Some(event) = merged.next().await { + info!("{event:?}"); + } +} + +// Initialise an INFO `Subscriber` for `Tracing` Json logs and install it as the global default. +fn init_logging() { + tracing_subscriber::fmt() + // Filter messages based on the INFO + .with_env_filter( + tracing_subscriber::filter::EnvFilter::builder() + .with_default_directive(tracing_subscriber::filter::LevelFilter::INFO.into()) + .from_env_lossy(), + ) + // Disable colours on release builds + .with_ansi(cfg!(debug_assertions)) + // Enable Json formatting + .json() + // Install this Tracing subscriber as global default + .init() +} diff --git a/barter-data/examples/multi_stream_multi_exchange.rs b/barter-data/examples/multi_stream_multi_exchange.rs new file mode 100644 index 00000000..5006f223 --- /dev/null +++ b/barter-data/examples/multi_stream_multi_exchange.rs @@ -0,0 +1,98 @@ +use barter_data::{ + event::{DataKind, MarketEvent}, + exchange::{ + binance::{futures::BinanceFuturesUsd, spot::BinanceSpot}, + kraken::Kraken, + okx::Okx, + }, + streams::Streams, + subscription::{ + book::{OrderBooksL1, OrderBooksL2}, + trade::PublicTrades, + }, +}; +use barter_integration::model::instrument::{kind::InstrumentKind, Instrument}; +use tokio_stream::StreamExt; +use tracing::info; + +#[rustfmt::skip] +#[tokio::main] +async fn main() { + // Initialise INFO Tracing log subscriber + init_logging(); + + // Notes: + // - MarketEvent could use a custom enumeration if more flexibility is required. + // - Each call to StreamBuilder::subscribe() creates a separate WebSocket connection for those + // Subscriptions passed. + + // Initialise MarketEvent Streams for various exchanges + let streams: Streams> = Streams::builder_multi() + + // Add PublicTrades Streams for various exchanges + .add(Streams::::builder() + .subscribe([ + (BinanceSpot::default(), "btc", "usdt", InstrumentKind::Spot, PublicTrades), + ]) + .subscribe([ + (BinanceFuturesUsd::default(), "btc", "usdt", InstrumentKind::Perpetual, PublicTrades), + ]) + .subscribe([ + (Okx, "btc", "usdt", InstrumentKind::Spot, PublicTrades), + (Okx, "btc", "usdt", InstrumentKind::Perpetual, PublicTrades), + ]) + ) + + // Add OrderBooksL1 Stream for various exchanges + .add(Streams::::builder() + .subscribe([ + (BinanceSpot::default(), "btc", "usdt", InstrumentKind::Spot, OrderBooksL1), + ]) + .subscribe([ + (BinanceFuturesUsd::default(), "btc", "usdt", InstrumentKind::Perpetual, OrderBooksL1), + ]) + .subscribe([ + (Kraken, "xbt", "usd", InstrumentKind::Spot, OrderBooksL1), + ]) + ) + + // Add OrderBooksL2 Stream for various exchanges + .add(Streams::::builder() + .subscribe([ + (BinanceSpot::default(), "btc", "usdt", InstrumentKind::Spot, OrderBooksL2), + ]) + .subscribe([ + (BinanceFuturesUsd::default(), "btc", "usdt", InstrumentKind::Perpetual, OrderBooksL2), + ]) + ) + .init() + .await + .unwrap(); + + // Join all exchange Streams into a single tokio_stream::StreamMap + // Notes: + // - Use `streams.select(ExchangeId)` to interact with the individual exchange streams! + // - Use `streams.join()` to join all exchange streams into a single mpsc::UnboundedReceiver! + let mut joined_stream = streams.join_map().await; + + while let Some((exchange, data)) = joined_stream.next().await { + info!("Exchange: {exchange}, MarketEvent: {data:?}"); + } +} + +// Initialise an INFO `Subscriber` for `Tracing` Json logs and install it as the global default. +fn init_logging() { + tracing_subscriber::fmt() + // Filter messages based on the INFO + .with_env_filter( + tracing_subscriber::filter::EnvFilter::builder() + .with_default_directive(tracing_subscriber::filter::LevelFilter::INFO.into()) + .from_env_lossy(), + ) + // Disable colours on release builds + .with_ansi(cfg!(debug_assertions)) + // Enable Json formatting + .json() + // Install this Tracing subscriber as global default + .init() +} diff --git a/barter-data/examples/order_books_l1_streams.rs b/barter-data/examples/order_books_l1_streams.rs new file mode 100644 index 00000000..be8d1686 --- /dev/null +++ b/barter-data/examples/order_books_l1_streams.rs @@ -0,0 +1,68 @@ +use barter_data::{ + exchange::{binance::spot::BinanceSpot, ExchangeId}, + streams::Streams, + subscription::book::OrderBooksL1, +}; +use barter_integration::model::instrument::kind::InstrumentKind; +use tracing::info; + +#[rustfmt::skip] +#[tokio::main] +async fn main() { + // Initialise INFO Tracing log subscriber + init_logging(); + + // Initialise OrderBooksL1 Streams for BinanceSpot only + // '--> each call to StreamBuilder::subscribe() creates a separate WebSocket connection + let mut streams = Streams::::builder() + + // Separate WebSocket connection for BTC_USDT stream since it's very high volume + .subscribe([ + (BinanceSpot::default(), "btc", "usdt", InstrumentKind::Spot, OrderBooksL1), + ]) + + // Separate WebSocket connection for ETH_USDT stream since it's very high volume + .subscribe([ + (BinanceSpot::default(), "eth", "usdt", InstrumentKind::Spot, OrderBooksL1), + ]) + + // Lower volume Instruments can share a WebSocket connection + .subscribe([ + (BinanceSpot::default(), "xrp", "usdt", InstrumentKind::Spot, OrderBooksL1), + (BinanceSpot::default(), "sol", "usdt", InstrumentKind::Spot, OrderBooksL1), + (BinanceSpot::default(), "avax", "usdt", InstrumentKind::Spot, OrderBooksL1), + (BinanceSpot::default(), "ltc", "usdt", InstrumentKind::Spot, OrderBooksL1), + ]) + .init() + .await + .unwrap(); + + // Select the ExchangeId::BinanceSpot stream + // Notes: + // - Use `streams.select(ExchangeId)` to interact with the individual exchange streams! + // - Use `streams.join()` to join all exchange streams into a single mpsc::UnboundedReceiver! + let mut binance_stream = streams + .select(ExchangeId::BinanceSpot) + .unwrap(); + + while let Some(order_book_l1) = binance_stream.recv().await { + info!("MarketEvent: {order_book_l1:?}"); + } +} + +// Initialise an INFO `Subscriber` for `Tracing` Json logs and install it as the global default. +fn init_logging() { + tracing_subscriber::fmt() + // Filter messages based on the INFO + .with_env_filter( + tracing_subscriber::filter::EnvFilter::builder() + .with_default_directive(tracing_subscriber::filter::LevelFilter::INFO.into()) + .from_env_lossy(), + ) + // Disable colours on release builds + .with_ansi(cfg!(debug_assertions)) + // Enable Json formatting + .json() + // Install this Tracing subscriber as global default + .init() +} diff --git a/barter-data/examples/order_books_l1_streams_multi_exchange.rs b/barter-data/examples/order_books_l1_streams_multi_exchange.rs new file mode 100644 index 00000000..fe842d36 --- /dev/null +++ b/barter-data/examples/order_books_l1_streams_multi_exchange.rs @@ -0,0 +1,66 @@ +use barter_data::{ + exchange::{ + binance::{futures::BinanceFuturesUsd, spot::BinanceSpot}, + kraken::Kraken, + }, + streams::Streams, + subscription::book::OrderBooksL1, +}; +use barter_integration::model::instrument::kind::InstrumentKind; +use futures::StreamExt; +use tracing::info; + +#[rustfmt::skip] +#[tokio::main] +async fn main() { + // Initialise INFO Tracing log subscriber + init_logging(); + + // Initialise OrderBooksL1 Streams for various exchanges + // '--> each call to StreamBuilder::subscribe() initialises a separate WebSocket connection + let streams = Streams::::builder() + .subscribe([ + (BinanceSpot::default(), "btc", "usdt", InstrumentKind::Spot, OrderBooksL1), + (BinanceSpot::default(), "eth", "usd", InstrumentKind::Spot, OrderBooksL1), + ]) + .subscribe([ + (BinanceFuturesUsd::default(), "btc", "usdt", InstrumentKind::Perpetual, OrderBooksL1), + (BinanceFuturesUsd::default(), "eth", "usd", InstrumentKind::Perpetual, OrderBooksL1), + ]) + .subscribe([ + (Kraken, "xbt", "usd", InstrumentKind::Spot, OrderBooksL1), + (Kraken, "ada", "usd", InstrumentKind::Spot, OrderBooksL1), + (Kraken, "matic", "usd", InstrumentKind::Spot, OrderBooksL1), + (Kraken, "dot", "usd", InstrumentKind::Spot, OrderBooksL1), + ]) + .init() + .await + .unwrap(); + + // Join all exchange OrderBooksL1 streams into a single tokio_stream::StreamMap + // Notes: + // - Use `streams.select(ExchangeId)` to interact with the individual exchange streams! + // - Use `streams.join()` to join all exchange streams into a single mpsc::UnboundedReceiver! + let mut joined_stream = streams.join_map().await; + + while let Some((exchange, order_book_l1)) = joined_stream.next().await { + info!("Exchange: {exchange}, MarketEvent: {order_book_l1:?}"); + } +} + +// Initialise an INFO `Subscriber` for `Tracing` Json logs and install it as the global default. +fn init_logging() { + tracing_subscriber::fmt() + // Filter messages based on the INFO + .with_env_filter( + tracing_subscriber::filter::EnvFilter::builder() + .with_default_directive(tracing_subscriber::filter::LevelFilter::INFO.into()) + .from_env_lossy(), + ) + // Disable colours on release builds + .with_ansi(cfg!(debug_assertions)) + // Enable Json formatting + .json() + // Install this Tracing subscriber as global default + .init() +} diff --git a/barter-data/examples/order_books_l2_streams.rs b/barter-data/examples/order_books_l2_streams.rs new file mode 100644 index 00000000..0a8f9e74 --- /dev/null +++ b/barter-data/examples/order_books_l2_streams.rs @@ -0,0 +1,68 @@ +use barter_data::{ + exchange::{binance::spot::BinanceSpot, ExchangeId}, + streams::Streams, + subscription::book::OrderBooksL2, +}; +use barter_integration::model::instrument::kind::InstrumentKind; +use tracing::info; + +#[rustfmt::skip] +#[tokio::main] +async fn main() { + // Initialise INFO Tracing log subscriber + init_logging(); + + // Initialise OrderBooksL2 Streams for BinanceSpot only + // '--> each call to StreamBuilder::subscribe() creates a separate WebSocket connection + let mut streams = Streams::::builder() + + // Separate WebSocket connection for BTC_USDT stream since it's very high volume + .subscribe([ + (BinanceSpot::default(), "btc", "usdt", InstrumentKind::Spot, OrderBooksL2), + ]) + + // Separate WebSocket connection for ETH_USDT stream since it's very high volume + .subscribe([ + (BinanceSpot::default(), "eth", "usdt", InstrumentKind::Spot, OrderBooksL2), + ]) + + // Lower volume Instruments can share a WebSocket connection + .subscribe([ + (BinanceSpot::default(), "xrp", "usdt", InstrumentKind::Spot, OrderBooksL2), + (BinanceSpot::default(), "sol", "usdt", InstrumentKind::Spot, OrderBooksL2), + (BinanceSpot::default(), "avax", "usdt", InstrumentKind::Spot, OrderBooksL2), + (BinanceSpot::default(), "ltc", "usdt", InstrumentKind::Spot, OrderBooksL2), + ]) + .init() + .await + .unwrap(); + + // Select the ExchangeId::BinanceSpot stream + // Notes: + // - Use `streams.select(ExchangeId)` to interact with the individual exchange streams! + // - Use `streams.join()` to join all exchange streams into a single mpsc::UnboundedReceiver! + let mut binance_stream = streams + .select(ExchangeId::BinanceSpot) + .unwrap(); + + while let Some(order_book_l2) = binance_stream.recv().await { + info!("MarketEvent: {order_book_l2:?}"); + } +} + +// Initialise an INFO `Subscriber` for `Tracing` Json logs and install it as the global default. +fn init_logging() { + tracing_subscriber::fmt() + // Filter messages based on the INFO + .with_env_filter( + tracing_subscriber::filter::EnvFilter::builder() + .with_default_directive(tracing_subscriber::filter::LevelFilter::INFO.into()) + .from_env_lossy(), + ) + // Disable colours on release builds + .with_ansi(cfg!(debug_assertions)) + // Enable Json formatting + .json() + // Install this Tracing subscriber as global default + .init() +} diff --git a/barter-data/examples/public_trades_streams.rs b/barter-data/examples/public_trades_streams.rs new file mode 100644 index 00000000..4be19f38 --- /dev/null +++ b/barter-data/examples/public_trades_streams.rs @@ -0,0 +1,68 @@ +use barter_data::{ + exchange::{binance::futures::BinanceFuturesUsd, ExchangeId}, + streams::Streams, + subscription::trade::PublicTrades, +}; +use barter_integration::model::instrument::kind::InstrumentKind; +use tracing::info; + +#[rustfmt::skip] +#[tokio::main] +async fn main() { + // Initialise INFO Tracing log subscriber + init_logging(); + + // Initialise PublicTrades Streams for BinanceFuturesUsd only + // '--> each call to StreamBuilder::subscribe() creates a separate WebSocket connection + let mut streams = Streams::::builder() + + // Separate WebSocket connection for BTC_USDT stream since it's very high volume + .subscribe([ + (BinanceFuturesUsd::default(), "btc", "usdt", InstrumentKind::Perpetual, PublicTrades), + ]) + + // Separate WebSocket connection for ETH_USDT stream since it's very high volume + .subscribe([ + (BinanceFuturesUsd::default(), "eth", "usdt", InstrumentKind::Perpetual, PublicTrades), + ]) + + // Lower volume Instruments can share a WebSocket connection + .subscribe([ + (BinanceFuturesUsd::default(), "xrp", "usdt", InstrumentKind::Perpetual, PublicTrades), + (BinanceFuturesUsd::default(), "sol", "usdt", InstrumentKind::Perpetual, PublicTrades), + (BinanceFuturesUsd::default(), "avax", "usdt", InstrumentKind::Perpetual, PublicTrades), + (BinanceFuturesUsd::default(), "ltc", "usdt", InstrumentKind::Perpetual, PublicTrades), + ]) + .init() + .await + .unwrap(); + + // Select the ExchangeId::BinanceFuturesUsd stream + // Notes: + // - Use `streams.select(ExchangeId)` to interact with the individual exchange streams! + // - Use `streams.join()` to join all exchange streams into a single mpsc::UnboundedReceiver! + let mut binance_stream = streams + .select(ExchangeId::BinanceFuturesUsd) + .unwrap(); + + while let Some(trade) = binance_stream.recv().await { + info!("MarketEvent: {trade:?}"); + } +} + +// Initialise an INFO `Subscriber` for `Tracing` Json logs and install it as the global default. +fn init_logging() { + tracing_subscriber::fmt() + // Filter messages based on the INFO + .with_env_filter( + tracing_subscriber::filter::EnvFilter::builder() + .with_default_directive(tracing_subscriber::filter::LevelFilter::INFO.into()) + .from_env_lossy(), + ) + // Disable colours on release builds + .with_ansi(cfg!(debug_assertions)) + // Enable Json formatting + .json() + // Install this Tracing subscriber as global default + .init() +} diff --git a/barter-data/examples/public_trades_streams_multi_exchange.rs b/barter-data/examples/public_trades_streams_multi_exchange.rs new file mode 100644 index 00000000..1fac800b --- /dev/null +++ b/barter-data/examples/public_trades_streams_multi_exchange.rs @@ -0,0 +1,127 @@ +use barter_data::{ + exchange::{ + binance::{futures::BinanceFuturesUsd, spot::BinanceSpot}, + bitmex::Bitmex, + bybit::{futures::BybitPerpetualsUsd, spot::BybitSpot}, + coinbase::Coinbase, + gateio::{ + option::GateioOptions, + perpetual::{GateioPerpetualsBtc, GateioPerpetualsUsd}, + spot::GateioSpot, + }, + okx::Okx, + }, + streams::Streams, + subscription::trade::PublicTrades, +}; +use barter_integration::model::instrument::kind::{ + FutureContract, InstrumentKind, OptionContract, OptionExercise, OptionKind, +}; +use chrono::{TimeZone, Utc}; +use futures::StreamExt; +use tracing::info; + +#[rustfmt::skip] +#[tokio::main] +async fn main() { + // Initialise INFO Tracing log subscriber + init_logging(); + + // Initialise PublicTrades Streams for various exchanges + // '--> each call to StreamBuilder::subscribe() creates a separate WebSocket connection + let streams = Streams::::builder() + .subscribe([ + (BinanceSpot::default(), "btc", "usdt", InstrumentKind::Spot, PublicTrades), + (BinanceSpot::default(), "eth", "usdt", InstrumentKind::Spot, PublicTrades), + ]) + .subscribe([ + (BinanceFuturesUsd::default(), "btc", "usdt", InstrumentKind::Perpetual, PublicTrades), + (BinanceFuturesUsd::default(), "eth", "usdt", InstrumentKind::Perpetual, PublicTrades), + ]) + .subscribe([ + (Coinbase, "btc", "usd", InstrumentKind::Spot, PublicTrades), + (Coinbase, "eth", "usd", InstrumentKind::Spot, PublicTrades), + ]) + .subscribe([ + (GateioSpot::default(), "btc", "usdt", InstrumentKind::Spot, PublicTrades), + ]) + .subscribe([ + (GateioPerpetualsUsd::default(), "btc", "usdt", InstrumentKind::Perpetual, PublicTrades), + ]) + .subscribe([ + (GateioPerpetualsBtc::default(), "btc", "usd", InstrumentKind::Perpetual, PublicTrades), + ]) + .subscribe([ + (GateioOptions::default(), "btc", "usdt", InstrumentKind::Option(put_contract()), PublicTrades), + ]) + .subscribe([ + (Okx, "btc", "usdt", InstrumentKind::Spot, PublicTrades), + (Okx, "btc", "usdt", InstrumentKind::Perpetual, PublicTrades), + (Okx, "btc", "usd", InstrumentKind::Future(future_contract()), PublicTrades), + (Okx, "btc", "usd", InstrumentKind::Option(call_contract()), PublicTrades), + ]) + .subscribe([ + (BybitSpot::default(), "btc", "usdt", InstrumentKind::Spot, PublicTrades), + (BybitSpot::default(), "eth", "usdt", InstrumentKind::Spot, PublicTrades), + ]) + .subscribe([ + (BybitPerpetualsUsd::default(), "btc", "usdt", InstrumentKind::Perpetual, PublicTrades), + ]) + .subscribe([ + (Bitmex, "xbt", "usd", InstrumentKind::Perpetual, PublicTrades) + ]) + .init() + .await + .unwrap(); + + // Join all exchange PublicTrades streams into a single tokio_stream::StreamMap + // Notes: + // - Use `streams.select(ExchangeId)` to interact with the individual exchange streams! + // - Use `streams.join()` to join all exchange streams into a single mpsc::UnboundedReceiver! + let mut joined_stream = streams.join_map().await; + + while let Some((exchange, trade)) = joined_stream.next().await { + info!("Exchange: {exchange}, MarketEvent: {trade:?}"); + } +} + +// Initialise an INFO `Subscriber` for `Tracing` Json logs and install it as the global default. +fn init_logging() { + tracing_subscriber::fmt() + // Filter messages based on the INFO + .with_env_filter( + tracing_subscriber::filter::EnvFilter::builder() + .with_default_directive(tracing_subscriber::filter::LevelFilter::INFO.into()) + .from_env_lossy(), + ) + // Disable colours on release builds + .with_ansi(cfg!(debug_assertions)) + // Enable Json formatting + .json() + // Install this Tracing subscriber as global default + .init() +} + +fn put_contract() -> OptionContract { + OptionContract { + kind: OptionKind::Put, + exercise: OptionExercise::European, + expiry: Utc.timestamp_millis_opt(1703808000000).unwrap(), + strike: rust_decimal_macros::dec!(50000), + } +} + +fn future_contract() -> FutureContract { + FutureContract { + expiry: Utc.timestamp_millis_opt(1695945600000).unwrap(), + } +} + +fn call_contract() -> OptionContract { + OptionContract { + kind: OptionKind::Call, + exercise: OptionExercise::American, + expiry: Utc.timestamp_millis_opt(1703808000000).unwrap(), + strike: rust_decimal_macros::dec!(35000), + } +} diff --git a/barter-data/src/error.rs b/barter-data/src/error.rs new file mode 100644 index 00000000..59836af7 --- /dev/null +++ b/barter-data/src/error.rs @@ -0,0 +1,72 @@ +use crate::{exchange::ExchangeId, subscription::SubKind}; +use barter_integration::error::SocketError; +use thiserror::Error; + +/// All errors generated in `barter-data`. +#[derive(Debug, Error)] +pub enum DataError { + #[error("SocketError: {0}")] + Socket(#[from] SocketError), + + #[error("unsupported dynamic Subscription for exchange: {exchange}, kind: {sub_kind}")] + Unsupported { + exchange: ExchangeId, + sub_kind: SubKind, + }, + + #[error( + "\ + InvalidSequence: first_update_id {first_update_id} does not follow on from the \ + prev_last_update_id {prev_last_update_id} \ + " + )] + InvalidSequence { + prev_last_update_id: u64, + first_update_id: u64, + }, +} + +impl DataError { + /// Determine if an error requires a [`MarketStream`](super::MarketStream) to re-initialise. + #[allow(clippy::match_like_matches_macro)] + pub fn is_terminal(&self) -> bool { + match self { + DataError::InvalidSequence { .. } => true, + _ => false, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_data_error_is_terminal() { + struct TestCase { + input: DataError, + expected: bool, + } + + let tests = vec![ + TestCase { + // TC0: is terminal w/ DataError::InvalidSequence + input: DataError::InvalidSequence { + prev_last_update_id: 0, + first_update_id: 0, + }, + expected: true, + }, + TestCase { + // TC1: is not terminal w/ DataError::Socket + input: DataError::Socket(SocketError::Sink), + expected: false, + }, + ]; + + for (index, test) in tests.into_iter().enumerate() { + let actual = test.input.is_terminal(); + assert_eq!(actual, test.expected, "TC{} failed", index); + } + } +} diff --git a/barter-data/src/event.rs b/barter-data/src/event.rs new file mode 100644 index 00000000..02251097 --- /dev/null +++ b/barter-data/src/event.rs @@ -0,0 +1,134 @@ +use crate::{ + error::DataError, + subscription::{ + book::{OrderBook, OrderBookL1}, + candle::Candle, + liquidation::Liquidation, + trade::PublicTrade, + }, +}; +use barter_integration::model::{instrument::Instrument, Exchange}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +/// Convenient new type containing a collection of [`MarketEvent`](MarketEvent)s. +#[derive(Debug)] +pub struct MarketIter(pub Vec, DataError>>); + +impl FromIterator, DataError>> + for MarketIter +{ + fn from_iter(iter: Iter) -> Self + where + Iter: IntoIterator, DataError>>, + { + Self(iter.into_iter().collect()) + } +} + +/// Normalised Barter [`MarketEvent`](Self) wrapping the `T` data variant in metadata. +/// +/// Note: `T` can be an enum such as the [`DataKind`] if required. +/// +/// See [`crate::subscription`] for all existing Barter Market event variants. +/// +/// ### Examples +/// - [`MarketEvent`](PublicTrade) +/// - [`MarketEvent`](OrderBookL1) +/// - [`MarketEvent`](DataKind) +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Debug, Deserialize, Serialize)] +pub struct MarketEvent { + pub exchange_time: DateTime, + pub received_time: DateTime, + pub exchange: Exchange, + pub instrument: InstrumentId, + pub kind: T, +} + +/// Available kinds of normalised Barter [`MarketEvent`](MarketEvent). +/// +/// ### Notes +/// - [`Self`] is only used as the [`MarketEvent`](MarketEvent) `Output` when combining +/// several [`Streams`](crate::streams::Streams) using the +/// [`MultiStreamBuilder`](crate::streams::builder::multi::MultiStreamBuilder), or via +/// the [`DynamicStreams::select_all`](crate::streams::builder::dynamic::DynamicStreams) method. +/// - [`Self`] is purposefully not supported in any +/// [`Subscription`](crate::subscription::Subscription)s directly, it is only used to +/// make ergonomic [`Streams`](crate::streams::Streams) containing many +/// [`MarketEvent`](MarketEvent) kinds. +#[derive(Clone, PartialEq, PartialOrd, Debug, Deserialize, Serialize)] +pub enum DataKind { + Trade(PublicTrade), + OrderBookL1(OrderBookL1), + OrderBook(OrderBook), + Candle(Candle), + Liquidation(Liquidation), +} + +impl From> + for MarketEvent +{ + fn from(event: MarketEvent) -> Self { + Self { + exchange_time: event.exchange_time, + received_time: event.received_time, + exchange: event.exchange, + instrument: event.instrument, + kind: DataKind::Trade(event.kind), + } + } +} + +impl From> + for MarketEvent +{ + fn from(event: MarketEvent) -> Self { + Self { + exchange_time: event.exchange_time, + received_time: event.received_time, + exchange: event.exchange, + instrument: event.instrument, + kind: DataKind::OrderBookL1(event.kind), + } + } +} + +impl From> + for MarketEvent +{ + fn from(event: MarketEvent) -> Self { + Self { + exchange_time: event.exchange_time, + received_time: event.received_time, + exchange: event.exchange, + instrument: event.instrument, + kind: DataKind::OrderBook(event.kind), + } + } +} + +impl From> for MarketEvent { + fn from(event: MarketEvent) -> Self { + Self { + exchange_time: event.exchange_time, + received_time: event.received_time, + exchange: event.exchange, + instrument: event.instrument, + kind: DataKind::Candle(event.kind), + } + } +} + +impl From> + for MarketEvent +{ + fn from(event: MarketEvent) -> Self { + Self { + exchange_time: event.exchange_time, + received_time: event.received_time, + exchange: event.exchange, + instrument: event.instrument, + kind: DataKind::Liquidation(event.kind), + } + } +} diff --git a/barter-data/src/exchange/binance/book/l1.rs b/barter-data/src/exchange/binance/book/l1.rs new file mode 100644 index 00000000..27f3f6e7 --- /dev/null +++ b/barter-data/src/exchange/binance/book/l1.rs @@ -0,0 +1,166 @@ +use crate::{ + event::{MarketEvent, MarketIter}, + exchange::{binance::channel::BinanceChannel, subscription::ExchangeSub, ExchangeId}, + subscription::book::{Level, OrderBookL1}, + Identifier, +}; +use barter_integration::model::{Exchange, SubscriptionId}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +/// [`Binance`](super::super::Binance) real-time OrderBook Level1 (top of book) message. +/// +/// ### Raw Payload Examples +/// #### BinanceSpot OrderBookL1 +/// See docs: +/// ```json +/// { +/// "u":22606535573, +/// "s":"ETHUSDT", +/// "b":"1215.27000000", +/// "B":"32.49110000", +/// "a":"1215.28000000", +/// "A":"13.93900000" +/// } +/// ``` +/// +/// #### BinanceFuturesUsd OrderBookL1 +/// See docs: +/// ```json +/// { +/// "u":22606535573, +/// "s":"ETHUSDT", +/// "b":"1215.27000000", +/// "B":"32.49110000", +/// "a":"1215.28000000", +/// "A":"13.93900000" +/// } +/// ``` +#[derive(Clone, PartialEq, PartialOrd, Debug, Deserialize, Serialize)] +pub struct BinanceOrderBookL1 { + #[serde(alias = "s", deserialize_with = "de_ob_l1_subscription_id")] + pub subscription_id: SubscriptionId, + #[serde( + alias = "T", + deserialize_with = "barter_integration::de::de_u64_epoch_ms_as_datetime_utc", + default = "Utc::now" + )] + pub time: DateTime, + #[serde(alias = "b", deserialize_with = "barter_integration::de::de_str")] + pub best_bid_price: f64, + #[serde(alias = "B", deserialize_with = "barter_integration::de::de_str")] + pub best_bid_amount: f64, + #[serde(alias = "a", deserialize_with = "barter_integration::de::de_str")] + pub best_ask_price: f64, + #[serde(alias = "A", deserialize_with = "barter_integration::de::de_str")] + pub best_ask_amount: f64, +} + +impl Identifier> for BinanceOrderBookL1 { + fn id(&self) -> Option { + Some(self.subscription_id.clone()) + } +} + +impl From<(ExchangeId, InstrumentId, BinanceOrderBookL1)> + for MarketIter +{ + fn from( + (exchange_id, instrument, book): (ExchangeId, InstrumentId, BinanceOrderBookL1), + ) -> Self { + Self(vec![Ok(MarketEvent { + exchange_time: book.time, + received_time: Utc::now(), + exchange: Exchange::from(exchange_id), + instrument, + kind: OrderBookL1 { + last_update_time: book.time, + best_bid: Level::new(book.best_bid_price, book.best_bid_amount), + best_ask: Level::new(book.best_ask_price, book.best_ask_amount), + }, + })]) + } +} + +/// Deserialize a [`BinanceOrderBookL1`] "s" (eg/ "BTCUSDT") as the associated [`SubscriptionId`]. +/// +/// eg/ "@bookTicker|BTCUSDT" +pub fn de_ob_l1_subscription_id<'de, D>(deserializer: D) -> Result +where + D: serde::de::Deserializer<'de>, +{ + <&str as Deserialize>::deserialize(deserializer) + .map(|market| ExchangeSub::from((BinanceChannel::ORDER_BOOK_L1, market)).id()) +} + +#[cfg(test)] +mod tests { + use super::*; + + mod de { + use super::*; + + #[test] + fn test_binance_order_book_l1() { + struct TestCase { + input: &'static str, + expected: BinanceOrderBookL1, + } + + let time = Utc::now(); + + let tests = vec![ + TestCase { + // TC0: valid Spot BinanceOrderBookL1 + input: r#" + { + "u":22606535573, + "s":"ETHUSDT", + "b":"1215.27000000", + "B":"32.49110000", + "a":"1215.28000000", + "A":"13.93900000" + } + "#, + expected: BinanceOrderBookL1 { + subscription_id: SubscriptionId::from("@bookTicker|ETHUSDT"), + time, + best_bid_price: 1215.27000000, + best_bid_amount: 32.49110000, + best_ask_price: 1215.28000000, + best_ask_amount: 13.93900000, + }, + }, + TestCase { + // TC1: valid FuturePerpetual BinanceOrderBookL1 + input: r#" + { + "e":"bookTicker", + "u":2286618712950, + "s":"BTCUSDT", + "b":"16858.90", + "B":"13.692", + "a":"16859.00", + "A":"30.219", + "T":1671621244670, + "E":1671621244673 + }"#, + expected: BinanceOrderBookL1 { + subscription_id: SubscriptionId::from("@bookTicker|BTCUSDT"), + time, + best_bid_price: 16858.90, + best_bid_amount: 13.692, + best_ask_price: 16859.00, + best_ask_amount: 30.219, + }, + }, + ]; + + for (index, test) in tests.into_iter().enumerate() { + let actual = serde_json::from_str::(test.input).unwrap(); + let actual = BinanceOrderBookL1 { time, ..actual }; + assert_eq!(actual, test.expected, "TC{} failed", index); + } + } + } +} diff --git a/barter-data/src/exchange/binance/book/l2.rs b/barter-data/src/exchange/binance/book/l2.rs new file mode 100644 index 00000000..47c944bb --- /dev/null +++ b/barter-data/src/exchange/binance/book/l2.rs @@ -0,0 +1,169 @@ +use super::{super::channel::BinanceChannel, BinanceLevel}; +use crate::{ + exchange::subscription::ExchangeSub, + subscription::book::{OrderBook, OrderBookSide}, + Identifier, +}; +use barter_integration::model::{Side, SubscriptionId}; +use chrono::Utc; +use serde::{Deserialize, Serialize}; + +/// [`Binance`](super::super::Binance) OrderBook Level2 snapshot HTTP message. +/// +/// Used as the starting [`OrderBook`] before OrderBook Level2 delta WebSocket updates are +/// applied. +/// +/// ### Payload Examples +/// See docs: +/// #### BinanceSpot OrderBookL2Snapshot +/// ```json +/// { +/// "lastUpdateId": 1027024, +/// "bids": [ +/// ["4.00000000", "431.00000000"] +/// ], +/// "asks": [ +/// ["4.00000200", "12.00000000"] +/// ] +/// } +/// ``` +/// +/// #### BinanceFuturesUsd OrderBookL2Snapshot +/// See docs: +/// ```json +/// { +/// "lastUpdateId": 1027024, +/// "E": 1589436922972, +/// "T": 1589436922959, +/// "bids": [ +/// ["4.00000000", "431.00000000"] +/// ], +/// "asks": [ +/// ["4.00000200", "12.00000000"] +/// ] +/// } +/// ``` +#[derive(Clone, PartialEq, PartialOrd, Debug, Deserialize, Serialize)] +pub struct BinanceOrderBookL2Snapshot { + #[serde(rename = "lastUpdateId")] + pub last_update_id: u64, + pub bids: Vec, + pub asks: Vec, +} + +impl From for OrderBook { + fn from(snapshot: BinanceOrderBookL2Snapshot) -> Self { + Self { + last_update_time: Utc::now(), + bids: OrderBookSide::new(Side::Buy, snapshot.bids), + asks: OrderBookSide::new(Side::Sell, snapshot.asks), + } + } +} + +/// Deserialize a +/// [`BinanceSpotOrderBookL2Delta`](super::super::spot::l2::BinanceSpotOrderBookL2Delta) or +/// [`BinanceFuturesOrderBookL2Delta`](super::super::futures::l2::BinanceFuturesOrderBookL2Delta) +/// "s" field (eg/ "BTCUSDT") as the associated [`SubscriptionId`] +/// +/// eg/ "@depth@100ms|BTCUSDT" +pub fn de_ob_l2_subscription_id<'de, D>(deserializer: D) -> Result +where + D: serde::de::Deserializer<'de>, +{ + <&str as Deserialize>::deserialize(deserializer) + .map(|market| ExchangeSub::from((BinanceChannel::ORDER_BOOK_L2, market)).id()) +} + +#[cfg(test)] +mod tests { + use super::*; + + mod de { + use super::*; + + #[test] + fn test_binance_order_book_l2_snapshot() { + struct TestCase { + input: &'static str, + expected: BinanceOrderBookL2Snapshot, + } + + let tests = vec![ + TestCase { + // TC0: valid Spot BinanceOrderBookL2Snapshot + input: r#" + { + "lastUpdateId": 1027024, + "bids": [ + [ + "4.00000000", + "431.00000000" + ] + ], + "asks": [ + [ + "4.00000200", + "12.00000000" + ] + ] + } + "#, + expected: BinanceOrderBookL2Snapshot { + last_update_id: 1027024, + bids: vec![BinanceLevel { + price: 4.0, + amount: 431.0, + }], + asks: vec![BinanceLevel { + price: 4.00000200, + amount: 12.0, + }], + }, + }, + TestCase { + // TC1: valid FuturePerpetual BinanceOrderBookL2Snapshot + input: r#" + { + "lastUpdateId": 1027024, + "E": 1589436922972, + "T": 1589436922959, + "bids": [ + [ + "4.00000000", + "431.00000000" + ] + ], + "asks": [ + [ + "4.00000200", + "12.00000000" + ] + ] + } + "#, + expected: BinanceOrderBookL2Snapshot { + last_update_id: 1027024, + bids: vec![BinanceLevel { + price: 4.0, + amount: 431.0, + }], + asks: vec![BinanceLevel { + price: 4.00000200, + amount: 12.0, + }], + }, + }, + ]; + + for (index, test) in tests.into_iter().enumerate() { + assert_eq!( + serde_json::from_str::(test.input).unwrap(), + test.expected, + "TC{} failed", + index + ); + } + } + } +} diff --git a/barter-data/src/exchange/binance/book/mod.rs b/barter-data/src/exchange/binance/book/mod.rs new file mode 100644 index 00000000..b1987a2b --- /dev/null +++ b/barter-data/src/exchange/binance/book/mod.rs @@ -0,0 +1,53 @@ +use crate::subscription::book::Level; +use serde::{Deserialize, Serialize}; + +/// Level 1 OrderBook types (top of book). +pub mod l1; + +/// Level 2 OrderBook types (top of book). +pub mod l2; + +/// [`Binance`](super::Binance) OrderBook level. +/// +/// #### Raw Payload Examples +/// See docs: +/// ```json +/// ["4.00000200", "12.00000000"] +/// ``` +#[derive(Clone, Copy, PartialEq, PartialOrd, Debug, Deserialize, Serialize)] +pub struct BinanceLevel { + #[serde(deserialize_with = "barter_integration::de::de_str")] + pub price: f64, + #[serde(deserialize_with = "barter_integration::de::de_str")] + pub amount: f64, +} + +impl From for Level { + fn from(level: BinanceLevel) -> Self { + Self { + price: level.price, + amount: level.amount, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + mod de { + use super::*; + + #[test] + fn test_binance_level() { + let input = r#"["4.00000200", "12.00000000"]"#; + assert_eq!( + serde_json::from_str::(input).unwrap(), + BinanceLevel { + price: 4.00000200, + amount: 12.0 + }, + ) + } + } +} diff --git a/barter-data/src/exchange/binance/channel.rs b/barter-data/src/exchange/binance/channel.rs new file mode 100644 index 00000000..1575062f --- /dev/null +++ b/barter-data/src/exchange/binance/channel.rs @@ -0,0 +1,87 @@ +use super::{futures::BinanceFuturesUsd, Binance}; +use crate::{ + subscription::{ + book::{OrderBooksL1, OrderBooksL2}, + liquidation::Liquidations, + trade::PublicTrades, + Subscription, + }, + Identifier, +}; +use serde::Serialize; + +/// Type that defines how to translate a Barter [`Subscription`] into a [`Binance`] +/// channel to be subscribed to. +/// +/// See docs: +/// See docs: +#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Serialize)] +pub struct BinanceChannel(pub &'static str); + +impl BinanceChannel { + /// [`Binance`] real-time trades channel name. + /// + /// See docs: + /// + /// Note: + /// For [`BinanceFuturesUsd`] this real-time + /// stream is undocumented. + /// + /// See discord: + pub const TRADES: Self = Self("@trade"); + + /// [`Binance`] real-time OrderBook Level1 (top of book) channel name. + /// + /// See docs: + /// See docs: + pub const ORDER_BOOK_L1: Self = Self("@bookTicker"); + + /// [`Binance`] OrderBook Level2 channel name (100ms delta updates). + /// + /// See docs: + /// See docs: + pub const ORDER_BOOK_L2: Self = Self("@depth@100ms"); + + /// [`BinanceFuturesUsd`] liquidation orders channel name. + /// + /// See docs: + pub const LIQUIDATIONS: Self = Self("@forceOrder"); +} + +impl Identifier + for Subscription, Instrument, PublicTrades> +{ + fn id(&self) -> BinanceChannel { + BinanceChannel::TRADES + } +} + +impl Identifier + for Subscription, Instrument, OrderBooksL1> +{ + fn id(&self) -> BinanceChannel { + BinanceChannel::ORDER_BOOK_L1 + } +} + +impl Identifier + for Subscription, Instrument, OrderBooksL2> +{ + fn id(&self) -> BinanceChannel { + BinanceChannel::ORDER_BOOK_L2 + } +} + +impl Identifier + for Subscription +{ + fn id(&self) -> BinanceChannel { + BinanceChannel::LIQUIDATIONS + } +} + +impl AsRef for BinanceChannel { + fn as_ref(&self) -> &str { + self.0 + } +} diff --git a/barter-data/src/exchange/binance/futures/l2.rs b/barter-data/src/exchange/binance/futures/l2.rs new file mode 100644 index 00000000..85a54a1f --- /dev/null +++ b/barter-data/src/exchange/binance/futures/l2.rs @@ -0,0 +1,611 @@ +use super::super::book::{l2::BinanceOrderBookL2Snapshot, BinanceLevel}; +use crate::{ + error::DataError, + subscription::book::OrderBook, + transformer::book::{InstrumentOrderBook, OrderBookUpdater}, + Identifier, +}; +use async_trait::async_trait; +use barter_integration::{ + error::SocketError, + model::{instrument::Instrument, SubscriptionId}, + protocol::websocket::WsMessage, +}; +use chrono::Utc; +use serde::{Deserialize, Serialize}; +use tokio::sync::mpsc; + +/// [`BinanceFuturesUsd`](super::BinanceFuturesUsd) HTTP OrderBook L2 snapshot url. +/// +/// See docs: +pub const HTTP_BOOK_L2_SNAPSHOT_URL_BINANCE_SPOT: &str = "https://fapi.binance.com/fapi/v1/depth"; + +/// [`BinanceFuturesUsd`](super::BinanceFuturesUsd) OrderBook Level2 deltas WebSocket message. +/// +/// ### Raw Payload Examples +/// See docs: +/// ```json +/// { +/// "e": "depthUpdate", +/// "E": 123456789, +/// "T": 123456788, +/// "s": "BTCUSDT", +/// "U": 157, +/// "u": 160, +/// "pu": 149, +/// "b": [ +/// ["0.0024", "10"] +/// ], +/// "a": [ +/// ["0.0026", "100"] +/// ] +/// } +/// ``` +#[derive(Clone, PartialEq, PartialOrd, Debug, Deserialize, Serialize)] +pub struct BinanceFuturesOrderBookL2Delta { + #[serde( + alias = "s", + deserialize_with = "super::super::book::l2::de_ob_l2_subscription_id" + )] + pub subscription_id: SubscriptionId, + #[serde(alias = "U")] + pub first_update_id: u64, + #[serde(alias = "u")] + pub last_update_id: u64, + #[serde(alias = "pu")] + pub prev_last_update_id: u64, + #[serde(alias = "b")] + pub bids: Vec, + #[serde(alias = "a")] + pub asks: Vec, +} + +impl Identifier> for BinanceFuturesOrderBookL2Delta { + fn id(&self) -> Option { + Some(self.subscription_id.clone()) + } +} + +/// [`Binance`](super::super::Binance) [`BinanceServerFuturesUsd`](super::BinanceServerFuturesUsd) +/// [`OrderBookUpdater`]. +/// +/// BinanceFuturesUsd: How To Manage A Local OrderBook Correctly +/// +/// 1. Open a stream to wss://fstream.binance.com/stream?streams=BTCUSDT@depth. +/// 2. Buffer the events you receive from the stream. +/// 3. Get a depth snapshot from . +/// 4. -- *DIFFERENT FROM SPOT* -- +/// Drop any event where u is < lastUpdateId in the snapshot. +/// 5. -- *DIFFERENT FROM SPOT* -- +/// The first processed event should have U <= lastUpdateId AND u >= lastUpdateId +/// 6. -- *DIFFERENT FROM SPOT* -- +/// While listening to the stream, each new event's pu should be equal to the previous +/// event's u, otherwise initialize the process from step 3. +/// 7. The data in each event is the absolute quantity for a price level. +/// 8. If the quantity is 0, remove the price level. +/// +/// Notes: +/// - Receiving an event that removes a price level that is not in your local order book can happen and is normal. +/// - Uppercase U => first_update_id +/// - Lowercase u => last_update_id, +/// - Lowercase pu => prev_last_update_id +/// +/// See docs: +#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Deserialize, Serialize)] +pub struct BinanceFuturesBookUpdater { + pub updates_processed: u64, + pub last_update_id: u64, +} + +impl BinanceFuturesBookUpdater { + /// Construct a new BinanceFutures [`OrderBookUpdater`] using the provided last_update_id from + /// a HTTP snapshot. + pub fn new(last_update_id: u64) -> Self { + Self { + updates_processed: 0, + last_update_id, + } + } + + /// BinanceFuturesUsd: How To Manage A Local OrderBook Correctly: Step 5: + /// "The first processed event should have U <= lastUpdateId AND u >= lastUpdateId" + /// + /// See docs: + pub fn is_first_update(&self) -> bool { + self.updates_processed == 0 + } + + /// BinanceFuturesUsd: How To Manage A Local OrderBook Correctly: Step 5: + /// "The first processed event should have U <= lastUpdateId AND u >= lastUpdateId" + /// + /// See docs: + pub fn validate_first_update( + &self, + update: &BinanceFuturesOrderBookL2Delta, + ) -> Result<(), DataError> { + if update.first_update_id <= self.last_update_id + && update.last_update_id >= self.last_update_id + { + Ok(()) + } else { + Err(DataError::InvalidSequence { + prev_last_update_id: self.last_update_id, + first_update_id: update.first_update_id, + }) + } + } + + /// BinanceFuturesUsd: How To Manage A Local OrderBook Correctly: Step 6: + /// "While listening to the stream, each new event's pu should be equal to the previous + /// event's u, otherwise initialize the process from step 3." + /// + /// See docs: + pub fn validate_next_update( + &self, + update: &BinanceFuturesOrderBookL2Delta, + ) -> Result<(), DataError> { + if update.prev_last_update_id == self.last_update_id { + Ok(()) + } else { + Err(DataError::InvalidSequence { + prev_last_update_id: self.last_update_id, + first_update_id: update.first_update_id, + }) + } + } +} + +#[async_trait] +impl OrderBookUpdater for BinanceFuturesBookUpdater { + type OrderBook = OrderBook; + type Update = BinanceFuturesOrderBookL2Delta; + + async fn init( + _: mpsc::UnboundedSender, + instrument: Instrument, + ) -> Result, DataError> + where + Exchange: Send, + Kind: Send, + { + // Construct initial OrderBook snapshot GET url + let snapshot_url = format!( + "{}?symbol={}{}&limit=100", + HTTP_BOOK_L2_SNAPSHOT_URL_BINANCE_SPOT, + instrument.base.as_ref().to_uppercase(), + instrument.quote.as_ref().to_uppercase() + ); + + // Fetch initial OrderBook snapshot via HTTP + let snapshot = reqwest::get(snapshot_url) + .await + .map_err(SocketError::Http)? + .json::() + .await + .map_err(SocketError::Http)?; + + Ok(InstrumentOrderBook { + instrument, + updater: Self::new(snapshot.last_update_id), + book: OrderBook::from(snapshot), + }) + } + + fn update( + &mut self, + book: &mut Self::OrderBook, + update: Self::Update, + ) -> Result, DataError> { + // BinanceFuturesUsd: How To Manage A Local OrderBook Correctly + // See Self's Rust Docs for more information on each numbered step + // See docs: + + // 4. Drop any event where u is < lastUpdateId in the snapshot: + if update.last_update_id < self.last_update_id { + return Ok(None); + } + + if self.is_first_update() { + // 5. The first processed event should have U <= lastUpdateId AND u >= lastUpdateId: + self.validate_first_update(&update)?; + } else { + // 6. Each new event's pu should be equal to the previous event's u: + self.validate_next_update(&update)?; + } + + // Update OrderBook metadata & Levels: + // 7. The data in each event is the absolute quantity for a price level. + // 8. If the quantity is 0, remove the price level. + book.last_update_time = Utc::now(); + book.bids.upsert(update.bids); + book.asks.upsert(update.asks); + + // Update OrderBookUpdater metadata + self.updates_processed += 1; + self.last_update_id = update.last_update_id; + + Ok(Some(book.snapshot())) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + mod de { + use super::*; + + #[test] + fn test_binance_futures_order_book_l2_deltas() { + let input = r#" + { + "e": "depthUpdate", + "E": 123456789, + "T": 123456788, + "s": "BTCUSDT", + "U": 157, + "u": 160, + "pu": 149, + "b": [ + [ + "0.0024", + "10" + ] + ], + "a": [ + [ + "0.0026", + "100" + ] + ] + } + "#; + + assert_eq!( + serde_json::from_str::(input).unwrap(), + BinanceFuturesOrderBookL2Delta { + subscription_id: SubscriptionId::from("@depth@100ms|BTCUSDT"), + first_update_id: 157, + last_update_id: 160, + prev_last_update_id: 149, + bids: vec![BinanceLevel { + price: 0.0024, + amount: 10.0 + },], + asks: vec![BinanceLevel { + price: 0.0026, + amount: 100.0 + },] + } + ); + } + } + + mod binance_futures_book_updater { + use super::*; + use crate::subscription::book::{Level, OrderBookSide}; + use barter_integration::model::Side; + + #[test] + fn test_is_first_update() { + struct TestCase { + updater: BinanceFuturesBookUpdater, + expected: bool, + } + + let tests = vec![ + TestCase { + // TC0: is first update + updater: BinanceFuturesBookUpdater::new(10), + expected: true, + }, + TestCase { + // TC1: is not first update + updater: BinanceFuturesBookUpdater { + updates_processed: 10, + last_update_id: 100, + }, + expected: false, + }, + ]; + + for (index, test) in tests.into_iter().enumerate() { + assert_eq!( + test.updater.is_first_update(), + test.expected, + "TC{} failed", + index + ); + } + } + + #[test] + fn test_validate_first_update() { + struct TestCase { + updater: BinanceFuturesBookUpdater, + input: BinanceFuturesOrderBookL2Delta, + expected: Result<(), DataError>, + } + + let tests = vec![ + TestCase { + // TC0: valid first update + updater: BinanceFuturesBookUpdater { + updates_processed: 0, + last_update_id: 100, + }, + input: BinanceFuturesOrderBookL2Delta { + subscription_id: SubscriptionId::from("subscription_id"), + first_update_id: 100, + last_update_id: 110, + prev_last_update_id: 90, + bids: vec![], + asks: vec![], + }, + expected: Ok(()), + }, + TestCase { + // TC1: invalid first update w/ u < lastUpdateId + updater: BinanceFuturesBookUpdater { + updates_processed: 0, + last_update_id: 100, + }, + input: BinanceFuturesOrderBookL2Delta { + subscription_id: SubscriptionId::from("subscription_id"), + first_update_id: 100, + last_update_id: 90, + prev_last_update_id: 90, + bids: vec![], + asks: vec![], + }, + expected: Err(DataError::InvalidSequence { + prev_last_update_id: 100, + first_update_id: 100, + }), + }, + TestCase { + // TC2: invalid first update w/ U > lastUpdateId + updater: BinanceFuturesBookUpdater { + updates_processed: 0, + last_update_id: 100, + }, + input: BinanceFuturesOrderBookL2Delta { + subscription_id: SubscriptionId::from("subscription_id"), + first_update_id: 110, + last_update_id: 120, + prev_last_update_id: 90, + bids: vec![], + asks: vec![], + }, + expected: Err(DataError::InvalidSequence { + prev_last_update_id: 100, + first_update_id: 110, + }), + }, + TestCase { + // TC3: invalid first update w/ u < lastUpdateId & U > lastUpdateId + updater: BinanceFuturesBookUpdater { + updates_processed: 0, + last_update_id: 100, + }, + input: BinanceFuturesOrderBookL2Delta { + subscription_id: SubscriptionId::from("subscription_id"), + first_update_id: 110, + last_update_id: 90, + prev_last_update_id: 90, + bids: vec![], + asks: vec![], + }, + expected: Err(DataError::InvalidSequence { + prev_last_update_id: 100, + first_update_id: 110, + }), + }, + ]; + + for (index, test) in tests.into_iter().enumerate() { + let actual = test.updater.validate_first_update(&test.input); + match (actual, test.expected) { + (Ok(actual), Ok(expected)) => { + assert_eq!(actual, expected, "TC{} failed", index) + } + (Err(_), Err(_)) => { + // Test passed + } + (actual, expected) => { + // Test failed + panic!("TC{index} failed because actual != expected. \nActual: {actual:?}\nExpected: {expected:?}\n"); + } + } + } + } + + #[test] + fn test_validate_next_update() { + struct TestCase { + updater: BinanceFuturesBookUpdater, + input: BinanceFuturesOrderBookL2Delta, + expected: Result<(), DataError>, + } + + let tests = vec![ + TestCase { + // TC0: valid next update + updater: BinanceFuturesBookUpdater { + updates_processed: 100, + last_update_id: 100, + }, + input: BinanceFuturesOrderBookL2Delta { + subscription_id: SubscriptionId::from("subscription_id"), + first_update_id: 101, + last_update_id: 110, + prev_last_update_id: 100, + bids: vec![], + asks: vec![], + }, + expected: Ok(()), + }, + TestCase { + // TC1: invalid first update w/ pu != prev_last_update_id + updater: BinanceFuturesBookUpdater { + updates_processed: 100, + last_update_id: 100, + }, + input: BinanceFuturesOrderBookL2Delta { + subscription_id: SubscriptionId::from("subscription_id"), + first_update_id: 100, + last_update_id: 90, + prev_last_update_id: 90, + bids: vec![], + asks: vec![], + }, + expected: Err(DataError::InvalidSequence { + prev_last_update_id: 100, + first_update_id: 100, + }), + }, + ]; + + for (index, test) in tests.into_iter().enumerate() { + let actual = test.updater.validate_next_update(&test.input); + match (actual, test.expected) { + (Ok(actual), Ok(expected)) => { + assert_eq!(actual, expected, "TC{} failed", index) + } + (Err(_), Err(_)) => { + // Test passed + } + (actual, expected) => { + // Test failed + panic!("TC{index} failed because actual != expected. \nActual: {actual:?}\nExpected: {expected:?}\n"); + } + } + } + } + + #[test] + fn update() { + struct TestCase { + updater: BinanceFuturesBookUpdater, + book: OrderBook, + input_update: BinanceFuturesOrderBookL2Delta, + expected: Result, DataError>, + } + + let time = Utc::now(); + + let tests = vec![ + TestCase { + // TC0: Drop any event where u is < lastUpdateId in the snapshot + updater: BinanceFuturesBookUpdater { + updates_processed: 100, + last_update_id: 100, + }, + book: OrderBook { + last_update_time: time, + bids: OrderBookSide::new(Side::Buy, vec![Level::new(50, 1)]), + asks: OrderBookSide::new(Side::Sell, vec![Level::new(100, 1)]), + }, + input_update: BinanceFuturesOrderBookL2Delta { + subscription_id: SubscriptionId::from("subscription_id"), + first_update_id: 0, + last_update_id: 0, + prev_last_update_id: 0, + bids: vec![], + asks: vec![], + }, + expected: Ok(None), + }, + TestCase { + // TC1: valid update with sorted snapshot generated + updater: BinanceFuturesBookUpdater { + updates_processed: 100, + last_update_id: 100, + }, + book: OrderBook { + last_update_time: time, + bids: OrderBookSide::new( + Side::Buy, + vec![Level::new(80, 1), Level::new(100, 1), Level::new(90, 1)], + ), + asks: OrderBookSide::new( + Side::Sell, + vec![Level::new(150, 1), Level::new(110, 1), Level::new(120, 1)], + ), + }, + input_update: BinanceFuturesOrderBookL2Delta { + subscription_id: SubscriptionId::from("subscription_id"), + first_update_id: 101, + last_update_id: 110, + prev_last_update_id: 100, + bids: vec![ + // Level exists & new value is 0 => remove Level + BinanceLevel { + price: 80.0, + amount: 0.0, + }, + // Level exists & new value is > 0 => replace Level + BinanceLevel { + price: 90.0, + amount: 10.0, + }, + ], + asks: vec![ + // Level does not exist & new value > 0 => insert new Level + BinanceLevel { + price: 200.0, + amount: 1.0, + }, + // Level does not exist & new value is 0 => no change + BinanceLevel { + price: 500.0, + amount: 0.0, + }, + ], + }, + expected: Ok(Some(OrderBook { + last_update_time: time, + bids: OrderBookSide::new( + Side::Buy, + vec![Level::new(100, 1), Level::new(90, 10)], + ), + asks: OrderBookSide::new( + Side::Sell, + vec![ + Level::new(110, 1), + Level::new(120, 1), + Level::new(150, 1), + Level::new(200, 1), + ], + ), + })), + }, + ]; + + for (index, mut test) in tests.into_iter().enumerate() { + let actual = test.updater.update(&mut test.book, test.input_update); + + match (actual, test.expected) { + (Ok(Some(actual)), Ok(Some(expected))) => { + // Replace time with deterministic timestamp + let actual = OrderBook { + last_update_time: time, + ..actual + }; + assert_eq!(actual, expected, "TC{} failed", index) + } + (Ok(None), Ok(None)) => { + // Test passed + } + (Err(_), Err(_)) => { + // Test passed + } + (actual, expected) => { + // Test failed + panic!("TC{index} failed because actual != expected. \nActual: {actual:?}\nExpected: {expected:?}\n"); + } + } + } + } + } +} diff --git a/barter-data/src/exchange/binance/futures/liquidation.rs b/barter-data/src/exchange/binance/futures/liquidation.rs new file mode 100644 index 00000000..2dca9997 --- /dev/null +++ b/barter-data/src/exchange/binance/futures/liquidation.rs @@ -0,0 +1,165 @@ +use super::super::BinanceChannel; +use crate::{ + event::{MarketEvent, MarketIter}, + exchange::ExchangeId, + subscription::liquidation::Liquidation, + Identifier, +}; +use barter_integration::model::{Exchange, Side, SubscriptionId}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +/// [`BinanceFuturesUsd`](super::BinanceFuturesUsd) Liquidation order message. +/// +/// ### Raw Payload Examples +/// See docs: +/// ```json +/// { +/// "e": "forceOrder", +/// "E": 1665523974222, +/// "o": { +/// "s": "BTCUSDT", +/// "S": "SELL", +/// "o": "LIMIT", +/// "f": "IOC", +/// "q": "0.009", +/// "p": "18917.15", +/// "ap": "18990.00", +/// "X": "FILLED", +/// "l": "0.009", +/// "z": "0.009", +/// "T": 1665523974217 +/// } +/// } +/// ``` +#[derive(Clone, PartialEq, PartialOrd, Debug, Deserialize, Serialize)] +pub struct BinanceLiquidation { + #[serde(alias = "o")] + pub order: BinanceLiquidationOrder, +} + +/// [`BinanceFuturesUsd`](super::BinanceFuturesUsd) Liquidation order. +/// +/// ### Raw Payload Examples +/// ```json +/// { +/// "s": "BTCUSDT", +/// "S": "SELL", +/// "o": "LIMIT", +/// "f": "IOC", +/// "q": "0.009", +/// "p": "18917.15", +/// "ap": "18990.00", +/// "X": "FILLED", +/// "l": "0.009", +/// "z": "0.009", +/// "T": 1665523974217 +/// } +/// ``` +/// +/// See docs: +#[derive(Clone, PartialEq, PartialOrd, Debug, Deserialize, Serialize)] +pub struct BinanceLiquidationOrder { + #[serde(alias = "s", deserialize_with = "de_liquidation_subscription_id")] + pub subscription_id: SubscriptionId, + #[serde(alias = "S")] + pub side: Side, + #[serde(alias = "p", deserialize_with = "barter_integration::de::de_str")] + pub price: f64, + #[serde(alias = "q", deserialize_with = "barter_integration::de::de_str")] + pub quantity: f64, + #[serde( + alias = "T", + deserialize_with = "barter_integration::de::de_u64_epoch_ms_as_datetime_utc" + )] + pub time: DateTime, +} + +impl Identifier> for BinanceLiquidation { + fn id(&self) -> Option { + Some(self.order.subscription_id.clone()) + } +} + +impl From<(ExchangeId, InstrumentId, BinanceLiquidation)> + for MarketIter +{ + fn from( + (exchange_id, instrument, liquidation): (ExchangeId, InstrumentId, BinanceLiquidation), + ) -> Self { + Self(vec![Ok(MarketEvent { + exchange_time: liquidation.order.time, + received_time: Utc::now(), + exchange: Exchange::from(exchange_id), + instrument, + kind: Liquidation { + side: liquidation.order.side, + price: liquidation.order.price, + quantity: liquidation.order.quantity, + time: liquidation.order.time, + }, + })]) + } +} + +/// Deserialize a [`BinanceLiquidationOrder`] "s" (eg/ "BTCUSDT") as the associated +/// [`SubscriptionId`]. +/// +/// eg/ "forceOrder|BTCUSDT" +pub fn de_liquidation_subscription_id<'de, D>(deserializer: D) -> Result +where + D: serde::de::Deserializer<'de>, +{ + Deserialize::deserialize(deserializer).map(|market: String| { + SubscriptionId::from(format!("{}|{}", BinanceChannel::LIQUIDATIONS.0, market)) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + mod de { + use super::*; + use barter_integration::de::datetime_utc_from_epoch_duration; + use std::time::Duration; + + #[test] + fn test_binance_liquidation() { + let input = r#" + { + "e": "forceOrder", + "E": 1665523974222, + "o": { + "s": "BTCUSDT", + "S": "SELL", + "o": "LIMIT", + "f": "IOC", + "q": "0.009", + "p": "18917.15", + "ap": "18990.00", + "X": "FILLED", + "l": "0.009", + "z": "0.009", + "T": 1665523974217 + } + } + "#; + + assert_eq!( + serde_json::from_str::(input).unwrap(), + BinanceLiquidation { + order: BinanceLiquidationOrder { + subscription_id: SubscriptionId::from("@forceOrder|BTCUSDT"), + side: Side::Sell, + price: 18917.15, + quantity: 0.009, + time: datetime_utc_from_epoch_duration(Duration::from_millis( + 1665523974217, + )), + }, + } + ); + } + } +} diff --git a/barter-data/src/exchange/binance/futures/mod.rs b/barter-data/src/exchange/binance/futures/mod.rs new file mode 100644 index 00000000..2a385715 --- /dev/null +++ b/barter-data/src/exchange/binance/futures/mod.rs @@ -0,0 +1,52 @@ +use self::{l2::BinanceFuturesBookUpdater, liquidation::BinanceLiquidation}; +use super::{Binance, ExchangeServer}; +use crate::{ + exchange::{ExchangeId, StreamSelector}, + instrument::InstrumentData, + subscription::{book::OrderBooksL2, liquidation::Liquidations}, + transformer::{book::MultiBookTransformer, stateless::StatelessTransformer}, + ExchangeWsStream, +}; +use barter_integration::model::instrument::Instrument; + +/// Level 2 OrderBook types (top of book) and perpetual +/// [`OrderBookUpdater`](crate::transformer::book::OrderBookUpdater) implementation. +pub mod l2; + +/// Liquidation types. +pub mod liquidation; + +/// [`BinanceFuturesUsd`] WebSocket server base url. +/// +/// See docs: +pub const WEBSOCKET_BASE_URL_BINANCE_FUTURES_USD: &str = "wss://fstream.binance.com/ws"; + +/// [`Binance`] perpetual usd exchange. +pub type BinanceFuturesUsd = Binance; + +/// [`Binance`] perpetual usd [`ExchangeServer`]. +#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Default)] +pub struct BinanceServerFuturesUsd; + +impl ExchangeServer for BinanceServerFuturesUsd { + const ID: ExchangeId = ExchangeId::BinanceFuturesUsd; + + fn websocket_url() -> &'static str { + WEBSOCKET_BASE_URL_BINANCE_FUTURES_USD + } +} + +impl StreamSelector for BinanceFuturesUsd { + type Stream = ExchangeWsStream< + MultiBookTransformer, + >; +} + +impl StreamSelector for BinanceFuturesUsd +where + Instrument: InstrumentData, +{ + type Stream = ExchangeWsStream< + StatelessTransformer, + >; +} diff --git a/barter-data/src/exchange/binance/market.rs b/barter-data/src/exchange/binance/market.rs new file mode 100644 index 00000000..ab9bf816 --- /dev/null +++ b/barter-data/src/exchange/binance/market.rs @@ -0,0 +1,54 @@ +use super::Binance; +use crate::{ + instrument::{KeyedInstrument, MarketInstrumentData}, + subscription::Subscription, + Identifier, +}; +use barter_integration::model::instrument::{symbol::Symbol, Instrument}; +use serde::{Deserialize, Serialize}; + +/// Type that defines how to translate a Barter [`Subscription`] into a [`Binance`] +/// market that can be subscribed to. +/// +/// See docs: +/// See docs: +#[derive(Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Deserialize, Serialize)] +pub struct BinanceMarket(pub String); + +impl Identifier for Subscription, Instrument, Kind> { + fn id(&self) -> BinanceMarket { + binance_market(&self.instrument.base, &self.instrument.quote) + } +} + +impl Identifier + for Subscription, KeyedInstrument, Kind> +{ + fn id(&self) -> BinanceMarket { + binance_market( + &self.instrument.as_ref().base, + &self.instrument.as_ref().quote, + ) + } +} + +impl Identifier + for Subscription, MarketInstrumentData, Kind> +{ + fn id(&self) -> BinanceMarket { + BinanceMarket(self.instrument.name_exchange.clone()) + } +} + +impl AsRef for BinanceMarket { + fn as_ref(&self) -> &str { + &self.0 + } +} + +fn binance_market(base: &Symbol, quote: &Symbol) -> BinanceMarket { + // Notes: + // - Must be lowercase when subscribing (transformed to lowercase by Binance fn requests). + // - Must be uppercase since Binance sends message with uppercase MARKET (eg/ BTCUSDT). + BinanceMarket(format!("{base}{quote}").to_uppercase()) +} diff --git a/barter-data/src/exchange/binance/mod.rs b/barter-data/src/exchange/binance/mod.rs new file mode 100644 index 00000000..5164d6f0 --- /dev/null +++ b/barter-data/src/exchange/binance/mod.rs @@ -0,0 +1,153 @@ +use self::{ + book::l1::BinanceOrderBookL1, channel::BinanceChannel, market::BinanceMarket, + subscription::BinanceSubResponse, trade::BinanceTrade, +}; +use crate::{ + exchange::{Connector, ExchangeId, ExchangeServer, ExchangeSub, StreamSelector}, + instrument::InstrumentData, + subscriber::{validator::WebSocketSubValidator, WebSocketSubscriber}, + subscription::{book::OrderBooksL1, trade::PublicTrades, Map}, + transformer::stateless::StatelessTransformer, + ExchangeWsStream, +}; +use barter_integration::{error::SocketError, protocol::websocket::WsMessage}; +use std::{fmt::Debug, marker::PhantomData}; +use url::Url; + +/// OrderBook types common to both [`BinanceSpot`](spot::BinanceSpot) and +/// [`BinanceFuturesUsd`](futures::BinanceFuturesUsd). +pub mod book; + +/// Defines the type that translates a Barter [`Subscription`](crate::subscription::Subscription) +/// into an exchange [`Connector`] specific channel used for generating [`Connector::requests`]. +pub mod channel; + +/// [`ExchangeServer`] and [`StreamSelector`] implementations for +/// [`BinanceFuturesUsd`](futures::BinanceFuturesUsd). +pub mod futures; + +/// Defines the type that translates a Barter [`Subscription`](crate::subscription::Subscription) +/// into an exchange [`Connector`] specific market used for generating [`Connector::requests`]. +pub mod market; + +/// [`ExchangeServer`] and [`StreamSelector`] implementations for +/// [`BinanceSpot`](spot::BinanceSpot). +pub mod spot; + +/// [`Subscription`](crate::subscription::Subscription) response type and response +/// [`Validator`](barter_integration::Validator) common to both [`BinanceSpot`](spot::BinanceSpot) +/// and [`BinanceFuturesUsd`](futures::BinanceFuturesUsd). +pub mod subscription; + +/// Public trade types common to both [`BinanceSpot`](spot::BinanceSpot) and +/// [`BinanceFuturesUsd`](futures::BinanceFuturesUsd). +pub mod trade; + +/// Generic [`Binance`](Binance) exchange. +/// +/// ### Notes +/// A `Server` [`ExchangeServer`] implementations exists for +/// [`BinanceSpot`](spot::BinanceSpot) and [`BinanceFuturesUsd`](futures::BinanceFuturesUsd). +#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Default)] +pub struct Binance { + server: PhantomData, +} + +impl Connector for Binance +where + Server: ExchangeServer, +{ + const ID: ExchangeId = Server::ID; + type Channel = BinanceChannel; + type Market = BinanceMarket; + type Subscriber = WebSocketSubscriber; + type SubValidator = WebSocketSubValidator; + type SubResponse = BinanceSubResponse; + + fn url() -> Result { + Url::parse(Server::websocket_url()).map_err(SocketError::UrlParse) + } + + fn requests(exchange_subs: Vec>) -> Vec { + let stream_names = exchange_subs + .into_iter() + .map(|sub| { + // Note: + // Market must be lowercase when subscribing, but lowercase in general since + // Binance sends message with uppercase MARKET (eg/ BTCUSDT). + format!( + "{}{}", + sub.market.as_ref().to_lowercase(), + sub.channel.as_ref() + ) + }) + .collect::>(); + + vec![WsMessage::Text( + serde_json::json!({ + "method": "SUBSCRIBE", + "params": stream_names, + "id": 1 + }) + .to_string(), + )] + } + + fn expected_responses(_: &Map) -> usize { + 1 + } +} + +impl StreamSelector for Binance +where + Instrument: InstrumentData, + Server: ExchangeServer + Debug + Send + Sync, +{ + type Stream = + ExchangeWsStream>; +} + +impl StreamSelector for Binance +where + Instrument: InstrumentData, + Server: ExchangeServer + Debug + Send + Sync, +{ + type Stream = ExchangeWsStream< + StatelessTransformer, + >; +} + +impl<'de, Server> serde::Deserialize<'de> for Binance +where + Server: ExchangeServer, +{ + fn deserialize(deserializer: D) -> Result + where + D: serde::de::Deserializer<'de>, + { + let input = ::deserialize(deserializer)?; + let expected = Self::ID.as_str(); + + if input.as_str() == Self::ID.as_str() { + Ok(Self::default()) + } else { + Err(serde::de::Error::invalid_value( + serde::de::Unexpected::Str(input.as_str()), + &expected, + )) + } + } +} + +impl serde::Serialize for Binance +where + Server: ExchangeServer, +{ + fn serialize(&self, serializer: S) -> Result + where + S: serde::ser::Serializer, + { + let exchange_id = Self::ID.as_str(); + serializer.serialize_str(exchange_id) + } +} diff --git a/barter-data/src/exchange/binance/spot/l2.rs b/barter-data/src/exchange/binance/spot/l2.rs new file mode 100644 index 00000000..955046de --- /dev/null +++ b/barter-data/src/exchange/binance/spot/l2.rs @@ -0,0 +1,602 @@ +use super::super::book::{l2::BinanceOrderBookL2Snapshot, BinanceLevel}; +use crate::{ + error::DataError, + subscription::book::OrderBook, + transformer::book::{InstrumentOrderBook, OrderBookUpdater}, + Identifier, +}; +use async_trait::async_trait; +use barter_integration::{ + error::SocketError, + model::{instrument::Instrument, SubscriptionId}, + protocol::websocket::WsMessage, +}; +use chrono::Utc; +use serde::{Deserialize, Serialize}; +use tokio::sync::mpsc; + +/// [`BinanceSpot`](super::BinanceSpot) HTTP OrderBook L2 snapshot url. +/// +/// See docs: +pub const HTTP_BOOK_L2_SNAPSHOT_URL_BINANCE_SPOT: &str = "https://api.binance.com/api/v3/depth"; + +/// [`BinanceSpot`](super::BinanceSpot) OrderBook Level2 deltas WebSocket message. +/// +/// ### Raw Payload Examples +/// See docs: +/// ```json +/// { +/// "e":"depthUpdate", +/// "E":1671656397761, +/// "s":"ETHUSDT", +/// "U":22611425143, +/// "u":22611425151, +/// "b":[ +/// ["1209.67000000","85.48210000"], +/// ["1209.66000000","20.68790000"] +/// ], +/// "a":[] +/// } +/// ``` +#[derive(Clone, PartialEq, PartialOrd, Debug, Deserialize, Serialize)] +pub struct BinanceSpotOrderBookL2Delta { + #[serde( + alias = "s", + deserialize_with = "super::super::book::l2::de_ob_l2_subscription_id" + )] + pub subscription_id: SubscriptionId, + #[serde(alias = "U")] + pub first_update_id: u64, + #[serde(alias = "u")] + pub last_update_id: u64, + #[serde(alias = "b")] + pub bids: Vec, + #[serde(alias = "a")] + pub asks: Vec, +} + +impl Identifier> for BinanceSpotOrderBookL2Delta { + fn id(&self) -> Option { + Some(self.subscription_id.clone()) + } +} + +/// [`Binance`](super::super::Binance) [`BinanceServerSpot`](super::BinanceServerSpot) +/// [`OrderBookUpdater`]. +/// +/// BinanceSpot: How To Manage A Local OrderBook Correctly +/// +/// 1. Open a stream to wss://stream.binance.com:9443/ws/BTCUSDT@depth. +/// 2. Buffer the events you receive from the stream. +/// 3. Get a depth snapshot from . +/// 4. -- *DIFFERENT FROM FUTURES* -- +/// Drop any event where u is <= lastUpdateId in the snapshot. +/// 5. -- *DIFFERENT FROM FUTURES* -- +/// The first processed event should have U <= lastUpdateId+1 AND u >= lastUpdateId+1. +/// 6. -- *DIFFERENT FROM FUTURES* -- +/// While listening to the stream, each new event's U should be equal to the +/// previous event's u+1, otherwise initialize the process from step 3. +/// 7. The data in each event is the absolute quantity for a price level. +/// 8. If the quantity is 0, remove the price level. +/// +/// Notes: +/// - Receiving an event that removes a price level that is not in your local order book can happen and is normal. +/// - Uppercase U => first_update_id +/// - Lowercase u => last_update_id, +/// +/// See docs: +#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Deserialize, Serialize)] +pub struct BinanceSpotBookUpdater { + pub updates_processed: u64, + pub last_update_id: u64, + pub prev_last_update_id: u64, +} + +impl BinanceSpotBookUpdater { + /// Construct a new BinanceSpot [`OrderBookUpdater`] using the provided last_update_id from + /// a HTTP snapshot. + pub fn new(last_update_id: u64) -> Self { + Self { + updates_processed: 0, + prev_last_update_id: last_update_id, + last_update_id, + } + } + + /// BinanceSpot: How To Manage A Local OrderBook Correctly: Step 5: + /// "The first processed event should have U <= lastUpdateId+1 AND u >= lastUpdateId+1" + /// + /// See docs: + pub fn is_first_update(&self) -> bool { + self.updates_processed == 0 + } + + /// BinanceSpot: How To Manage A Local OrderBook Correctly: Step 5: + /// "The first processed event should have U <= lastUpdateId+1 AND u >= lastUpdateId+1" + /// + /// See docs: + pub fn validate_first_update( + &self, + update: &BinanceSpotOrderBookL2Delta, + ) -> Result<(), DataError> { + let expected_next_id = self.last_update_id + 1; + if update.first_update_id <= expected_next_id && update.last_update_id >= expected_next_id { + Ok(()) + } else { + Err(DataError::InvalidSequence { + prev_last_update_id: self.last_update_id, + first_update_id: update.first_update_id, + }) + } + } + + /// BinanceFuturesUsd: How To Manage A Local OrderBook Correctly: Step 6: + /// "While listening to the stream, each new event's U should be equal to the + /// previous event's u+1, otherwise initialize the process from step 3." + /// + /// See docs: + pub fn validate_next_update( + &self, + update: &BinanceSpotOrderBookL2Delta, + ) -> Result<(), DataError> { + let expected_next_id = self.last_update_id + 1; + if update.first_update_id == expected_next_id { + Ok(()) + } else { + Err(DataError::InvalidSequence { + prev_last_update_id: self.last_update_id, + first_update_id: update.first_update_id, + }) + } + } +} + +#[async_trait] +impl OrderBookUpdater for BinanceSpotBookUpdater { + type OrderBook = OrderBook; + type Update = BinanceSpotOrderBookL2Delta; + + async fn init( + _: mpsc::UnboundedSender, + instrument: Instrument, + ) -> Result, DataError> + where + Exchange: Send, + Kind: Send, + { + // Construct initial OrderBook snapshot GET url + let snapshot_url = format!( + "{}?symbol={}{}&limit=100", + HTTP_BOOK_L2_SNAPSHOT_URL_BINANCE_SPOT, + instrument.base.as_ref().to_uppercase(), + instrument.quote.as_ref().to_uppercase() + ); + + // Fetch initial OrderBook snapshot via HTTP + let snapshot = reqwest::get(snapshot_url) + .await + .map_err(SocketError::Http)? + .json::() + .await + .map_err(SocketError::Http)?; + + Ok(InstrumentOrderBook { + instrument, + updater: Self::new(snapshot.last_update_id), + book: OrderBook::from(snapshot), + }) + } + + fn update( + &mut self, + book: &mut Self::OrderBook, + update: Self::Update, + ) -> Result, DataError> { + // BinanceSpot: How To Manage A Local OrderBook Correctly + // See Self's Rust Docs for more information on each numbered step + // See docs: + + // 4. Drop any event where u is <= lastUpdateId in the snapshot: + if update.last_update_id <= self.last_update_id { + return Ok(None); + } + + if self.is_first_update() { + // 5. The first processed event should have U <= lastUpdateId AND u >= lastUpdateId: + self.validate_first_update(&update)?; + } else { + // 6. Each new event's pu should be equal to the previous event's u: + self.validate_next_update(&update)?; + } + + // Update OrderBook metadata & Levels: + // 7. The data in each event is the absolute quantity for a price level. + // 8. If the quantity is 0, remove the price level. + book.last_update_time = Utc::now(); + book.bids.upsert(update.bids); + book.asks.upsert(update.asks); + + // Update OrderBookUpdater metadata + self.updates_processed += 1; + self.prev_last_update_id = self.last_update_id; + self.last_update_id = update.last_update_id; + + Ok(Some(book.snapshot())) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + mod de { + use super::*; + + #[test] + fn test_binance_spot_order_book_l2_delta() { + let input = r#" + { + "e":"depthUpdate", + "E":1671656397761, + "s":"ETHUSDT", + "U":22611425143, + "u":22611425151, + "b":[ + ["1209.67000000","85.48210000"], + ["1209.66000000","20.68790000"] + ], + "a":[] + } + "#; + + assert_eq!( + serde_json::from_str::(input).unwrap(), + BinanceSpotOrderBookL2Delta { + subscription_id: SubscriptionId::from("@depth@100ms|ETHUSDT"), + first_update_id: 22611425143, + last_update_id: 22611425151, + bids: vec![ + BinanceLevel { + price: 1209.67000000, + amount: 85.48210000 + }, + BinanceLevel { + price: 1209.66000000, + amount: 20.68790000 + }, + ], + asks: vec![] + } + ); + } + } + + mod binance_spot_book_updater { + use super::*; + use crate::subscription::book::{Level, OrderBookSide}; + use barter_integration::model::Side; + + #[test] + fn test_is_first_update() { + struct TestCase { + input: BinanceSpotBookUpdater, + expected: bool, + } + + let tests = vec![ + TestCase { + // TC0: is first update + input: BinanceSpotBookUpdater::new(10), + expected: true, + }, + TestCase { + // TC1: is not first update + input: BinanceSpotBookUpdater { + updates_processed: 10, + last_update_id: 100, + prev_last_update_id: 90, + }, + expected: false, + }, + ]; + + for (index, test) in tests.into_iter().enumerate() { + assert_eq!( + test.input.is_first_update(), + test.expected, + "TC{} failed", + index + ); + } + } + + #[test] + fn test_validate_first_update() { + struct TestCase { + updater: BinanceSpotBookUpdater, + input: BinanceSpotOrderBookL2Delta, + expected: Result<(), DataError>, + } + + let tests = vec![ + TestCase { + // TC0: valid first update + updater: BinanceSpotBookUpdater { + updates_processed: 0, + last_update_id: 100, + prev_last_update_id: 90, + }, + input: BinanceSpotOrderBookL2Delta { + subscription_id: SubscriptionId::from("subscription_id"), + first_update_id: 100, + last_update_id: 110, + bids: vec![], + asks: vec![], + }, + expected: Ok(()), + }, + TestCase { + // TC1: invalid first update w/ U > lastUpdateId+1 + updater: BinanceSpotBookUpdater { + updates_processed: 0, + last_update_id: 100, + prev_last_update_id: 90, + }, + input: BinanceSpotOrderBookL2Delta { + subscription_id: SubscriptionId::from("subscription_id"), + first_update_id: 102, + last_update_id: 90, + bids: vec![], + asks: vec![], + }, + expected: Err(DataError::InvalidSequence { + prev_last_update_id: 100, + first_update_id: 102, + }), + }, + TestCase { + // TC2: invalid first update w/ u < lastUpdateId+1 + updater: BinanceSpotBookUpdater { + updates_processed: 0, + last_update_id: 100, + prev_last_update_id: 90, + }, + input: BinanceSpotOrderBookL2Delta { + subscription_id: SubscriptionId::from("subscription_id"), + first_update_id: 110, + last_update_id: 90, + bids: vec![], + asks: vec![], + }, + expected: Err(DataError::InvalidSequence { + prev_last_update_id: 100, + first_update_id: 110, + }), + }, + TestCase { + // TC3: invalid first update w/ U > lastUpdateId+1 & u < lastUpdateId+1 + updater: BinanceSpotBookUpdater { + updates_processed: 0, + last_update_id: 100, + prev_last_update_id: 90, + }, + input: BinanceSpotOrderBookL2Delta { + subscription_id: SubscriptionId::from("subscription_id"), + first_update_id: 110, + last_update_id: 90, + bids: vec![], + asks: vec![], + }, + expected: Err(DataError::InvalidSequence { + prev_last_update_id: 100, + first_update_id: 110, + }), + }, + ]; + + for (index, test) in tests.into_iter().enumerate() { + let actual = test.updater.validate_first_update(&test.input); + match (actual, test.expected) { + (Ok(actual), Ok(expected)) => { + assert_eq!(actual, expected, "TC{} failed", index) + } + (Err(_), Err(_)) => { + // Test passed + } + (actual, expected) => { + // Test failed + panic!("TC{index} failed because actual != expected. \nActual: {actual:?}\nExpected: {expected:?}\n"); + } + } + } + } + + #[test] + fn test_validate_next_update() { + struct TestCase { + updater: BinanceSpotBookUpdater, + input: BinanceSpotOrderBookL2Delta, + expected: Result<(), DataError>, + } + + let tests = vec![ + TestCase { + // TC0: valid next update + updater: BinanceSpotBookUpdater { + updates_processed: 100, + last_update_id: 100, + prev_last_update_id: 100, + }, + input: BinanceSpotOrderBookL2Delta { + subscription_id: SubscriptionId::from("subscription_id"), + first_update_id: 101, + last_update_id: 110, + bids: vec![], + asks: vec![], + }, + expected: Ok(()), + }, + TestCase { + // TC1: invalid first update w/ U != prev_last_update_id+1 + updater: BinanceSpotBookUpdater { + updates_processed: 100, + last_update_id: 100, + prev_last_update_id: 90, + }, + input: BinanceSpotOrderBookL2Delta { + subscription_id: SubscriptionId::from("subscription_id"), + first_update_id: 120, + last_update_id: 130, + bids: vec![], + asks: vec![], + }, + expected: Err(DataError::InvalidSequence { + prev_last_update_id: 100, + first_update_id: 120, + }), + }, + ]; + + for (index, test) in tests.into_iter().enumerate() { + let actual = test.updater.validate_next_update(&test.input); + match (actual, test.expected) { + (Ok(actual), Ok(expected)) => { + assert_eq!(actual, expected, "TC{} failed", index) + } + (Err(_), Err(_)) => { + // Test passed + } + (actual, expected) => { + // Test failed + panic!("TC{index} failed because actual != expected. \nActual: {actual:?}\nExpected: {expected:?}\n"); + } + } + } + } + + #[test] + fn update() { + struct TestCase { + updater: BinanceSpotBookUpdater, + book: OrderBook, + input_update: BinanceSpotOrderBookL2Delta, + expected: Result, DataError>, + } + + let time = Utc::now(); + + let tests = vec![ + TestCase { + // TC0: Drop any event where u is <= lastUpdateId in the snapshot + updater: BinanceSpotBookUpdater { + updates_processed: 100, + last_update_id: 100, + prev_last_update_id: 0, + }, + book: OrderBook { + last_update_time: time, + bids: OrderBookSide::new(Side::Buy, vec![Level::new(50, 1)]), + asks: OrderBookSide::new(Side::Sell, vec![Level::new(100, 1)]), + }, + input_update: BinanceSpotOrderBookL2Delta { + subscription_id: SubscriptionId::from("subscription_id"), + first_update_id: 0, + last_update_id: 100, // u == updater.lastUpdateId + bids: vec![], + asks: vec![], + }, + expected: Ok(None), + }, + TestCase { + // TC1: valid update with sorted snapshot generated + updater: BinanceSpotBookUpdater { + updates_processed: 100, + last_update_id: 100, + prev_last_update_id: 100, + }, + book: OrderBook { + last_update_time: time, + bids: OrderBookSide::new( + Side::Buy, + vec![Level::new(80, 1), Level::new(100, 1), Level::new(90, 1)], + ), + asks: OrderBookSide::new( + Side::Sell, + vec![Level::new(150, 1), Level::new(110, 1), Level::new(120, 1)], + ), + }, + input_update: BinanceSpotOrderBookL2Delta { + subscription_id: SubscriptionId::from("subscription_id"), + first_update_id: 101, + last_update_id: 110, + bids: vec![ + // Level exists & new value is 0 => remove Level + BinanceLevel { + price: 80.0, + amount: 0.0, + }, + // Level exists & new value is > 0 => replace Level + BinanceLevel { + price: 90.0, + amount: 10.0, + }, + ], + asks: vec![ + // Level does not exist & new value > 0 => insert new Level + BinanceLevel { + price: 200.0, + amount: 1.0, + }, + // Level does not exist & new value is 0 => no change + BinanceLevel { + price: 500.0, + amount: 0.0, + }, + ], + }, + expected: Ok(Some(OrderBook { + last_update_time: time, + bids: OrderBookSide::new( + Side::Buy, + vec![Level::new(100, 1), Level::new(90, 10)], + ), + asks: OrderBookSide::new( + Side::Sell, + vec![ + Level::new(110, 1), + Level::new(120, 1), + Level::new(150, 1), + Level::new(200, 1), + ], + ), + })), + }, + ]; + + for (index, mut test) in tests.into_iter().enumerate() { + let actual = test.updater.update(&mut test.book, test.input_update); + + match (actual, test.expected) { + (Ok(Some(actual)), Ok(Some(expected))) => { + // Replace time with deterministic timestamp + let actual = OrderBook { + last_update_time: time, + ..actual + }; + assert_eq!(actual, expected, "TC{} failed", index) + } + (Ok(None), Ok(None)) => { + // Test passed + } + (Err(_), Err(_)) => { + // Test passed + } + (actual, expected) => { + // Test failed + panic!("TC{index} failed because actual != expected. \nActual: {actual:?}\nExpected: {expected:?}\n"); + } + } + } + } + } +} diff --git a/barter-data/src/exchange/binance/spot/mod.rs b/barter-data/src/exchange/binance/spot/mod.rs new file mode 100644 index 00000000..636292d7 --- /dev/null +++ b/barter-data/src/exchange/binance/spot/mod.rs @@ -0,0 +1,39 @@ +use self::l2::BinanceSpotBookUpdater; +use super::{Binance, ExchangeServer}; +use crate::{ + exchange::{ExchangeId, StreamSelector}, + subscription::book::OrderBooksL2, + transformer::book::MultiBookTransformer, + ExchangeWsStream, +}; +use barter_integration::model::instrument::Instrument; + +/// Level 2 OrderBook types (top of book) and spot +/// [`OrderBookUpdater`](crate::transformer::book::OrderBookUpdater) implementation. +pub mod l2; + +/// [`BinanceSpot`] WebSocket server base url. +/// +/// See docs: +pub const WEBSOCKET_BASE_URL_BINANCE_SPOT: &str = "wss://stream.binance.com:9443/ws"; + +/// [`Binance`] spot exchange. +pub type BinanceSpot = Binance; + +/// [`Binance`] spot [`ExchangeServer`]. +#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Default)] +pub struct BinanceServerSpot; + +impl ExchangeServer for BinanceServerSpot { + const ID: ExchangeId = ExchangeId::BinanceSpot; + + fn websocket_url() -> &'static str { + WEBSOCKET_BASE_URL_BINANCE_SPOT + } +} + +impl StreamSelector for BinanceSpot { + type Stream = ExchangeWsStream< + MultiBookTransformer, + >; +} diff --git a/barter-data/src/exchange/binance/subscription.rs b/barter-data/src/exchange/binance/subscription.rs new file mode 100644 index 00000000..04c4079e --- /dev/null +++ b/barter-data/src/exchange/binance/subscription.rs @@ -0,0 +1,126 @@ +use barter_integration::{error::SocketError, Validator}; +use serde::{Deserialize, Serialize}; + +/// [`Binance`](super::Binance) subscription response message. +/// +/// ### Raw Payload Examples +/// See docs: +/// #### Subscription Success +/// ```json +/// { +/// "id":1, +/// "result":null +/// } +/// ``` +/// +/// #### Subscription Failure +/// ```json +/// { +/// "id":1, +/// "result":[] +/// } +/// ``` +#[derive(Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Deserialize, Serialize)] +pub struct BinanceSubResponse { + result: Option>, + id: u32, +} + +impl Validator for BinanceSubResponse { + fn validate(self) -> Result + where + Self: Sized, + { + if self.result.is_none() { + Ok(self) + } else { + Err(SocketError::Subscribe( + "received failure subscription response".to_owned(), + )) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + mod de { + use super::*; + + #[test] + fn test_binance_sub_response() { + struct TestCase { + input: &'static str, + expected: Result, + } + + let cases = vec![ + TestCase { + // TC0: input response is Subscribed + input: r#"{"id":1,"result":null}"#, + expected: Ok(BinanceSubResponse { + result: None, + id: 1, + }), + }, + TestCase { + // TC1: input response is failed subscription + input: r#"{"result": [], "id": 1}"#, + expected: Ok(BinanceSubResponse { + result: Some(vec![]), + id: 1, + }), + }, + ]; + + for (index, test) in cases.into_iter().enumerate() { + let actual = serde_json::from_str::(test.input); + match (actual, test.expected) { + (Ok(actual), Ok(expected)) => { + assert_eq!(actual, expected, "TC{} failed", index) + } + (Err(_), Err(_)) => { + // Test passed + } + (actual, expected) => { + // Test failed + panic!("TC{index} failed because actual != expected. \nActual: {actual:?}\nExpected: {expected:?}\n"); + } + } + } + } + } + + #[test] + fn test_validate_binance_sub_response() { + struct TestCase { + input_response: BinanceSubResponse, + is_valid: bool, + } + + let cases = vec![ + TestCase { + // TC0: input response is successful subscription + input_response: BinanceSubResponse { + result: None, + id: 1, + }, + is_valid: true, + }, + TestCase { + // TC1: input response is failed subscription + input_response: BinanceSubResponse { + result: Some(vec![]), + id: 1, + }, + is_valid: false, + }, + ]; + + for (index, test) in cases.into_iter().enumerate() { + let actual = test.input_response.validate().is_ok(); + assert_eq!(actual, test.is_valid, "TestCase {} failed", index); + } + } +} diff --git a/barter-data/src/exchange/binance/trade.rs b/barter-data/src/exchange/binance/trade.rs new file mode 100644 index 00000000..41920195 --- /dev/null +++ b/barter-data/src/exchange/binance/trade.rs @@ -0,0 +1,253 @@ +use barter_integration::model::{Exchange, Side, SubscriptionId}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +use crate::{ + event::{MarketEvent, MarketIter}, + exchange::{ExchangeId, ExchangeSub}, + subscription::trade::PublicTrade, + Identifier, +}; + +use super::BinanceChannel; + +/// Binance real-time trade message. +/// +/// Note: +/// For [`BinanceFuturesUsd`](super::futures::BinanceFuturesUsd) this real-time stream is +/// undocumented. +/// +/// See discord: +/// +/// ### Raw Payload Examples +/// See docs: +/// #### Spot Side::Buy Trade +/// ```json +/// { +/// "e":"trade", +/// "E":1649324825173, +/// "s":"ETHUSDT", +/// "t":1000000000, +/// "p":"10000.19", +/// "q":"0.239000", +/// "b":10108767791, +/// "a":10108764858, +/// "T":1749354825200, +/// "m":false, +/// "M":true +/// } +/// ``` +/// +/// #### FuturePerpetual Side::Sell Trade +/// ```json +/// { +/// "e": "trade", +/// "E": 1649839266194, +/// "T": 1749354825200, +/// "s": "ETHUSDT", +/// "t": 1000000000, +/// "p":"10000.19", +/// "q":"0.239000", +/// "X": "MARKET", +/// "m": true +/// } +/// ``` +#[derive(Clone, PartialEq, PartialOrd, Debug, Deserialize, Serialize)] +pub struct BinanceTrade { + #[serde(alias = "s", deserialize_with = "de_trade_subscription_id")] + pub subscription_id: SubscriptionId, + #[serde( + alias = "T", + deserialize_with = "barter_integration::de::de_u64_epoch_ms_as_datetime_utc" + )] + pub time: DateTime, + #[serde(alias = "t")] + pub id: u64, + #[serde(alias = "p", deserialize_with = "barter_integration::de::de_str")] + pub price: f64, + #[serde(alias = "q", deserialize_with = "barter_integration::de::de_str")] + pub amount: f64, + #[serde(alias = "m", deserialize_with = "de_side_from_buyer_is_maker")] + pub side: Side, +} + +impl Identifier> for BinanceTrade { + fn id(&self) -> Option { + Some(self.subscription_id.clone()) + } +} + +impl From<(ExchangeId, InstrumentId, BinanceTrade)> + for MarketIter +{ + fn from((exchange_id, instrument, trade): (ExchangeId, InstrumentId, BinanceTrade)) -> Self { + Self(vec![Ok(MarketEvent { + exchange_time: trade.time, + received_time: Utc::now(), + exchange: Exchange::from(exchange_id), + instrument, + kind: PublicTrade { + id: trade.id.to_string(), + price: trade.price, + amount: trade.amount, + side: trade.side, + }, + })]) + } +} + +/// Deserialize a [`BinanceTrade`] "s" (eg/ "BTCUSDT") as the associated [`SubscriptionId`] +/// (eg/ "@trade|BTCUSDT"). +pub fn de_trade_subscription_id<'de, D>(deserializer: D) -> Result +where + D: serde::de::Deserializer<'de>, +{ + <&str as Deserialize>::deserialize(deserializer) + .map(|market| ExchangeSub::from((BinanceChannel::TRADES, market)).id()) +} + +/// Deserialize a [`BinanceTrade`] "buyer_is_maker" boolean field to a Barter [`Side`]. +/// +/// Variants: +/// buyer_is_maker => Side::Sell +/// !buyer_is_maker => Side::Buy +pub fn de_side_from_buyer_is_maker<'de, D>(deserializer: D) -> Result +where + D: serde::de::Deserializer<'de>, +{ + Deserialize::deserialize(deserializer).map(|buyer_is_maker| { + if buyer_is_maker { + Side::Sell + } else { + Side::Buy + } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + mod de { + use std::time::Duration; + + use barter_integration::{de::datetime_utc_from_epoch_duration, error::SocketError}; + use serde::de::Error; + + use super::*; + + #[test] + fn test_binance_trade() { + struct TestCase { + input: &'static str, + expected: Result, + } + + let tests = vec![ + TestCase { + // TC0: Spot trade valid + input: r#" + { + "e":"trade","E":1649324825173,"s":"ETHUSDT","t":1000000000, + "p":"10000.19","q":"0.239000","b":10108767791,"a":10108764858, + "T":1749354825200,"m":false,"M":true + } + "#, + expected: Ok(BinanceTrade { + subscription_id: SubscriptionId::from("@trade|ETHUSDT"), + time: datetime_utc_from_epoch_duration(Duration::from_millis( + 1749354825200, + )), + id: 1000000000, + price: 10000.19, + amount: 0.239000, + side: Side::Buy, + }), + }, + TestCase { + // TC1: Spot trade malformed w/ "yes" is_buyer_maker field + input: r#"{ + "e":"trade","E":1649324825173,"s":"ETHUSDT","t":1000000000, + "p":"10000.19000000","q":"0.239000","b":10108767791,"a":10108764858, + "T":1649324825173,"m":"yes","M":true + }"#, + expected: Err(SocketError::Deserialise { + error: serde_json::Error::custom(""), + payload: "".to_owned(), + }), + }, + TestCase { + // TC2: FuturePerpetual trade w/ type MARKET + input: r#" + { + "e": "trade","E": 1649839266194,"T": 1749354825200,"s": "ETHUSDT", + "t": 1000000000,"p":"10000.19","q":"0.239000","X": "MARKET","m": true + } + "#, + expected: Ok(BinanceTrade { + subscription_id: SubscriptionId::from("@trade|ETHUSDT"), + time: datetime_utc_from_epoch_duration(Duration::from_millis( + 1749354825200, + )), + id: 1000000000, + price: 10000.19, + amount: 0.239000, + side: Side::Sell, + }), + }, + TestCase { + // TC3: FuturePerpetual trade w/ type LIQUIDATION + input: r#" + { + "e": "trade","E": 1649839266194,"T": 1749354825200,"s": "ETHUSDT", + "t": 1000000000,"p":"10000.19","q":"0.239000","X": "LIQUIDATION","m": false + } + "#, + expected: Ok(BinanceTrade { + subscription_id: SubscriptionId::from("@trade|ETHUSDT"), + time: datetime_utc_from_epoch_duration(Duration::from_millis( + 1749354825200, + )), + id: 1000000000, + price: 10000.19, + amount: 0.239000, + side: Side::Buy, + }), + }, + TestCase { + // TC4: FuturePerpetual trade w/ type LIQUIDATION + input: r#"{ + "e": "trade","E": 1649839266194,"T": 1749354825200,"s": "ETHUSDT", + "t": 1000000000,"p":"10000.19","q":"0.239000","X": "INSURANCE_FUND","m": false + }"#, + expected: Ok(BinanceTrade { + subscription_id: SubscriptionId::from("@trade|ETHUSDT"), + time: datetime_utc_from_epoch_duration(Duration::from_millis( + 1749354825200, + )), + id: 1000000000, + price: 10000.19, + amount: 0.239000, + side: Side::Buy, + }), + }, + ]; + + for (index, test) in tests.into_iter().enumerate() { + let actual = serde_json::from_str::(test.input); + match (actual, test.expected) { + (Ok(actual), Ok(expected)) => { + assert_eq!(actual, expected, "TC{} failed", index) + } + (Err(_), Err(_)) => { + // Test passed + } + (actual, expected) => { + // Test failed + panic!("TC{index} failed because actual != expected. \nActual: {actual:?}\nExpected: {expected:?}\n"); + } + } + } + } + } +} diff --git a/barter-data/src/exchange/bitfinex/channel.rs b/barter-data/src/exchange/bitfinex/channel.rs new file mode 100644 index 00000000..a027c124 --- /dev/null +++ b/barter-data/src/exchange/bitfinex/channel.rs @@ -0,0 +1,32 @@ +use super::Bitfinex; +use crate::{ + subscription::{trade::PublicTrades, Subscription}, + Identifier, +}; +use serde::Serialize; + +/// Type that defines how to translate a Barter [`Subscription`] into a +/// [`Bitfinex`](super::Bitfinex) channel to be subscribed to. +/// +/// See docs: +#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Serialize)] +pub struct BitfinexChannel(pub &'static str); + +impl BitfinexChannel { + /// [`Bitfinex`] real-time trades channel. + /// + /// See docs: + pub const TRADES: Self = Self("trades"); +} + +impl Identifier for Subscription { + fn id(&self) -> BitfinexChannel { + BitfinexChannel::TRADES + } +} + +impl AsRef for BitfinexChannel { + fn as_ref(&self) -> &str { + self.0 + } +} diff --git a/barter-data/src/exchange/bitfinex/market.rs b/barter-data/src/exchange/bitfinex/market.rs new file mode 100644 index 00000000..6431356c --- /dev/null +++ b/barter-data/src/exchange/bitfinex/market.rs @@ -0,0 +1,47 @@ +use super::Bitfinex; +use crate::{ + instrument::{KeyedInstrument, MarketInstrumentData}, + subscription::Subscription, + Identifier, +}; +use barter_integration::model::instrument::{symbol::Symbol, Instrument}; +use serde::{Deserialize, Serialize}; + +/// Type that defines how to translate a Barter [`Subscription`] into a +/// [`Bitfinex`] market that can be subscribed to. +/// +/// See docs: +#[derive(Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Deserialize, Serialize)] +pub struct BitfinexMarket(pub String); + +impl Identifier for Subscription { + fn id(&self) -> BitfinexMarket { + bitfinex_market(&self.instrument.base, &self.instrument.quote) + } +} + +impl Identifier for Subscription { + fn id(&self) -> BitfinexMarket { + bitfinex_market(&self.instrument.data.base, &self.instrument.data.quote) + } +} + +impl Identifier for Subscription { + fn id(&self) -> BitfinexMarket { + BitfinexMarket(self.instrument.name_exchange.clone()) + } +} + +impl AsRef for BitfinexMarket { + fn as_ref(&self) -> &str { + &self.0 + } +} + +fn bitfinex_market(base: &Symbol, quote: &Symbol) -> BitfinexMarket { + BitfinexMarket(format!( + "t{}{}", + base.to_string().to_uppercase(), + quote.to_string().to_uppercase() + )) +} diff --git a/barter-data/src/exchange/bitfinex/message.rs b/barter-data/src/exchange/bitfinex/message.rs new file mode 100644 index 00000000..98f33688 --- /dev/null +++ b/barter-data/src/exchange/bitfinex/message.rs @@ -0,0 +1,219 @@ +use super::trade::BitfinexTrade; +use crate::{ + event::MarketIter, exchange::ExchangeId, subscription::trade::PublicTrade, Identifier, +}; +use barter_integration::{de::extract_next, model::SubscriptionId}; +use serde::Serialize; + +/// [`Bitfinex`](super::Bitfinex) message received over +/// [`WebSocket`](barter_integration::protocol::websocket::WebSocket) relating to an active +/// [`Subscription`](crate::Subscription). +/// +/// The message is associated with the original [`Subscription`](crate::Subscription) using the +/// `channel_id` field as the [`SubscriptionId`]. +/// +/// ### Raw Payload Examples +/// #### Heartbeat +/// See docs: +/// ```json +/// [420191,"hb"] +/// ``` +/// +/// #### Side::Buy Trade +/// See docs: +/// ```json +/// [420191,"te",[1225484398,1665452200022,0.08980641,19027.02807752]] +/// ``` +/// +/// #### Side::Sell Trade +/// See docs: +/// ```json +/// [420191,"te",[1225484398,1665452200022,-0.08980641,19027.02807752]] +/// ``` +#[derive(Clone, Copy, PartialEq, PartialOrd, Debug, Serialize)] +pub struct BitfinexMessage { + pub channel_id: u32, + pub payload: BitfinexPayload, +} + +/// [`Bitfinex`](super::Bitfinex) market data variants associated with an +/// active [`Subscription`](crate::Subscription). +/// +/// See [`BitfinexMessage`] for full raw payload examples. +/// +/// See docs: +#[derive(Clone, Copy, PartialEq, PartialOrd, Debug, Serialize)] +pub enum BitfinexPayload { + Heartbeat, + Trade(BitfinexTrade), +} + +impl Identifier> for BitfinexMessage { + fn id(&self) -> Option { + match self.payload { + BitfinexPayload::Heartbeat => None, + BitfinexPayload::Trade(_) => Some(SubscriptionId::from(self.channel_id.to_string())), + } + } +} + +impl From<(ExchangeId, InstrumentId, BitfinexMessage)> + for MarketIter +{ + fn from( + (exchange_id, instrument, message): (ExchangeId, InstrumentId, BitfinexMessage), + ) -> Self { + match message.payload { + BitfinexPayload::Heartbeat => Self(vec![]), + BitfinexPayload::Trade(trade) => Self::from((exchange_id, instrument, trade)), + } + } +} + +impl<'de> serde::Deserialize<'de> for BitfinexMessage { + fn deserialize(deserializer: D) -> Result + where + D: serde::de::Deserializer<'de>, + { + struct SeqVisitor; + + impl<'de> serde::de::Visitor<'de> for SeqVisitor { + type Value = BitfinexMessage; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("BitfinexMessage struct from the Bitfinex WebSocket API") + } + + fn visit_seq( + self, + mut seq: SeqAccessor, + ) -> Result + where + SeqAccessor: serde::de::SeqAccess<'de>, + { + // Trade: [CHANNEL_ID, <"te", "tu">, [ID, TIME, AMOUNT, PRICE]] + // Heartbeat: [ CHANNEL_ID, "hb" ] + // Candle: [CHANNEL_ID, [MTS, OPEN, CLOSE, HIGH, LOW, VOLUME]] + + // Extract CHANNEL_ID used to identify SubscriptionId: 1st element of the sequence + let channel_id: u32 = extract_next(&mut seq, "channel_id")?; + + // Extract message tag to identify payload type: 2nd element of the sequence + let message_tag: String = extract_next(&mut seq, "message_tag")?; + + // Use message tag to extract the payload: 3rd element of sequence + let payload = match message_tag.as_str() { + // Filter "tu" Trades since they are identical but slower + // '--> use as additional Heartbeat + "hb" | "tu" => BitfinexPayload::Heartbeat, + "te" => BitfinexPayload::Trade(extract_next(&mut seq, "BitfinexTrade")?), + other => { + return Err(serde::de::Error::unknown_variant( + other, + &["heartbeat (hb)", "trade (te | tu)"], + )) + } + }; + + // Ignore any additional elements or SerDe will fail + // '--> Bitfinex may add fields without warning + while seq.next_element::()?.is_some() {} + Ok(BitfinexMessage { + channel_id, + payload, + }) + } + } + + // Use Visitor implementation to deserialise the WebSocket BitfinexMessage + deserializer.deserialize_seq(SeqVisitor) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use barter_integration::{ + de::datetime_utc_from_epoch_duration, error::SocketError, model::Side, + }; + use std::time::Duration; + + #[test] + fn test_de_bitfinex_message() { + struct TestCase { + input: &'static str, + expected: Result, + } + + // Trade: [CHANNEL_ID, <"te", "tu">, [ID, TIME, AMOUNT, PRICE]] + // Heartbeat: [ CHANNEL_ID, "hb" ] + // Candle: [CHANNEL_ID, [MTS, OPEN, CLOSE, HIGH, LOW, VOLUME]] + + let cases = vec![ + // TC0: Trade message te Sell + TestCase { + input: r#"[420191,"te",[1225484398,1665452200022,-0.08980641,19027.02807752]]"#, + expected: Ok(BitfinexMessage { + channel_id: 420191, + payload: BitfinexPayload::Trade(BitfinexTrade { + id: 1225484398, + time: datetime_utc_from_epoch_duration(Duration::from_millis( + 1665452200022, + )), + side: Side::Sell, + price: 19027.02807752, + amount: 0.08980641, + }), + }), + }, + // TC1: Trade message te Buy + TestCase { + input: r#"[420191,"te",[1225484398,1665452200022,0.08980641,19027.02807752]]"#, + expected: Ok(BitfinexMessage { + channel_id: 420191, + payload: BitfinexPayload::Trade(BitfinexTrade { + id: 1225484398, + time: datetime_utc_from_epoch_duration(Duration::from_millis( + 1665452200022, + )), + side: Side::Buy, + price: 19027.02807752, + amount: 0.08980641, + }), + }), + }, + // TC2: Trade tu --> Should be marked as a heartbeat + TestCase { + input: r#"[420191,"tu",[1225484398,1665452200022,-0.08980641,19027.02807752]]"#, + expected: Ok(BitfinexMessage { + channel_id: 420191, + payload: BitfinexPayload::Heartbeat, + }), + }, + // TC3: Heartbeat message + TestCase { + input: r#"[420191,"hb"]"#, + expected: Ok(BitfinexMessage { + channel_id: 420191, + payload: BitfinexPayload::Heartbeat, + }), + }, + ]; + + for (index, test) in cases.into_iter().enumerate() { + let actual = serde_json::from_str::(test.input); + match (actual, test.expected) { + (Ok(actual), Ok(expected)) => { + assert_eq!(actual, expected, "TC{} failed", index) + } + (Err(_), Err(_)) => { + // Test passed + } + (actual, expected) => { + // Test failed + panic!("TC{index} failed because actual != expected. \nActual: {actual:?}\nExpected: {expected:?}\n"); + } + } + } + } +} diff --git a/barter-data/src/exchange/bitfinex/mod.rs b/barter-data/src/exchange/bitfinex/mod.rs new file mode 100644 index 00000000..bcb465b2 --- /dev/null +++ b/barter-data/src/exchange/bitfinex/mod.rs @@ -0,0 +1,109 @@ +//! +//! ### Notes +//! #### SubscripionId +//! - Successful Bitfinex subscription responses contain a numeric `CHANNEL_ID` that must be used to +//! identify future messages relating to that subscription (not persistent across connections). +//! - To identify the initial subscription response containing the `CHANNEL_ID`, the "channel" & +//! "market" identifiers can be used for the `SubscriptionId(channel|market)` +//! (eg/ SubscriptionId("trades|tBTCUSD")). +//! - Once the subscription has been validated and the `CHANNEL_ID` determined, each `SubscriptionId` +//! in the `SubscriptionIds` `HashMap` is mutated to become `SubscriptionId(CHANNEL_ID)`. +//! eg/ SubscriptionId("trades|tBTCUSD") -> SubscriptionId(69) +//! +//! #### Connection Limits +//! - The user is allowed up to 20 connections per minute on the public API. +//! - Each connection can be used to connect up to 25 different channels. +//! +//! #### Trade Variants +//! - Bitfinex trades subscriptions results in receiving tag="te" & tag="tu" trades. +//! - Both appear to be identical payloads, but "te" arriving marginally faster. +//! - Therefore, tag="tu" trades are filtered out and considered only as additional Heartbeats. + +use self::{ + channel::BitfinexChannel, market::BitfinexMarket, message::BitfinexMessage, + subscription::BitfinexPlatformEvent, validator::BitfinexWebSocketSubValidator, +}; +use crate::{ + exchange::{Connector, ExchangeId, ExchangeSub, StreamSelector}, + instrument::InstrumentData, + subscriber::WebSocketSubscriber, + subscription::trade::PublicTrades, + transformer::stateless::StatelessTransformer, + ExchangeWsStream, +}; +use barter_integration::{error::SocketError, protocol::websocket::WsMessage}; +use barter_macro::{DeExchange, SerExchange}; +use serde_json::json; +use url::Url; + +/// Defines the type that translates a Barter [`Subscription`](crate::subscription::Subscription) +/// into an exchange [`Connector`] specific channel used for generating [`Connector::requests`]. +pub mod channel; + +/// Defines the type that translates a Barter [`Subscription`](crate::subscription::Subscription) +/// into an exchange [`Connector`] specific market used for generating [`Connector::requests`]. +pub mod market; + +/// [`BitfinexMessage`](message::BitfinexMessage) type for [`Bitfinex`]. +pub mod message; + +/// [`Subscription`](crate::subscription::Subscription) response types and response +/// [`Validator`](barter_integration::Validator) for [`Bitfinex`]. +pub mod subscription; + +/// Public trade types for [`Bitfinex`]. +pub mod trade; + +/// Custom [`SubscriptionValidator`](crate::subscriber::validator::SubscriptionValidator) +/// implementation for [`Bitfinex`]. +pub mod validator; + +/// [`Bitfinex`] server base url. +/// +/// See docs: +pub const BASE_URL_BITFINEX: &str = "wss://api-pub.bitfinex.com/ws/2"; + +/// [`Bitfinex`] exchange. +/// +/// See docs: +#[derive( + Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Default, DeExchange, SerExchange, +)] +pub struct Bitfinex; + +impl Connector for Bitfinex { + const ID: ExchangeId = ExchangeId::Bitfinex; + type Channel = BitfinexChannel; + type Market = BitfinexMarket; + type Subscriber = WebSocketSubscriber; + type SubValidator = BitfinexWebSocketSubValidator; + type SubResponse = BitfinexPlatformEvent; + + fn url() -> Result { + Url::parse(BASE_URL_BITFINEX).map_err(SocketError::UrlParse) + } + + fn requests(exchange_subs: Vec>) -> Vec { + exchange_subs + .into_iter() + .map(|ExchangeSub { channel, market }| { + WsMessage::Text( + json!({ + "event": "subscribe", + "channel": channel.as_ref(), + "symbol": market.as_ref(), + }) + .to_string(), + ) + }) + .collect() + } +} + +impl StreamSelector for Bitfinex +where + Instrument: InstrumentData, +{ + type Stream = + ExchangeWsStream>; +} diff --git a/barter-data/src/exchange/bitfinex/subscription.rs b/barter-data/src/exchange/bitfinex/subscription.rs new file mode 100644 index 00000000..74f4c005 --- /dev/null +++ b/barter-data/src/exchange/bitfinex/subscription.rs @@ -0,0 +1,358 @@ +use barter_integration::{error::SocketError, Validator}; +use serde::{Deserialize, Serialize}; + +/// [`Bitfinex`](super::Bitfinex) platform event detailing the variants expected to be received +/// while connecting and subscribing. +/// +/// ### Raw Payload Examples +/// See docs: +/// #### Platform Status Online +/// ``` json +/// { +/// "event": "info", +/// "version": VERSION, +/// "platform": { +/// "status": 1 +/// } +/// } +/// ``` +/// +/// #### Subscription Trades Success +/// ``` json +/// { +/// event: "subscribed", +/// channel: "trades", +/// chanId: CHANNEL_ID, +/// symbol: "tBTCUSD" +/// pair: "BTCUSD" +/// } +/// ``` +/// +/// #### Subscription Failure +/// ``` json +/// { +/// "event": "error", +/// "msg": ERROR_MSG, +/// "code": ERROR_CODE +/// } +#[derive(Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Deserialize, Serialize)] +#[serde(tag = "event", rename_all = "lowercase")] +pub enum BitfinexPlatformEvent { + #[serde(rename = "info")] + PlatformStatus(BitfinexPlatformStatus), + Subscribed(BitfinexSubResponse), + Error(BitfinexError), +} + +impl Validator for BitfinexPlatformEvent { + fn validate(self) -> Result + where + Self: Sized, + { + match &self { + BitfinexPlatformEvent::PlatformStatus(status) => match status.status { + Status::Operative => Ok(self), + Status::Maintenance => Err(SocketError::Subscribe(format!( + "exchange version: {} with server_id: {} is in maintenance mode", + status.api_version, status.server_id, + ))), + }, + BitfinexPlatformEvent::Subscribed(_) => Ok(self), + BitfinexPlatformEvent::Error(error) => Err(SocketError::Subscribe(format!( + "received failure subscription response code: {} with message: {}", + error.code, error.msg, + ))), + } + } +} + +/// [`Bitfinex`](super::Bitfinex) platform status message containing the server we are connecting +/// to, the version of the API, and if it is in maintenance mode. +/// +/// ### Raw Payload Examples +/// See docs: +/// #### Platform Status Operative +/// ``` json +/// { +/// "event": "info", +/// "version": 2, +/// "serverId": "" +/// "platform": { +/// "status": 1 +/// } +/// } +/// ``` +/// +/// #### Platform Status In Maintenance +/// ``` json +/// { +/// "event": "info", +/// "version": 2, +/// "serverId": "" +/// "platform": { +/// "status": 0 +/// } +/// } +/// ``` +#[derive(Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Deserialize, Serialize)] +pub struct BitfinexPlatformStatus { + #[serde(rename = "version")] + api_version: u8, + #[serde(rename = "serverId")] + server_id: String, + #[serde(rename = "platform")] + status: Status, +} + +/// [`Bitfinex`](super::Bitfinex) platform [`Status`] indicating if the API is in maintenance mode. +/// +/// See [`BitfinexPlatformStatus`] for full raw payload examples. +/// +/// See docs: +#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Serialize)] +pub enum Status { + Maintenance, + Operative, +} + +/// [`Bitfinex`](super::Bitfinex) subscription success response variants for each channel. +/// +/// ### Raw Payload Examples +/// See docs: +/// #### Subscription Trades Success +/// ``` json +/// { +/// event: "subscribed", +/// channel: "trades", +/// chanId: CHANNEL_ID, +/// symbol: "tBTCUSD" +/// pair: "BTCUSD" +/// } +/// ``` +/// +/// #### Subscription Failure +/// ``` json +/// { +/// "event": "error", +/// "msg": ERROR_MSG, +/// "code": ERROR_CODE +/// } +#[derive(Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Deserialize, Serialize)] +pub struct BitfinexSubResponse { + pub channel: String, + #[serde(rename = "symbol")] + pub market: String, + #[serde(rename = "chanId")] + pub channel_id: BitfinexChannelId, +} + +/// [`Bitfinex`](super::Bitfinex) channel identifier that is used to identify the subscription +/// associated with incoming events. See the module level "SubscriptionId" documentation notes +/// for more details. +/// +/// See docs: +#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Deserialize, Serialize)] +pub struct BitfinexChannelId(pub u32); + +/// [`Bitfinex`](super::Bitfinex) error message that is received if a [`BitfinexSubResponse`] +/// indicates a WebSocket subscription failure. +/// +/// ### Subscription Error Codes: +/// 10300: Generic failure +/// 10301: Already subscribed +/// 10302: Unknown channel +/// +/// See [`BitfinexPlatformStatus`] for full raw payload examples. +/// +/// See docs: +#[derive(Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Deserialize, Serialize)] +pub struct BitfinexError { + msg: String, + code: u32, +} + +impl<'de> Deserialize<'de> for Status { + fn deserialize(deserializer: D) -> Result + where + D: serde::de::Deserializer<'de>, + { + #[derive(Deserialize)] + struct Outer { + #[serde(deserialize_with = "de_status_from_u8")] + status: Status, + } + + // Deserialise Outer struct + let Outer { status } = Outer::deserialize(deserializer)?; + + Ok(status) + } +} + +/// Deserialize a `u8` as a `Bitfinex` platform [`Status`]. +/// +/// 0u8 => [`Status::Maintenance`](Status),
+/// 1u8 => [`Status::Operative`](Status),
+/// other => [`de::Error`] +fn de_status_from_u8<'de, D>(deserializer: D) -> Result +where + D: serde::de::Deserializer<'de>, +{ + match Deserialize::deserialize(deserializer)? { + 0 => Ok(Status::Maintenance), + 1 => Ok(Status::Operative), + other => Err(serde::de::Error::invalid_value( + serde::de::Unexpected::Unsigned(other as u64), + &"0 or 1", + )), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_de_bitfinex_platform_event() { + struct TestCase { + input: &'static str, + expected: Result, + } + + let cases = vec![ + // TC0: platform status is online + TestCase { + input: r#"{"event": "info", "version": 2, "serverId": "5b73a436-19ca-4a15-8160-9069bdd7f181", "platform": { "status": 1 }}"#, + expected: Ok(BitfinexPlatformEvent::PlatformStatus( + BitfinexPlatformStatus { + api_version: 2, + server_id: "5b73a436-19ca-4a15-8160-9069bdd7f181".to_string(), + status: Status::Operative, + }, + )), + }, + // TC1: platform status is offline + TestCase { + input: r#"{"event": "info", "version": 2, "serverId": "5b73a436-19ca-4a15-8160-9069bdd7f181", "platform": { "status": 0 }}"#, + expected: Ok(BitfinexPlatformEvent::PlatformStatus( + BitfinexPlatformStatus { + api_version: 2, + server_id: "5b73a436-19ca-4a15-8160-9069bdd7f181".to_string(), + status: Status::Maintenance, + }, + )), + }, + // TC1: successful trades channel subscription + TestCase { + input: r#"{"event": "subscribed", "channel": "trades", "chanId": 2203, "symbol": "tBTCUSD", "pair": "BTCUSD"}"#, + expected: Ok(BitfinexPlatformEvent::Subscribed(BitfinexSubResponse { + channel: "trades".to_string(), + channel_id: BitfinexChannelId(2203), + market: "tBTCUSD".to_owned(), + })), + }, + // TC2: Input response is error + TestCase { + input: r#"{"event": "error", "msg": "Already subscribed", "code": 10202}"#, + expected: Ok(BitfinexPlatformEvent::Error(BitfinexError { + msg: "Already subscribed".to_owned(), + code: 10202, + })), + }, + ]; + + for (index, test) in cases.into_iter().enumerate() { + let actual = serde_json::from_str::(test.input); + match (actual, test.expected) { + (Ok(actual), Ok(expected)) => { + assert_eq!(actual, expected, "TC{} failed", index) + } + (Err(_), Err(_)) => { + // Test passed + } + (actual, expected) => { + // Test failed + panic!("TC{index} failed because actual != expected. \nActual: {actual:?}\nExpected: {expected:?}\n"); + } + } + } + } + + #[test] + fn test_bitfinex_platform_sub_response_validate() { + struct TestCase { + input: BitfinexPlatformEvent, + expected: Result, + } + + let tests = vec![ + TestCase { + // TC0: bitfinex server is offline + input: BitfinexPlatformEvent::PlatformStatus(BitfinexPlatformStatus { + api_version: 2, + server_id: "server_id".to_string(), + status: Status::Maintenance, + }), + expected: Err(SocketError::Subscribe(format!( + "exchange version: {} with server_id: {} is in maintenance mode", + 2, "server_id", + ))), + }, + TestCase { + // TC1: bitfinex server is online + input: BitfinexPlatformEvent::PlatformStatus(BitfinexPlatformStatus { + api_version: 2, + server_id: "server_id".to_string(), + status: Status::Operative, + }), + expected: Ok(BitfinexPlatformEvent::PlatformStatus( + BitfinexPlatformStatus { + api_version: 2, + server_id: "server_id".to_string(), + status: Status::Operative, + }, + )), + }, + TestCase { + // TC2: subscription success + input: BitfinexPlatformEvent::Subscribed(BitfinexSubResponse { + channel: "channel".to_string(), + market: "market".to_string(), + channel_id: BitfinexChannelId(1), + }), + expected: Ok(BitfinexPlatformEvent::Subscribed(BitfinexSubResponse { + channel: "channel".to_string(), + market: "market".to_string(), + channel_id: BitfinexChannelId(1), + })), + }, + TestCase { + // TC3: subscription error + input: BitfinexPlatformEvent::Error(BitfinexError { + msg: "error message".to_string(), + code: 0, + }), + expected: Err(SocketError::Subscribe(format!( + "received failure subscription response code: {} with message: {}", + 0, "error message", + ))), + }, + ]; + + for (index, test) in tests.into_iter().enumerate() { + let actual = test.input.validate(); + match (actual, test.expected) { + (Ok(actual), Ok(expected)) => { + assert_eq!(actual, expected, "TC{} failed", index) + } + (Err(_), Err(_)) => { + // Test passed + } + (actual, expected) => { + // Test failed + panic!("TC{index} failed because actual != expected. \nActual: {actual:?}\nExpected: {expected:?}\n"); + } + } + } + } +} diff --git a/barter-data/src/exchange/bitfinex/trade.rs b/barter-data/src/exchange/bitfinex/trade.rs new file mode 100644 index 00000000..0382ac2a --- /dev/null +++ b/barter-data/src/exchange/bitfinex/trade.rs @@ -0,0 +1,117 @@ +use crate::{ + event::{MarketEvent, MarketIter}, + exchange::ExchangeId, + subscription::trade::PublicTrade, +}; +use barter_integration::{ + de::{datetime_utc_from_epoch_duration, extract_next}, + model::{Exchange, Side}, +}; +use chrono::{DateTime, Utc}; +use serde::Serialize; + +/// [`Bitfinex`](super::Bitfinex) real-time trade message. +/// +/// ### Raw Payload Examples +/// Format: \[ID, TIME, AMOUNT, PRICE\],
where +/- of amount indicates Side +/// +/// See docs: +/// +/// #### Side::Buy Trade +/// See docs: +/// ```json +/// [420191,"te",[1225484398,1665452200022,0.08980641,19027.02807752]] +/// ``` +/// +/// #### Side::Sell Trade +/// See docs: +/// ```json +/// [420191,"te",[1225484398,1665452200022,-0.08980641,19027.02807752]] +/// ``` +/// +/// ## Notes: +/// - [`Bitfinex`](super::Bitfinex) trades subscriptions results in receiving tag="te" & tag="tu" +/// trades, both of which are identical. +/// - "te" trades arrive marginally faster. +/// - Therefore, tag="tu" trades are filtered out and considered only as additional Heartbeats. +/// +/// See docs: +#[derive(Clone, Copy, PartialEq, PartialOrd, Debug, Serialize)] +pub struct BitfinexTrade { + pub id: u64, + pub time: DateTime, + pub side: Side, + pub price: f64, + pub amount: f64, +} + +impl From<(ExchangeId, InstrumentId, BitfinexTrade)> + for MarketIter +{ + fn from((exchange_id, instrument, trade): (ExchangeId, InstrumentId, BitfinexTrade)) -> Self { + Self(vec![Ok(MarketEvent { + exchange_time: trade.time, + received_time: Utc::now(), + exchange: Exchange::from(exchange_id), + instrument, + kind: PublicTrade { + id: trade.id.to_string(), + price: trade.price, + amount: trade.amount, + side: trade.side, + }, + })]) + } +} + +impl<'de> serde::Deserialize<'de> for BitfinexTrade { + fn deserialize(deserializer: D) -> Result + where + D: serde::de::Deserializer<'de>, + { + struct SeqVisitor; + + impl<'de> serde::de::Visitor<'de> for SeqVisitor { + type Value = BitfinexTrade; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("BitfinexTrade struct from the Bitfinex WebSocket API") + } + + fn visit_seq( + self, + mut seq: SeqAccessor, + ) -> Result + where + SeqAccessor: serde::de::SeqAccess<'de>, + { + // Trade: [ID, TIME, AMOUNT,PRICE] + let id = extract_next(&mut seq, "id")?; + let time_millis = extract_next(&mut seq, "time")?; + let amount: f64 = extract_next(&mut seq, "amount")?; + let price = extract_next(&mut seq, "price")?; + let side = match amount.is_sign_positive() { + true => Side::Buy, + false => Side::Sell, + }; + + // Ignore any additional elements or SerDe will fail + // '--> Bitfinex may add fields without warning + while seq.next_element::()?.is_some() {} + + Ok(BitfinexTrade { + id, + time: datetime_utc_from_epoch_duration(std::time::Duration::from_millis( + time_millis, + )), + price, + amount: amount.abs(), + side, + }) + } + } + + // Use Visitor implementation to deserialise the BitfinexTrade message + deserializer.deserialize_seq(SeqVisitor) + } +} diff --git a/barter-data/src/exchange/bitfinex/validator.rs b/barter-data/src/exchange/bitfinex/validator.rs new file mode 100644 index 00000000..7da859ba --- /dev/null +++ b/barter-data/src/exchange/bitfinex/validator.rs @@ -0,0 +1,149 @@ +use super::subscription::{BitfinexPlatformEvent, BitfinexSubResponse}; +use crate::{ + exchange::{Connector, ExchangeSub}, + instrument::InstrumentData, + subscriber::validator::SubscriptionValidator, + subscription::{Map, SubscriptionKind}, + Identifier, +}; +use async_trait::async_trait; +use barter_integration::{ + error::SocketError, + model::SubscriptionId, + protocol::{ + websocket::{WebSocket, WebSocketParser}, + StreamParser, + }, + Validator, +}; +use futures::StreamExt; +use serde::{Deserialize, Serialize}; +use tracing::debug; + +/// [`Bitfinex`](super::Bitfinex) specific [`SubscriptionValidator`]. +/// +/// ### Notes +/// - Required because Bitfinex has a non-self-describing data format after subscriptions have been +/// validated. +/// - The [`BitfinexChannelId`](super::subscription::BitfinexChannelId) is used to identify the +/// [`Subscription`](crate::subscription::Subscription) associated with incoming +/// events, rather than a `String` channel-market identifier. +/// - Therefore the [`SubscriptionId`] format must change during [`BitfinexWebSocketSubValidator::validate`] +/// to use the [`BitfinexChannelId`](super::subscription::BitfinexChannelId) +/// (see module level "SubscriptionId" documentation notes for more details). +#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Deserialize, Serialize)] +pub struct BitfinexWebSocketSubValidator; + +#[async_trait] +impl SubscriptionValidator for BitfinexWebSocketSubValidator { + type Parser = WebSocketParser; + + async fn validate( + mut map: Map, + websocket: &mut WebSocket, + ) -> Result, SocketError> + where + Exchange: Connector + Send, + Instrument: InstrumentData, + Kind: SubscriptionKind + Send, + { + // Establish exchange specific subscription validation parameters + let timeout = Exchange::subscription_timeout(); + let expected_responses = Exchange::expected_responses(&map); + + // Parameter to keep track of successful Subscription outcomes + // '--> Bitfinex sends snapshots as the first message, so count them also + let mut success_responses = 0usize; + let mut init_snapshots_received = 0usize; + + loop { + // Break if all Subscriptions were a success + if success_responses == expected_responses + && init_snapshots_received == expected_responses + { + debug!(exchange = %Exchange::ID, "validated exchange WebSocket subscriptions"); + break Ok(map); + } + + tokio::select! { + // If timeout reached, return SubscribeError + _ = tokio::time::sleep(timeout) => { + break Err(SocketError::Subscribe( + format!("subscription validation timeout reached: {:?}", timeout) + )) + }, + // Parse incoming messages and determine subscription outcomes + message = websocket.next() => { + let response = match message { + Some(response) => response, + None => break Err(SocketError::Subscribe("WebSocket stream terminated unexpectedly".to_string())) + }; + + match Self::Parser::parse::(response) { + Some(Ok(response)) => match response.validate() { + // Bitfinex server is online + Ok(BitfinexPlatformEvent::PlatformStatus(status)) => { + debug!( + exchange = %Exchange::ID, + %success_responses, + %expected_responses, + payload = ?status, + "received Bitfinex platform status", + ); + } + + // Subscription success + Ok(BitfinexPlatformEvent::Subscribed(response)) => { + // Determine SubscriptionId associated with the success response + let BitfinexSubResponse { channel, market, channel_id } = &response; + let subscription_id = ExchangeSub::from((channel, market)).id(); + + // Replace SubscriptionId with SubscriptionId(channel_id) + if let Some(subscription) = map.0.remove(&subscription_id) { + success_responses += 1; + map.0.insert(SubscriptionId(channel_id.0.to_string()), subscription); + + debug!( + exchange = %Exchange::ID, + %success_responses, + %expected_responses, + payload = ?response, + "received valid Ok subscription response", + ); + } + } + + // Subscription failure + Err(err) => break Err(err), + + // Not reachable after BitfinexPlatformEvent validate() + Ok(BitfinexPlatformEvent::Error(error)) => panic!("{error:?}"), + } + Some(Err(SocketError::Deserialise { error, payload })) if success_responses >= 1 => { + // Already active Bitfinex subscriptions will send initial snapshots + init_snapshots_received += 1; + debug!( + exchange = %Exchange::ID, + ?error, + %success_responses, + %expected_responses, + %payload, + "failed to deserialise non SubResponse payload" + ); + continue + } + Some(Err(SocketError::Terminated(close_frame))) => { + break Err(SocketError::Subscribe( + format!("received WebSocket CloseFrame: {close_frame}") + )) + } + _ => { + // Pings, Pongs, Frames, etc. + continue + } + } + } + } + } + } +} diff --git a/barter-data/src/exchange/bitmex/channel.rs b/barter-data/src/exchange/bitmex/channel.rs new file mode 100644 index 00000000..36a1179b --- /dev/null +++ b/barter-data/src/exchange/bitmex/channel.rs @@ -0,0 +1,32 @@ +use crate::{ + exchange::bitmex::Bitmex, + subscription::{trade::PublicTrades, Subscription}, + Identifier, +}; +use serde::Serialize; + +/// Type that defines how to translate a Barter [`Subscription`] into a [`Bitmex`] +/// channel to be subscribed to. +/// +/// See docs: +#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Serialize)] +pub struct BitmexChannel(pub &'static str); + +impl BitmexChannel { + /// [`Bitmex`] real-time trades channel name. + /// + /// See docs: + pub const TRADES: Self = Self("trade"); +} + +impl Identifier for Subscription { + fn id(&self) -> BitmexChannel { + BitmexChannel::TRADES + } +} + +impl AsRef for BitmexChannel { + fn as_ref(&self) -> &str { + self.0 + } +} diff --git a/barter-data/src/exchange/bitmex/market.rs b/barter-data/src/exchange/bitmex/market.rs new file mode 100644 index 00000000..f92dfc99 --- /dev/null +++ b/barter-data/src/exchange/bitmex/market.rs @@ -0,0 +1,45 @@ +use crate::{ + exchange::bitmex::Bitmex, + instrument::{KeyedInstrument, MarketInstrumentData}, + subscription::Subscription, + Identifier, +}; +use barter_integration::model::instrument::{symbol::Symbol, Instrument}; +use serde::{Deserialize, Serialize}; + +/// Type that defines how to translate a Barter [`Subscription`] into a [`Bitmex`] +/// market that can be subscribed to. +/// +/// See docs: +#[derive(Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Deserialize, Serialize)] +pub struct BitmexMarket(pub String); + +impl Identifier for Subscription { + fn id(&self) -> BitmexMarket { + bitmex_market(&self.instrument.base, &self.instrument.quote) + } +} + +impl Identifier for Subscription { + fn id(&self) -> BitmexMarket { + bitmex_market(&self.instrument.data.base, &self.instrument.data.quote) + } +} + +impl Identifier for Subscription { + fn id(&self) -> BitmexMarket { + BitmexMarket(self.instrument.name_exchange.clone()) + } +} + +impl AsRef for BitmexMarket { + fn as_ref(&self) -> &str { + &self.0 + } +} + +fn bitmex_market(base: &Symbol, quote: &Symbol) -> BitmexMarket { + // Notes: + // - Must be uppercase since Bitmex sends message with uppercase MARKET (eg/ XBTUSD). + BitmexMarket(format!("{base}{quote}").to_uppercase()) +} diff --git a/barter-data/src/exchange/bitmex/message.rs b/barter-data/src/exchange/bitmex/message.rs new file mode 100644 index 00000000..03985722 --- /dev/null +++ b/barter-data/src/exchange/bitmex/message.rs @@ -0,0 +1,42 @@ +use crate::{exchange::bitmex::trade::BitmexTrade, Identifier}; +use barter_integration::model::SubscriptionId; +use serde::{Deserialize, Serialize}; + +/// ### Raw Payload Examples +/// See docs: +/// #### Trade payload +/// ```json +/// { +/// "table": "trade", +/// "action": "insert", +/// "data": [ +/// { +/// "timestamp": "2023-02-18T09:27:59.701Z", +/// "symbol": "XBTUSD", +/// "side": "Sell", +/// "size": 200, +/// "price": 24564.5, +/// "tickDirection": "MinusTick", +/// "trdMatchID": "31e50cb7-e005-a44e-f354-86e88dff52eb", +/// "grossValue": 814184, +/// "homeNotional": 0.00814184, +/// "foreignNotional": 200, +/// "trdType": "Regular" +/// } +/// ] +/// } +///``` +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Debug, Deserialize, Serialize)] +pub struct BitmexMessage { + pub table: String, + pub data: Vec, +} + +impl Identifier> for BitmexTrade { + fn id(&self) -> Option { + self.data + .first() + .map(|trade| SubscriptionId(format!("{}|{}", self.table, trade.symbol))) + .or(None) + } +} diff --git a/barter-data/src/exchange/bitmex/mod.rs b/barter-data/src/exchange/bitmex/mod.rs new file mode 100644 index 00000000..6d7073f4 --- /dev/null +++ b/barter-data/src/exchange/bitmex/mod.rs @@ -0,0 +1,111 @@ +use crate::{ + exchange::{ + bitmex::{ + channel::BitmexChannel, market::BitmexMarket, subscription::BitmexSubResponse, + trade::BitmexTrade, + }, + subscription::ExchangeSub, + Connector, ExchangeId, StreamSelector, + }, + instrument::InstrumentData, + subscriber::{validator::WebSocketSubValidator, WebSocketSubscriber}, + subscription::{trade::PublicTrades, Map}, + transformer::stateless::StatelessTransformer, + ExchangeWsStream, +}; +use barter_integration::{error::SocketError, protocol::websocket::WsMessage}; +use serde::de::{Error, Unexpected}; +use std::fmt::Debug; +use url::Url; + +/// Defines the type that translates a Barter [`Subscription`](crate::subscription::Subscription) +/// into an exchange [`Connector`] specific channel used for generating [`Connector::requests`]. +pub mod channel; + +/// Defines the type that translates a Barter [`Subscription`](crate::subscription::Subscription) +/// into an exchange [`Connector`] specific market used for generating [`Connector::requests`]. +pub mod market; + +/// Generic [`BitmexMessage`](message::BitmexMessage) +pub mod message; + +/// [`Subscription`](crate::subscription::Subscription) response type and response +/// [`Validator`](barter_integration::Validator) for [`Bitmex`]. +pub mod subscription; + +/// Public trade types for [`Bitmex`](Bitmex) +pub mod trade; + +/// [`Bitmex`] server base url. +/// +/// See docs: +pub const BASE_URL_BITMEX: &str = "wss://ws.bitmex.com/realtime"; + +#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Default)] +pub struct Bitmex; + +impl Connector for Bitmex { + const ID: ExchangeId = ExchangeId::Bitmex; + type Channel = BitmexChannel; + type Market = BitmexMarket; + type Subscriber = WebSocketSubscriber; + type SubValidator = WebSocketSubValidator; + type SubResponse = BitmexSubResponse; + + fn url() -> Result { + Url::parse(BASE_URL_BITMEX).map_err(SocketError::UrlParse) + } + + fn requests(exchange_subs: Vec>) -> Vec { + let stream_names = exchange_subs + .into_iter() + .map(|sub| format!("{}:{}", sub.channel.as_ref(), sub.market.as_ref(),)) + .collect::>(); + + vec![WsMessage::Text( + serde_json::json!({ + "op": "subscribe", + "args": stream_names + }) + .to_string(), + )] + } + + fn expected_responses(_: &Map) -> usize { + 1 + } +} + +impl StreamSelector for Bitmex +where + Instrument: InstrumentData, +{ + type Stream = + ExchangeWsStream>; +} + +impl<'de> serde::Deserialize<'de> for Bitmex { + fn deserialize(deserializer: D) -> Result + where + D: serde::de::Deserializer<'de>, + { + let input = <&str as serde::Deserialize>::deserialize(deserializer)?; + let expected = Self::ID.as_str(); + + if input == Self::ID.as_str() { + Ok(Self) + } else { + Err(Error::invalid_value(Unexpected::Str(input), &expected)) + } + } +} + +impl serde::Serialize for Bitmex { + fn serialize(&self, serializer: S) -> Result + where + S: serde::ser::Serializer, + { + let exchange_id = Self::ID.as_str(); + serializer.serialize_str(exchange_id) + } +} diff --git a/barter-data/src/exchange/bitmex/subscription.rs b/barter-data/src/exchange/bitmex/subscription.rs new file mode 100644 index 00000000..da6c43a3 --- /dev/null +++ b/barter-data/src/exchange/bitmex/subscription.rs @@ -0,0 +1,139 @@ +use barter_integration::{error::SocketError, Validator}; +use serde::{Deserialize, Serialize}; + +/// ### Raw Payload Examples +/// See docs: +/// #### Subscription response payload +/// ```json +/// { +/// "success": true, +/// "subscribe": "trade:XBTUSD", +/// "request": { +/// "op":"subscribe", +/// "args":[ +/// "trade:XBTUSD" +/// ] +/// } +/// } +///``` +#[derive(Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Deserialize, Serialize)] +pub struct BitmexSubResponse { + success: bool, + subscribe: String, +} + +impl Validator for BitmexSubResponse { + fn validate(self) -> Result + where + Self: Sized, + { + if self.success { + Ok(self) + } else { + Err(SocketError::Subscribe(format!( + "received failure subscription response for {} subscription", + self.subscribe + ))) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + mod de { + use super::*; + + #[test] + fn test_bitmex_sub_response() { + struct TestCase { + input: &'static str, + expected: Result, + } + + let cases = vec![ + TestCase { + // TC0: input response is Subscribed + input: r#" + { + "success": true, + "subscribe": "orderBookL2_25:XBTUSD", + "request": { + "op":"subscribe", + "args":[ + "orderBookL2_25:XBTUSD" + ] + } + } + "#, + expected: Ok(BitmexSubResponse { + success: true, + subscribe: "orderBookL2_25:XBTUSD".to_string(), + }), + }, + TestCase { + // TC1: input response is failed subscription + input: r#" + { + "success": false, + "subscribe": "orderBookL2_25:XBTUSD" + } + "#, + expected: Ok(BitmexSubResponse { + success: false, + subscribe: "orderBookL2_25:XBTUSD".to_string(), + }), + }, + ]; + + for (index, test) in cases.into_iter().enumerate() { + let actual = serde_json::from_str::(test.input); + match (actual, test.expected) { + (Ok(actual), Ok(expected)) => { + assert_eq!(actual, expected, "TC{} failed", index) + } + (Err(_), Err(_)) => { + // Test passed + } + (actual, expected) => { + // Test failed + panic!("TC{index} failed because actual != expected. \nActual: {actual:?}\nExpected: {expected:?}\n"); + } + } + } + } + } + + #[test] + fn test_validate_bitmex_sub_response() { + struct TestCase { + input_response: BitmexSubResponse, + is_valid: bool, + } + + let cases = vec![ + TestCase { + // TC0: input response is successful subscription + input_response: BitmexSubResponse { + success: true, + subscribe: "orderBookL2_25:XBTUSD".to_string(), + }, + is_valid: true, + }, + TestCase { + // TC1: input response is failed subscription + input_response: BitmexSubResponse { + success: false, + subscribe: "orderBookL2_25:XBTUSD".to_string(), + }, + is_valid: false, + }, + ]; + + for (index, test) in cases.into_iter().enumerate() { + let actual = test.input_response.validate().is_ok(); + assert_eq!(actual, test.is_valid, "TestCase {} failed", index); + } + } +} diff --git a/barter-data/src/exchange/bitmex/trade.rs b/barter-data/src/exchange/bitmex/trade.rs new file mode 100644 index 00000000..b99120ce --- /dev/null +++ b/barter-data/src/exchange/bitmex/trade.rs @@ -0,0 +1,205 @@ +use crate::{ + event::{MarketEvent, MarketIter}, + exchange::{bitmex::message::BitmexMessage, ExchangeId}, + subscription::trade::PublicTrade, +}; +use barter_integration::model::{Exchange, Side}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +/// Terse type alias for an [`BitmexTrade`](BitmexTradeInner) real-time trades WebSocket message. +pub type BitmexTrade = BitmexMessage; + +/// ### Raw Payload Examples +/// See docs: +/// #### Trade payload +/// ```json +/// { +/// "table": "trade", +/// "action": "insert", +/// "data": [ +/// { +/// "timestamp": "2023-02-18T09:27:59.701Z", +/// "symbol": "XBTUSD", +/// "side": "Sell", +/// "size": 200, +/// "price": 24564.5, +/// "tickDirection": "MinusTick", +/// "trdMatchID": "31e50cb7-e005-a44e-f354-86e88dff52eb", +/// "grossValue": 814184, +/// "homeNotional": 0.00814184, +/// "foreignNotional": 200, +/// "trdType": "Regular" +/// } +/// ] +/// } +///``` +#[derive(Clone, PartialEq, PartialOrd, Debug, Deserialize, Serialize)] +pub struct BitmexTradeInner { + pub timestamp: DateTime, + + pub symbol: String, + + pub side: Side, + #[serde(rename = "size")] + pub amount: f64, + pub price: f64, + + #[serde(rename = "trdMatchID")] + pub id: String, +} + +impl From<(ExchangeId, InstrumentId, BitmexTrade)> + for MarketIter +{ + fn from((exchange_id, instrument, trades): (ExchangeId, InstrumentId, BitmexTrade)) -> Self { + Self( + trades + .data + .into_iter() + .map(|trade| { + Ok(MarketEvent { + exchange_time: trade.timestamp, + received_time: Utc::now(), + exchange: Exchange::from(exchange_id), + instrument: instrument.clone(), + kind: PublicTrade { + id: trade.id, + price: trade.price, + amount: trade.amount, + side: trade.side, + }, + }) + }) + .collect(), + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + mod de { + use super::*; + use barter_integration::error::SocketError; + use chrono::{Duration, TimeZone}; + + #[test] + fn test_bitmex_trade() { + struct TestCase { + input: &'static str, + expected: Result, + } + + let tests = vec![ + // TC0: input BitmexTrade is deserialised + TestCase { + input: r#" + { + "timestamp": "2023-02-18T09:27:59.701Z", + "symbol": "XBTUSD", + "side": "Sell", + "size": 200, + "price": 24564.5, + "tickDirection": "MinusTick", + "trdMatchID": "31e50cb7-e005-a44e-f354-86e88dff52eb", + "grossValue": 814184, + "homeNotional": 0.00814184, + "foreignNotional": 200, + "trdType": "Regular" + } + "#, + expected: Ok(BitmexTradeInner { + timestamp: Utc.with_ymd_and_hms(2023, 2, 18, 9, 27, 59).unwrap() + + Duration::milliseconds(701), + symbol: "XBTUSD".to_string(), + side: Side::Sell, + amount: 200.0, + price: 24564.5, + id: "31e50cb7-e005-a44e-f354-86e88dff52eb".to_string(), + }), + }, + ]; + + for (index, test) in tests.into_iter().enumerate() { + let actual = serde_json::from_str::(test.input); + match (actual, test.expected) { + (Ok(actual), Ok(expected)) => { + assert_eq!(actual, expected, "TC{} failed", index) + } + (Err(_), Err(_)) => { + // Test passed + } + (actual, expected) => { + // Test failed + panic!("TC{index} failed because actual != expected. \nActual: {actual:?}\nExpected: {expected:?}\n"); + } + } + } + } + + #[test] + fn test_bitmex_trade_payload() { + struct TestCase { + input: &'static str, + expected: Result, + } + + let tests = vec![ + // TC0: input BitmexTradePayload is deserialised + TestCase { + input: r#" + { + "table": "trade", + "action": "insert", + "data": [ + { + "timestamp": "2023-02-18T09:27:59.701Z", + "symbol": "XBTUSD", + "side": "Sell", + "size": 200, + "price": 24564.5, + "tickDirection": "MinusTick", + "trdMatchID": "31e50cb7-e005-a44e-f354-86e88dff52eb", + "grossValue": 814184, + "homeNotional": 0.00814184, + "foreignNotional": 200, + "trdType": "Regular" + } + ] + } + "#, + expected: Ok(BitmexTrade { + table: "trade".to_string(), + data: vec![BitmexTradeInner { + timestamp: Utc.with_ymd_and_hms(2023, 2, 18, 9, 27, 59).unwrap() + + Duration::milliseconds(701), + symbol: "XBTUSD".to_string(), + side: Side::Sell, + amount: 200.0, + price: 24564.5, + id: "31e50cb7-e005-a44e-f354-86e88dff52eb".to_string(), + }], + }), + }, + ]; + + for (index, test) in tests.into_iter().enumerate() { + let actual = serde_json::from_str::(test.input); + match (actual, test.expected) { + (Ok(actual), Ok(expected)) => { + assert_eq!(actual, expected, "TC{} failed", index) + } + (Err(_), Err(_)) => { + // Test passed + } + (actual, expected) => { + // Test failed + panic!("TC{index} failed because actual != expected. \nActual: {actual:?}\nExpected: {expected:?}\n"); + } + } + } + } + } +} diff --git a/barter-data/src/exchange/bybit/channel.rs b/barter-data/src/exchange/bybit/channel.rs new file mode 100644 index 00000000..e2413467 --- /dev/null +++ b/barter-data/src/exchange/bybit/channel.rs @@ -0,0 +1,34 @@ +use crate::{ + exchange::bybit::Bybit, + subscription::{trade::PublicTrades, Subscription}, + Identifier, +}; +use serde::Serialize; + +/// Type that defines how to translate a Barter [`Subscription`] into a [`Bybit`] +/// channel to be subscribed to. +/// +/// See docs: +#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Serialize)] +pub struct BybitChannel(pub &'static str); + +impl BybitChannel { + /// [`Bybit`] real-time trades channel name. + /// + /// See docs: + pub const TRADES: Self = Self("publicTrade"); +} + +impl Identifier + for Subscription, Instrument, PublicTrades> +{ + fn id(&self) -> BybitChannel { + BybitChannel::TRADES + } +} + +impl AsRef for BybitChannel { + fn as_ref(&self) -> &str { + self.0 + } +} diff --git a/barter-data/src/exchange/bybit/futures/mod.rs b/barter-data/src/exchange/bybit/futures/mod.rs new file mode 100644 index 00000000..c81a384e --- /dev/null +++ b/barter-data/src/exchange/bybit/futures/mod.rs @@ -0,0 +1,22 @@ +use super::{Bybit, ExchangeServer}; +use crate::exchange::ExchangeId; + +/// [`BybitPerpetualsUsd`] WebSocket server base url. +/// +/// See docs: +pub const WEBSOCKET_BASE_URL_BYBIT_PERPETUALS_USD: &str = "wss://stream.bybit.com/v5/public/linear"; + +/// [`Bybit`] perpetual exchange. +pub type BybitPerpetualsUsd = Bybit; + +/// [`Bybit`] perpetual [`ExchangeServer`]. +#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Default)] +pub struct BybitServerPerpetualsUsd; + +impl ExchangeServer for BybitServerPerpetualsUsd { + const ID: ExchangeId = ExchangeId::BybitPerpetualsUsd; + + fn websocket_url() -> &'static str { + WEBSOCKET_BASE_URL_BYBIT_PERPETUALS_USD + } +} diff --git a/barter-data/src/exchange/bybit/market.rs b/barter-data/src/exchange/bybit/market.rs new file mode 100644 index 00000000..53476567 --- /dev/null +++ b/barter-data/src/exchange/bybit/market.rs @@ -0,0 +1,47 @@ +use crate::{ + exchange::bybit::Bybit, + instrument::{KeyedInstrument, MarketInstrumentData}, + subscription::Subscription, + Identifier, +}; +use barter_integration::model::instrument::{symbol::Symbol, Instrument}; +use serde::{Deserialize, Serialize}; + +/// Type that defines how to translate a Barter [`Subscription`] into a [`Bybit`] +/// market that can be subscribed to. +/// +/// See docs: +#[derive(Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Deserialize, Serialize)] +pub struct BybitMarket(pub String); + +impl Identifier for Subscription, Instrument, Kind> { + fn id(&self) -> BybitMarket { + bybit_market(&self.instrument.base, &self.instrument.quote) + } +} + +impl Identifier for Subscription, KeyedInstrument, Kind> { + fn id(&self) -> BybitMarket { + bybit_market(&self.instrument.data.base, &self.instrument.data.quote) + } +} + +impl Identifier + for Subscription, MarketInstrumentData, Kind> +{ + fn id(&self) -> BybitMarket { + BybitMarket(self.instrument.name_exchange.clone()) + } +} + +impl AsRef for BybitMarket { + fn as_ref(&self) -> &str { + &self.0 + } +} + +fn bybit_market(base: &Symbol, quote: &Symbol) -> BybitMarket { + // Notes: + // - Must be uppercase since Bybit sends message with uppercase MARKET (eg/ BTCUSDT). + BybitMarket(format!("{base}{quote}").to_uppercase()) +} diff --git a/barter-data/src/exchange/bybit/message.rs b/barter-data/src/exchange/bybit/message.rs new file mode 100644 index 00000000..703b00dc --- /dev/null +++ b/barter-data/src/exchange/bybit/message.rs @@ -0,0 +1,157 @@ +use crate::{ + event::MarketIter, + exchange::{ + bybit::{channel::BybitChannel, subscription::BybitResponse, trade::BybitTrade}, + ExchangeId, + }, + subscription::trade::PublicTrade, + Identifier, +}; +use barter_integration::model::SubscriptionId; +use chrono::{DateTime, Utc}; +use serde::{ + de::{Error, Unexpected}, + Deserialize, Serialize, +}; + +/// [`Bybit`](super::Bybit) websocket message supports both [`BybitTrade`](BybitTrade) and [`BybitResponse`](BybitResponse) . +#[derive(Debug, Serialize, Deserialize)] +#[serde(untagged)] +pub enum BybitMessage { + Response(BybitResponse), + Trade(BybitTrade), +} + +/// ### Raw Payload Examples +/// See docs: +/// #### Spot Side::Buy Trade +///```json +/// { +/// "topic": "publicTrade.BTCUSDT", +/// "type": "snapshot", +/// "ts": 1672304486868, +/// "data": [ +/// { +/// "T": 1672304486865, +/// "s": "BTCUSDT", +/// "S": "Buy", +/// "v": "0.001", +/// "p": "16578.50", +/// "L": "PlusTick", +/// "i": "20f43950-d8dd-5b31-9112-a178eb6023af", +/// "BT": false +/// } +/// ] +/// } +/// ``` +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Debug, Deserialize, Serialize)] +pub struct BybitPayload { + #[serde(alias = "topic", deserialize_with = "de_message_subscription_id")] + pub subscription_id: SubscriptionId, + + #[serde(rename = "type")] + pub r#type: String, + + #[serde( + alias = "ts", + deserialize_with = "barter_integration::de::de_u64_epoch_ms_as_datetime_utc" + )] + pub time: DateTime, + pub data: T, +} + +/// Deserialize a [`BybitPayload`] "s" (eg/ "publicTrade.BTCUSDT") as the associated +/// [`SubscriptionId`]. +/// +/// eg/ "publicTrade|BTCUSDT" +pub fn de_message_subscription_id<'de, D>(deserializer: D) -> Result +where + D: serde::de::Deserializer<'de>, +{ + let input = <&str as serde::Deserialize>::deserialize(deserializer)?; + let mut tokens = input.split('.'); + + match (tokens.next(), tokens.next(), tokens.next()) { + (Some("publicTrade"), Some(market), None) => Ok(SubscriptionId::from(format!( + "{}|{market}", + BybitChannel::TRADES.0 + ))), + _ => Err(Error::invalid_value( + Unexpected::Str(input), + &"invalid message type expected pattern: .", + )), + } +} + +impl Identifier> for BybitMessage { + fn id(&self) -> Option { + match self { + BybitMessage::Trade(trade) => Some(trade.subscription_id.clone()), + _ => None, + } + } +} + +impl From<(ExchangeId, InstrumentId, BybitMessage)> + for MarketIter +{ + fn from((exchange_id, instrument, message): (ExchangeId, InstrumentId, BybitMessage)) -> Self { + match message { + BybitMessage::Response(_) => Self(vec![]), + BybitMessage::Trade(trade) => Self::from((exchange_id, instrument, trade)), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + mod de { + use super::*; + use crate::exchange::bybit::subscription::BybitReturnMessage; + use barter_integration::error::SocketError; + + #[test] + fn test_bybit_pong() { + struct TestCase { + input: &'static str, + expected: Result, + } + + let tests = vec![ + // TC0: input BybitResponse(Pong) is deserialised + TestCase { + input: r#" + { + "success": true, + "ret_msg": "pong", + "conn_id": "0970e817-426e-429a-a679-ff7f55e0b16a", + "op": "ping" + } + "#, + expected: Ok(BybitResponse { + success: true, + ret_msg: BybitReturnMessage::Pong, + }), + }, + ]; + + for (index, test) in tests.into_iter().enumerate() { + let actual = serde_json::from_str::(test.input); + match (actual, test.expected) { + (Ok(actual), Ok(expected)) => { + assert_eq!(actual, expected, "TC{} failed", index) + } + (Err(_), Err(_)) => { + // Test passed + } + (actual, expected) => { + // Test failed + panic!("TC{index} failed because actual != expected. \nActual: {actual:?}\nExpected: {expected:?}\n"); + } + } + } + } + } +} diff --git a/barter-data/src/exchange/bybit/mod.rs b/barter-data/src/exchange/bybit/mod.rs new file mode 100644 index 00000000..912ed7af --- /dev/null +++ b/barter-data/src/exchange/bybit/mod.rs @@ -0,0 +1,149 @@ +use crate::{ + exchange::{ + bybit::{ + channel::BybitChannel, market::BybitMarket, message::BybitMessage, + subscription::BybitResponse, + }, + subscription::ExchangeSub, + Connector, ExchangeId, ExchangeServer, PingInterval, StreamSelector, + }, + instrument::InstrumentData, + subscriber::{validator::WebSocketSubValidator, WebSocketSubscriber}, + subscription::{trade::PublicTrades, Map}, + transformer::stateless::StatelessTransformer, + ExchangeWsStream, +}; +use barter_integration::{error::SocketError, protocol::websocket::WsMessage}; +use serde::de::{Error, Unexpected}; +use std::{fmt::Debug, marker::PhantomData, time::Duration}; +use tokio::time; +use url::Url; + +/// Defines the type that translates a Barter [`Subscription`](crate::subscription::Subscription) +/// into an exchange [`Connector`] specific channel used for generating [`Connector::requests`]. +pub mod channel; + +/// [`ExchangeServer`] and [`StreamSelector`] implementations for +/// [`BybitFuturesUsd`](futures::BybitPerpetualsUsd). +pub mod futures; + +/// Defines the type that translates a Barter [`Subscription`](crate::subscription::Subscription) +/// into an exchange [`Connector`] specific market used for generating [`Connector::requests`]. +pub mod market; + +/// Generic [`BybitPayload`](message::BybitPayload) type common to +/// [`BybitSpot`](spot::BybitSpot) +pub mod message; + +/// [`ExchangeServer`] and [`StreamSelector`] implementations for +/// [`BybitSpot`](spot::BybitSpot). +pub mod spot; + +/// [`Subscription`](crate::subscription::Subscription) response type and response +/// [`Validator`](barter_integration::Validator) common to both [`BybitSpot`](spot::BybitSpot) +/// and [`BybitFuturesUsd`](futures::BybitPerpetualsUsd). +pub mod subscription; + +/// Public trade types common to both [`BybitSpot`](spot::BybitSpot) and +/// [`BybitFuturesUsd`](futures::BybitPerpetualsUsd). +pub mod trade; + +/// Generic [`Bybit`](Bybit) exchange. +/// +/// ### Notes +/// A `Server` [`ExchangeServer`] implementations exists for +/// [`BybitSpot`](spot::BybitSpot) and [`BybitFuturesUsd`](futures::BybitPerpetualsUsd). +#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Default)] +pub struct Bybit { + server: PhantomData, +} + +impl Connector for Bybit +where + Server: ExchangeServer, +{ + const ID: ExchangeId = Server::ID; + type Channel = BybitChannel; + type Market = BybitMarket; + type Subscriber = WebSocketSubscriber; + type SubValidator = WebSocketSubValidator; + type SubResponse = BybitResponse; + + fn url() -> Result { + Url::parse(Server::websocket_url()).map_err(SocketError::UrlParse) + } + + fn ping_interval() -> Option { + Some(PingInterval { + interval: time::interval(Duration::from_millis(5_000)), + ping: || { + WsMessage::Text( + serde_json::json!({ + "op": "ping", + }) + .to_string(), + ) + }, + }) + } + + fn requests(exchange_subs: Vec>) -> Vec { + let stream_names = exchange_subs + .into_iter() + .map(|sub| format!("{}.{}", sub.channel.as_ref(), sub.market.as_ref(),)) + .collect::>(); + + vec![WsMessage::Text( + serde_json::json!({ + "op": "subscribe", + "args": stream_names + }) + .to_string(), + )] + } + + fn expected_responses(_: &Map) -> usize { + 1 + } +} + +impl StreamSelector for Bybit +where + Instrument: InstrumentData, + Server: ExchangeServer + Debug + Send + Sync, +{ + type Stream = + ExchangeWsStream>; +} + +impl<'de, Server> serde::Deserialize<'de> for Bybit +where + Server: ExchangeServer, +{ + fn deserialize(deserializer: D) -> Result + where + D: serde::de::Deserializer<'de>, + { + let input = <&str as serde::Deserialize>::deserialize(deserializer)?; + let expected = Self::ID.as_str(); + + if input == Self::ID.as_str() { + Ok(Self::default()) + } else { + Err(Error::invalid_value(Unexpected::Str(input), &expected)) + } + } +} + +impl serde::Serialize for Bybit +where + Server: ExchangeServer, +{ + fn serialize(&self, serializer: S) -> Result + where + S: serde::ser::Serializer, + { + let exchange_id = Self::ID.as_str(); + serializer.serialize_str(exchange_id) + } +} diff --git a/barter-data/src/exchange/bybit/spot/mod.rs b/barter-data/src/exchange/bybit/spot/mod.rs new file mode 100644 index 00000000..b6895ece --- /dev/null +++ b/barter-data/src/exchange/bybit/spot/mod.rs @@ -0,0 +1,22 @@ +use super::{Bybit, ExchangeServer}; +use crate::exchange::ExchangeId; + +/// [`BybitSpot`] WebSocket server base url. +/// +/// See docs: +pub const WEBSOCKET_BASE_URL_BYBIT_SPOT: &str = "wss://stream.bybit.com/v5/public/spot"; + +/// [`Bybit`] spot exchange. +pub type BybitSpot = Bybit; + +/// [`Bybit`] spot [`ExchangeServer`]. +#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Default)] +pub struct BybitServerSpot; + +impl ExchangeServer for BybitServerSpot { + const ID: ExchangeId = ExchangeId::BybitSpot; + + fn websocket_url() -> &'static str { + WEBSOCKET_BASE_URL_BYBIT_SPOT + } +} diff --git a/barter-data/src/exchange/bybit/subscription.rs b/barter-data/src/exchange/bybit/subscription.rs new file mode 100644 index 00000000..89266212 --- /dev/null +++ b/barter-data/src/exchange/bybit/subscription.rs @@ -0,0 +1,176 @@ +use barter_integration::{error::SocketError, Validator}; +use serde::{Deserialize, Serialize}; + +/// [`Bybit`](super::Bybit) subscription response message. +/// +/// ### Raw Payload Examples +/// See docs: +/// #### Subscription Success +/// ```json +/// { +/// "success": true, +/// "ret_msg": "subscribe", +/// "conn_id": "2324d924-aa4d-45b0-a858-7b8be29ab52b", +/// "req_id": "10001", +/// "op": "subscribe" +/// } +/// #### Subscription Failure +/// ```json +/// { +/// "success": false, +/// "ret_msg": "", +/// "conn_id": "2324d924-aa4d-45b0-a858-7b8be29ab52b", +/// "req_id": "10001", +/// "op": "subscribe" +/// } +/// +#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Deserialize, Serialize)] +pub struct BybitResponse { + pub success: bool, + #[serde(default)] + pub ret_msg: BybitReturnMessage, +} + +#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Deserialize, Serialize)] +pub enum BybitReturnMessage { + #[serde(alias = "")] + None, + #[serde(alias = "pong")] + Pong, + #[serde(alias = "subscribe")] + Subscribe, +} + +impl Default for BybitReturnMessage { + fn default() -> Self { + Self::None + } +} + +impl Validator for BybitResponse { + fn validate(self) -> Result + where + Self: Sized, + { + match self.ret_msg { + BybitReturnMessage::None | BybitReturnMessage::Subscribe => { + if self.success { + Ok(self) + } else { + Err(SocketError::Subscribe( + "received failure subscription response".to_owned(), + )) + } + } + _ => Err(SocketError::Subscribe( + "received other message out of sequence".to_owned(), + )), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + mod de { + use super::*; + + #[test] + fn test_bybit_sub_response() { + struct TestCase { + input: &'static str, + expected: Result, + } + + let cases = vec![ + TestCase { + // TC0: input response is Subscribed + input: r#" + { + "success": true, + "ret_msg": "subscribe", + "conn_id": "2324d924-aa4d-45b0-a858-7b8be29ab52b", + "req_id": "10001", + "op": "subscribe" + } + "#, + expected: Ok(BybitResponse { + success: true, + ret_msg: BybitReturnMessage::Subscribe, + }), + }, + TestCase { + // TC1: input response is failed subscription + input: r#" + { + "success": false, + "conn_id": "", + "op": "" + } + "#, + expected: Ok(BybitResponse { + success: false, + ret_msg: BybitReturnMessage::None, + }), + }, + ]; + + for (index, test) in cases.into_iter().enumerate() { + let actual = serde_json::from_str::(test.input); + match (actual, test.expected) { + (Ok(actual), Ok(expected)) => { + assert_eq!(actual, expected, "TC{} failed", index) + } + (Err(_), Err(_)) => { + // Test passed + } + (actual, expected) => { + // Test failed + panic!("TC{index} failed because actual != expected. \nActual: {actual:?}\nExpected: {expected:?}\n"); + } + } + } + } + } + + #[test] + fn test_validate_bybit_sub_response() { + struct TestCase { + input_response: BybitResponse, + is_valid: bool, + } + + let cases = vec![ + TestCase { + // TC0: input response is successful subscription + input_response: BybitResponse { + success: true, + ret_msg: BybitReturnMessage::Subscribe, + }, + is_valid: true, + }, + TestCase { + // TC1: input response is successful subscription + input_response: BybitResponse { + success: true, + ret_msg: BybitReturnMessage::None, + }, + is_valid: true, + }, + TestCase { + // TC2: input response is failed subscription + input_response: BybitResponse { + success: false, + ret_msg: BybitReturnMessage::Pong, + }, + is_valid: false, + }, + ]; + + for (index, test) in cases.into_iter().enumerate() { + let actual = test.input_response.validate().is_ok(); + assert_eq!(actual, test.is_valid, "TestCase {} failed", index); + } + } +} diff --git a/barter-data/src/exchange/bybit/trade.rs b/barter-data/src/exchange/bybit/trade.rs new file mode 100644 index 00000000..737d9148 --- /dev/null +++ b/barter-data/src/exchange/bybit/trade.rs @@ -0,0 +1,333 @@ +use crate::{ + event::{MarketEvent, MarketIter}, + exchange::{bybit::message::BybitPayload, ExchangeId}, + subscription::trade::PublicTrade, +}; +use barter_integration::model::{Exchange, Side}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +/// Terse type alias for an [`BybitTrade`](BybitTradeInner) real-time trades WebSocket message. +pub type BybitTrade = BybitPayload>; + +/// ### Raw Payload Examples +/// See docs: +/// Spot Side::Buy Trade +///```json +/// { +/// "T": 1672304486865, +/// "s": "BTCUSDT", +/// "S": "Buy", +/// "v": "0.001", +/// "p": "16578.50", +/// "L": "PlusTick", +/// "i": "20f43950-d8dd-5b31-9112-a178eb6023af", +/// "BT": false +/// } +/// ``` +#[derive(Clone, PartialEq, PartialOrd, Debug, Deserialize, Serialize)] +pub struct BybitTradeInner { + #[serde( + alias = "T", + deserialize_with = "barter_integration::de::de_u64_epoch_ms_as_datetime_utc" + )] + pub time: DateTime, + + #[serde(rename = "s")] + pub market: String, + + #[serde(rename = "S")] + pub side: Side, + + #[serde(alias = "v", deserialize_with = "barter_integration::de::de_str")] + pub amount: f64, + + #[serde(alias = "p", deserialize_with = "barter_integration::de::de_str")] + pub price: f64, + + #[serde(rename = "i")] + pub id: String, +} + +impl From<(ExchangeId, InstrumentId, BybitTrade)> + for MarketIter +{ + fn from((exchange_id, instrument, trades): (ExchangeId, InstrumentId, BybitTrade)) -> Self { + Self( + trades + .data + .into_iter() + .map(|trade| { + Ok(MarketEvent { + exchange_time: trade.time, + received_time: Utc::now(), + exchange: Exchange::from(exchange_id), + instrument: instrument.clone(), + kind: PublicTrade { + id: trade.id, + price: trade.price, + amount: trade.amount, + side: trade.side, + }, + }) + }) + .collect(), + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + mod de { + use super::*; + use barter_integration::{ + de::datetime_utc_from_epoch_duration, error::SocketError, model::SubscriptionId, + }; + use std::time::Duration; + + #[test] + fn test_bybit_trade() { + struct TestCase { + input: &'static str, + expected: Result, + } + + let tests = vec![ + // TC0: input BybitTradeInner is deserialised + TestCase { + input: r#" + { + "T": 1672304486865, + "s": "BTCUSDT", + "S": "Buy", + "v": "0.001", + "p": "16578.50", + "L": "PlusTick", + "i": "20f43950-d8dd-5b31-9112-a178eb6023af", + "BT": false + } + "#, + expected: Ok(BybitTradeInner { + time: datetime_utc_from_epoch_duration(Duration::from_millis( + 1672304486865, + )), + market: "BTCUSDT".to_string(), + side: Side::Buy, + amount: 0.001, + price: 16578.50, + id: "20f43950-d8dd-5b31-9112-a178eb6023af".to_string(), + }), + }, + // TC1: input BybitTradeInner is deserialised + TestCase { + input: r#" + { + "T": 1672304486865, + "s": "BTCUSDT", + "S": "Sell", + "v": "0.001", + "p": "16578.50", + "L": "PlusTick", + "i": "20f43950-d8dd-5b31-9112-a178eb6023af", + "BT": false + } + "#, + expected: Ok(BybitTradeInner { + time: datetime_utc_from_epoch_duration(Duration::from_millis( + 1672304486865, + )), + market: "BTCUSDT".to_string(), + side: Side::Sell, + amount: 0.001, + price: 16578.50, + id: "20f43950-d8dd-5b31-9112-a178eb6023af".to_string(), + }), + }, + // TC2: input BybitTradeInner is unable to be deserialised + TestCase { + input: r#" + { + "T": 1672304486865, + "s": "BTCUSDT", + "S": "Unknown", + "v": "0.001", + "p": "16578.50", + "L": "PlusTick", + "i": "20f43950-d8dd-5b31-9112-a178eb6023af", + "BT": false + } + "#, + expected: Err(SocketError::Unsupported { + entity: "", + item: "".to_string(), + }), + }, + ]; + + for (index, test) in tests.into_iter().enumerate() { + let actual = serde_json::from_str::(test.input); + match (actual, test.expected) { + (Ok(actual), Ok(expected)) => { + assert_eq!(actual, expected, "TC{} failed", index) + } + (Err(_), Err(_)) => { + // Test passed + } + (actual, expected) => { + // Test failed + panic!("TC{index} failed because actual != expected. \nActual: {actual:?}\nExpected: {expected:?}\n"); + } + } + } + } + + #[test] + fn test_bybit_trade_payload() { + struct TestCase { + input: &'static str, + expected: Result, + } + + let tests = vec![ + // TC0: input BybitTrade is deserialised + TestCase { + input: r#" + { + "topic": "publicTrade.BTCUSDT", + "type": "snapshot", + "ts": 1672304486868, + "data": [ + { + "T": 1672304486865, + "s": "BTCUSDT", + "S": "Buy", + "v": "0.001", + "p": "16578.50", + "L": "PlusTick", + "i": "20f43950-d8dd-5b31-9112-a178eb6023af", + "BT": false + }, + { + "T": 1672304486865, + "s": "BTCUSDT", + "S": "Sell", + "v": "0.001", + "p": "16578.50", + "L": "PlusTick", + "i": "20f43950-d8dd-5b31-9112-a178eb6023af", + "BT": false + } + ] + } + "#, + expected: Ok(BybitTrade { + subscription_id: SubscriptionId("publicTrade|BTCUSDT".to_string()), + r#type: "snapshot".to_string(), + time: datetime_utc_from_epoch_duration(Duration::from_millis( + 1672304486868, + )), + data: vec![ + BybitTradeInner { + time: datetime_utc_from_epoch_duration(Duration::from_millis( + 1672304486865, + )), + market: "BTCUSDT".to_string(), + side: Side::Buy, + amount: 0.001, + price: 16578.50, + id: "20f43950-d8dd-5b31-9112-a178eb6023af".to_string(), + }, + BybitTradeInner { + time: datetime_utc_from_epoch_duration(Duration::from_millis( + 1672304486865, + )), + market: "BTCUSDT".to_string(), + side: Side::Sell, + amount: 0.001, + price: 16578.50, + id: "20f43950-d8dd-5b31-9112-a178eb6023af".to_string(), + }, + ], + }), + }, + // TC1: input BybitTrade is invalid w/ no subscription_id + TestCase { + input: r#" + { + "data": [ + { + "T": 1672304486865, + "s": "BTCUSDT", + "S": "Unknown", + "v": "0.001", + "p": "16578.50", + "L": "PlusTick", + "i": "20f43950-d8dd-5b31-9112-a178eb6023af", + "BT": false + } + ] + } + "#, + expected: Err(SocketError::Unsupported { + entity: "", + item: "".to_string(), + }), + }, + // TC1: input BybitTrade is invalid w/ invalid subscription_id format + TestCase { + input: r#" + { + "topic": "publicTrade.BTCUSDT.should_not_be_present", + "type": "snapshot", + "ts": 1672304486868, + "data": [ + { + "T": 1672304486865, + "s": "BTCUSDT", + "S": "Buy", + "v": "0.001", + "p": "16578.50", + "L": "PlusTick", + "i": "20f43950-d8dd-5b31-9112-a178eb6023af", + "BT": false + }, + { + "T": 1672304486865, + "s": "BTCUSDT", + "S": "Sell", + "v": "0.001", + "p": "16578.50", + "L": "PlusTick", + "i": "20f43950-d8dd-5b31-9112-a178eb6023af", + "BT": false + } + ] + } + "#, + expected: Err(SocketError::Unsupported { + entity: "", + item: "".to_string(), + }), + }, + ]; + + for (index, test) in tests.into_iter().enumerate() { + let actual = serde_json::from_str::(test.input); + match (actual, test.expected) { + (Ok(actual), Ok(expected)) => { + assert_eq!(actual, expected, "TC{} failed", index) + } + (Err(_), Err(_)) => { + // Test passed + } + (actual, expected) => { + // Test failed + panic!("TC{index} failed because actual != expected. \nActual: {actual:?}\nExpected: {expected:?}\n"); + } + } + } + } + } +} diff --git a/barter-data/src/exchange/coinbase/channel.rs b/barter-data/src/exchange/coinbase/channel.rs new file mode 100644 index 00000000..ffe922ce --- /dev/null +++ b/barter-data/src/exchange/coinbase/channel.rs @@ -0,0 +1,32 @@ +use super::Coinbase; +use crate::{ + subscription::{trade::PublicTrades, Subscription}, + Identifier, +}; +use serde::Serialize; + +/// Type that defines how to translate a Barter [`Subscription`] into a +/// [`Coinbase`] channel to be subscribed to. +/// +/// See docs: +#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Serialize)] +pub struct CoinbaseChannel(pub &'static str); + +impl CoinbaseChannel { + /// [`Coinbase`] real-time trades channel. + /// + /// See docs: + pub const TRADES: Self = Self("matches"); +} + +impl Identifier for Subscription { + fn id(&self) -> CoinbaseChannel { + CoinbaseChannel::TRADES + } +} + +impl AsRef for CoinbaseChannel { + fn as_ref(&self) -> &str { + self.0 + } +} diff --git a/barter-data/src/exchange/coinbase/market.rs b/barter-data/src/exchange/coinbase/market.rs new file mode 100644 index 00000000..5e9c129c --- /dev/null +++ b/barter-data/src/exchange/coinbase/market.rs @@ -0,0 +1,43 @@ +use super::Coinbase; +use crate::{ + instrument::{KeyedInstrument, MarketInstrumentData}, + subscription::Subscription, + Identifier, +}; +use barter_integration::model::instrument::{symbol::Symbol, Instrument}; +use serde::{Deserialize, Serialize}; + +/// Type that defines how to translate a Barter [`Subscription`] into a +/// [`Coinbase`](super::Coinbase) market that can be subscribed to. +/// +/// See docs: +#[derive(Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Deserialize, Serialize)] +pub struct CoinbaseMarket(pub String); + +impl Identifier for Subscription { + fn id(&self) -> CoinbaseMarket { + coinbase_market(&self.instrument.base, &self.instrument.quote) + } +} + +impl Identifier for Subscription { + fn id(&self) -> CoinbaseMarket { + coinbase_market(&self.instrument.data.base, &self.instrument.data.quote) + } +} + +impl Identifier for Subscription { + fn id(&self) -> CoinbaseMarket { + CoinbaseMarket(self.instrument.name_exchange.clone()) + } +} + +impl AsRef for CoinbaseMarket { + fn as_ref(&self) -> &str { + &self.0 + } +} + +fn coinbase_market(base: &Symbol, quote: &Symbol) -> CoinbaseMarket { + CoinbaseMarket(format!("{base}-{quote}").to_uppercase()) +} diff --git a/barter-data/src/exchange/coinbase/mod.rs b/barter-data/src/exchange/coinbase/mod.rs new file mode 100644 index 00000000..01cf183e --- /dev/null +++ b/barter-data/src/exchange/coinbase/mod.rs @@ -0,0 +1,81 @@ +use self::{ + channel::CoinbaseChannel, market::CoinbaseMarket, subscription::CoinbaseSubResponse, + trade::CoinbaseTrade, +}; +use crate::{ + exchange::{Connector, ExchangeId, ExchangeSub, StreamSelector}, + instrument::InstrumentData, + subscriber::{validator::WebSocketSubValidator, WebSocketSubscriber}, + subscription::trade::PublicTrades, + transformer::stateless::StatelessTransformer, + ExchangeWsStream, +}; +use barter_integration::{error::SocketError, protocol::websocket::WsMessage}; +use barter_macro::{DeExchange, SerExchange}; +use serde_json::json; +use url::Url; + +/// Defines the type that translates a Barter [`Subscription`](crate::subscription::Subscription) +/// into an exchange [`Connector`] specific channel used for generating [`Connector::requests`]. +pub mod channel; + +/// Defines the type that translates a Barter [`Subscription`](crate::subscription::Subscription) +/// into an exchange [`Connector`] specific market used for generating [`Connector::requests`]. +pub mod market; + +/// [`Subscription`](crate::subscription::Subscription) response type and response +/// [`Validator`](barter_integration::Validator) for [`Coinbase`]. +pub mod subscription; + +/// Public trade types for [`Coinbase`]. +pub mod trade; + +/// [`Coinbase`] server base url. +/// +/// See docs: +pub const BASE_URL_COINBASE: &str = "wss://ws-feed.exchange.coinbase.com"; + +/// [`Coinbase`] exchange. +/// +/// See docs: +#[derive( + Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Default, DeExchange, SerExchange, +)] +pub struct Coinbase; + +impl Connector for Coinbase { + const ID: ExchangeId = ExchangeId::Coinbase; + type Channel = CoinbaseChannel; + type Market = CoinbaseMarket; + type Subscriber = WebSocketSubscriber; + type SubValidator = WebSocketSubValidator; + type SubResponse = CoinbaseSubResponse; + + fn url() -> Result { + Url::parse(BASE_URL_COINBASE).map_err(SocketError::UrlParse) + } + + fn requests(exchange_subs: Vec>) -> Vec { + exchange_subs + .into_iter() + .map(|ExchangeSub { channel, market }| { + WsMessage::Text( + json!({ + "type": "subscribe", + "product_ids": [market.as_ref()], + "channels": [channel.as_ref()], + }) + .to_string(), + ) + }) + .collect() + } +} + +impl StreamSelector for Coinbase +where + Instrument: InstrumentData, +{ + type Stream = + ExchangeWsStream>; +} diff --git a/barter-data/src/exchange/coinbase/subscription.rs b/barter-data/src/exchange/coinbase/subscription.rs new file mode 100644 index 00000000..a2c35605 --- /dev/null +++ b/barter-data/src/exchange/coinbase/subscription.rs @@ -0,0 +1,163 @@ +use barter_integration::{error::SocketError, Validator}; +use serde::{Deserialize, Serialize}; + +/// [`Coinbase`](super::Coinbase) WebSocket subscription response. +/// +/// ### Raw Payload Examples +/// See docs: +/// #### Subscripion Success +/// ```json +/// { +/// "type":"subscriptions", +/// "channels":[ +/// {"name":"matches","product_ids":["BTC-USD", "ETH-USD"]} +/// ] +/// } +/// ``` +/// +/// #### Subscription Failure +/// ```json +/// { +/// "type":"error", +/// "message":"Failed to subscribe", +/// "reason":"GIBBERISH-USD is not a valid product" +/// } +/// ``` +#[derive(Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Deserialize, Serialize)] +#[serde(tag = "type", rename_all = "lowercase")] +pub enum CoinbaseSubResponse { + #[serde(alias = "subscriptions")] + Subscribed { + channels: Vec, + }, + Error { + reason: String, + }, +} + +/// Communicates the [`Coinbase`](super::Coinbase) product_ids (eg/ "ETH-USD") associated with +/// a successful channel (eg/ "matches") subscription. +/// +/// See [`CoinbaseSubResponse`] for full raw paylaod examples. +/// +/// See docs: +#[derive(Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Deserialize, Serialize)] +pub struct CoinbaseChannels { + #[serde(alias = "name")] + pub channel: String, + pub product_ids: Vec, +} + +impl Validator for CoinbaseSubResponse { + fn validate(self) -> Result + where + Self: Sized, + { + match &self { + CoinbaseSubResponse::Subscribed { .. } => Ok(self), + CoinbaseSubResponse::Error { reason } => Err(SocketError::Subscribe(format!( + "received failure subscription response: {}", + reason + ))), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + mod de { + use super::*; + + #[test] + fn test_coinbase_sub_response() { + struct TestCase { + input: &'static str, + expected: Result, + } + + let cases = vec![ + TestCase { + // TC0: input response is Subscribed + input: r#" + { + "type":"subscriptions", + "channels":[ + {"name":"matches","product_ids":["BTC-USD", "ETH-USD"]} + ] + } + "#, + expected: Ok(CoinbaseSubResponse::Subscribed { + channels: vec![CoinbaseChannels { + channel: "matches".to_string(), + product_ids: vec!["BTC-USD".to_string(), "ETH-USD".to_string()], + }], + }), + }, + TestCase { + // TC1: input response is failed subscription + input: r#" + { + "type":"error", + "message":"Failed to subscribe", + "reason":"GIBBERISH-USD is not a valid product" + } + "#, + expected: Ok(CoinbaseSubResponse::Error { + reason: "GIBBERISH-USD is not a valid product".to_string(), + }), + }, + ]; + + for (index, test) in cases.into_iter().enumerate() { + let actual = serde_json::from_str::(test.input); + match (actual, test.expected) { + (Ok(actual), Ok(expected)) => { + assert_eq!(actual, expected, "TC{} failed", index) + } + (Err(_), Err(_)) => { + // Test passed + } + (actual, expected) => { + // Test failed + panic!("TC{index} failed because actual != expected. \nActual: {actual:?}\nExpected: {expected:?}\n"); + } + } + } + } + } + + #[test] + fn test_validate_coinbase_sub_response() { + struct TestCase { + input_response: CoinbaseSubResponse, + is_valid: bool, + } + + let cases = vec![ + TestCase { + // TC0: input response is successful subscription + input_response: CoinbaseSubResponse::Subscribed { + channels: vec![CoinbaseChannels { + channel: "matches".to_string(), + product_ids: vec!["BTC-USD".to_string(), "ETH-USD".to_string()], + }], + }, + is_valid: true, + }, + TestCase { + // TC1: input response is failed subscription + input_response: CoinbaseSubResponse::Error { + reason: "GIBBERISH-USD is not a valid product".to_string(), + }, + is_valid: false, + }, + ]; + + for (index, test) in cases.into_iter().enumerate() { + let actual = test.input_response.validate().is_ok(); + assert_eq!(actual, test.is_valid, "TestCase {} failed", index); + } + } +} diff --git a/barter-data/src/exchange/coinbase/trade.rs b/barter-data/src/exchange/coinbase/trade.rs new file mode 100644 index 00000000..c8be90b6 --- /dev/null +++ b/barter-data/src/exchange/coinbase/trade.rs @@ -0,0 +1,144 @@ +use super::CoinbaseChannel; +use crate::{ + event::{MarketEvent, MarketIter}, + exchange::{ExchangeId, ExchangeSub}, + subscription::trade::PublicTrade, + Identifier, +}; +use barter_integration::model::{Exchange, Side, SubscriptionId}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +/// Coinbase real-time trade WebSocket message. +/// +/// ### Raw Payload Examples +/// See docs: +/// ```json +/// { +/// "type": "match", +/// "trade_id": 10, +/// "sequence": 50, +/// "maker_order_id": "ac928c66-ca53-498f-9c13-a110027a60e8", +/// "taker_order_id": "132fb6ae-456b-4654-b4e0-d681ac05cea1", +/// "time": "2014-11-07T08:19:27.028459Z", +/// "product_id": "BTC-USD", +/// "size": "5.23512", +/// "price": +/// "400.23", +/// "side": "sell" +/// } +/// ``` +#[derive(Clone, PartialEq, PartialOrd, Debug, Deserialize, Serialize)] +pub struct CoinbaseTrade { + #[serde(alias = "product_id", deserialize_with = "de_trade_subscription_id")] + pub subscription_id: SubscriptionId, + #[serde(alias = "trade_id")] + pub id: u64, + pub time: DateTime, + #[serde(alias = "size", deserialize_with = "barter_integration::de::de_str")] + pub amount: f64, + #[serde(deserialize_with = "barter_integration::de::de_str")] + pub price: f64, + pub side: Side, +} + +impl Identifier> for CoinbaseTrade { + fn id(&self) -> Option { + Some(self.subscription_id.clone()) + } +} + +impl From<(ExchangeId, InstrumentId, CoinbaseTrade)> + for MarketIter +{ + fn from((exchange_id, instrument, trade): (ExchangeId, InstrumentId, CoinbaseTrade)) -> Self { + Self(vec![Ok(MarketEvent { + exchange_time: trade.time, + received_time: Utc::now(), + exchange: Exchange::from(exchange_id), + instrument, + kind: PublicTrade { + id: trade.id.to_string(), + price: trade.price, + amount: trade.amount, + side: trade.side, + }, + })]) + } +} + +/// Deserialize a [`CoinbaseTrade`] "product_id" (eg/ "BTC-USD") as the associated [`SubscriptionId`] +/// (eg/ SubscriptionId("matches|BTC-USD"). +pub fn de_trade_subscription_id<'de, D>(deserializer: D) -> Result +where + D: serde::de::Deserializer<'de>, +{ + <&str as Deserialize>::deserialize(deserializer) + .map(|product_id| ExchangeSub::from((CoinbaseChannel::TRADES, product_id)).id()) +} + +#[cfg(test)] +mod tests { + use super::*; + use barter_integration::error::SocketError; + use chrono::NaiveDateTime; + use serde::de::Error; + use std::str::FromStr; + + #[test] + fn test_de_coinbase_trade() { + struct TestCase { + input: &'static str, + expected: Result, + } + + let cases = vec![ + TestCase { + // TC0: invalid Coinbase message w/ unknown tag + input: r#"{"type": "unknown", "sequence": 50,"product_id": "BTC-USD"}"#, + expected: Err(SocketError::Deserialise { + error: serde_json::Error::custom(""), + payload: "".to_owned(), + }), + }, + TestCase { + // TC1: valid Spot CoinbaseTrade + input: r#" + { + "type": "match","trade_id": 10,"sequence": 50, + "maker_order_id": "ac928c66-ca53-498f-9c13-a110027a60e8", + "taker_order_id": "132fb6ae-456b-4654-b4e0-d681ac05cea1", + "time": "2014-11-07T08:19:27.028459Z", + "product_id": "BTC-USD", "size": "5.23512", "price": "400.23", "side": "sell" + }"#, + expected: Ok(CoinbaseTrade { + subscription_id: SubscriptionId::from("matches|BTC-USD"), + id: 10, + price: 400.23, + amount: 5.23512, + side: Side::Sell, + time: DateTime::from_utc( + NaiveDateTime::from_str("2014-11-07T08:19:27.028459").unwrap(), + Utc, + ), + }), + }, + ]; + + for (index, test) in cases.into_iter().enumerate() { + let actual = serde_json::from_str::(test.input); + match (actual, test.expected) { + (Ok(actual), Ok(expected)) => { + assert_eq!(actual, expected, "TC{} failed", index) + } + (Err(_), Err(_)) => { + // Test passed + } + (actual, expected) => { + // Test failed + panic!("TC{index} failed because actual != expected. \nActual: {actual:?}\nExpected: {expected:?}\n"); + } + } + } + } +} diff --git a/barter-data/src/exchange/gateio/channel.rs b/barter-data/src/exchange/gateio/channel.rs new file mode 100644 index 00000000..b91e4084 --- /dev/null +++ b/barter-data/src/exchange/gateio/channel.rs @@ -0,0 +1,52 @@ +use crate::{ + instrument::InstrumentData, + subscription::{trade::PublicTrades, Subscription}, + Identifier, +}; +use barter_integration::model::instrument::kind::InstrumentKind; +use serde::Serialize; + +/// Type that defines how to translate a Barter [`Subscription`] into a +/// [`Gateio`](super::Gateio) channel to be subscribed to. +/// +/// See docs: +#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Serialize)] +pub struct GateioChannel(pub &'static str); + +impl GateioChannel { + /// Gateio [`InstrumentKind::Spot`] real-time trades channel. + /// + /// See docs: + pub const SPOT_TRADES: Self = Self("spot.trades"); + + /// Gateio [`InstrumentKind::Future`] & [`InstrumentKind::Perpetual`] real-time trades channel. + /// + /// See docs: + /// See docs: + pub const FUTURE_TRADES: Self = Self("futures.trades"); + + /// Gateio [`InstrumentKind::Option`] real-time trades channel. + /// + /// See docs: + pub const OPTION_TRADES: Self = Self("options.trades"); +} + +impl Identifier + for Subscription +where + Instrument: InstrumentData, +{ + fn id(&self) -> GateioChannel { + match self.instrument.kind() { + InstrumentKind::Spot => GateioChannel::SPOT_TRADES, + InstrumentKind::Future(_) | InstrumentKind::Perpetual => GateioChannel::FUTURE_TRADES, + InstrumentKind::Option(_) => GateioChannel::OPTION_TRADES, + } + } +} + +impl AsRef for GateioChannel { + fn as_ref(&self) -> &str { + self.0 + } +} diff --git a/barter-data/src/exchange/gateio/future/mod.rs b/barter-data/src/exchange/gateio/future/mod.rs new file mode 100644 index 00000000..cb468402 --- /dev/null +++ b/barter-data/src/exchange/gateio/future/mod.rs @@ -0,0 +1,68 @@ +use crate::{ + exchange::{ + gateio::{perpetual::trade::GateioFuturesTrades, Gateio}, + ExchangeId, ExchangeServer, StreamSelector, + }, + instrument::InstrumentData, + subscription::trade::PublicTrades, + transformer::stateless::StatelessTransformer, + ExchangeWsStream, +}; + +/// [`GateioFuturesUsd`] WebSocket server base url. +/// +/// See docs: +pub const WEBSOCKET_BASE_URL_GATEIO_FUTURES_USD: &str = "wss://fx-ws.gateio.ws/v4/ws/delivery/usdt"; + +/// [`Gateio`] perpetual usd exchange. +pub type GateioFuturesUsd = Gateio; + +/// [`Gateio`] perpetual usd [`ExchangeServer`]. +#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Default)] +pub struct GateioServerFuturesUsd; + +impl ExchangeServer for GateioServerFuturesUsd { + const ID: ExchangeId = ExchangeId::GateioFuturesUsd; + + fn websocket_url() -> &'static str { + WEBSOCKET_BASE_URL_GATEIO_FUTURES_USD + } +} + +impl StreamSelector for GateioFuturesUsd +where + Instrument: InstrumentData, +{ + type Stream = ExchangeWsStream< + StatelessTransformer, + >; +} + +/// [`GateioFuturesBtc`] WebSocket server base url. +/// +/// See docs: +pub const WEBSOCKET_BASE_URL_GATEIO_FUTURES_BTC: &str = "wss://fx-ws.gateio.ws/v4/ws/delivery/btc"; + +/// [`Gateio`] perpetual btc exchange. +pub type GateioFuturesBtc = Gateio; + +/// [`Gateio`] perpetual btc [`ExchangeServer`]. +#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Default)] +pub struct GateioServerFuturesBtc; + +impl ExchangeServer for GateioServerFuturesBtc { + const ID: ExchangeId = ExchangeId::GateioFuturesBtc; + + fn websocket_url() -> &'static str { + WEBSOCKET_BASE_URL_GATEIO_FUTURES_BTC + } +} + +impl StreamSelector for GateioFuturesBtc +where + Instrument: InstrumentData, +{ + type Stream = ExchangeWsStream< + StatelessTransformer, + >; +} diff --git a/barter-data/src/exchange/gateio/market.rs b/barter-data/src/exchange/gateio/market.rs new file mode 100644 index 00000000..6385deef --- /dev/null +++ b/barter-data/src/exchange/gateio/market.rs @@ -0,0 +1,83 @@ +use super::Gateio; +use crate::{ + instrument::{KeyedInstrument, MarketInstrumentData}, + subscription::Subscription, + Identifier, +}; +use barter_integration::model::instrument::{ + kind::{InstrumentKind, OptionKind}, + Instrument, +}; +use chrono::{ + format::{DelayedFormat, StrftimeItems}, + DateTime, Utc, +}; +use serde::{Deserialize, Serialize}; + +/// Type that defines how to translate a Barter [`Subscription`] into a +/// [`Gateio`](super::Gateio) market that can be subscribed to. +/// +/// See docs: +#[derive(Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Deserialize, Serialize)] +pub struct GateioMarket(pub String); + +impl Identifier for Subscription, Instrument, Kind> { + fn id(&self) -> GateioMarket { + gateio_market(&self.instrument) + } +} + +impl Identifier + for Subscription, KeyedInstrument, Kind> +{ + fn id(&self) -> GateioMarket { + gateio_market(&self.instrument.data) + } +} + +impl Identifier + for Subscription, MarketInstrumentData, Kind> +{ + fn id(&self) -> GateioMarket { + GateioMarket(self.instrument.name_exchange.clone()) + } +} + +impl AsRef for GateioMarket { + fn as_ref(&self) -> &str { + &self.0 + } +} + +fn gateio_market(instrument: &Instrument) -> GateioMarket { + use InstrumentKind::*; + let Instrument { base, quote, kind } = instrument; + + GateioMarket( + match kind { + Spot | Perpetual => format!("{base}_{quote}"), + Future(future) => { + format!("{base}_{quote}_QUARTERLY_{}", format_expiry(future.expiry)) + } + Option(option) => format!( + "{base}_{quote}-{}-{}-{}", + format_expiry(option.expiry), + option.strike, + match option.kind { + OptionKind::Call => "C", + OptionKind::Put => "P", + }, + ), + } + .to_uppercase(), + ) +} + +/// Format the expiry DateTime to be Gateio API compatible. +/// +/// eg/ "20241231" (31st of December 2024) +/// +/// See docs: +fn format_expiry<'a>(expiry: DateTime) -> DelayedFormat> { + expiry.date_naive().format("%Y%m%d") +} diff --git a/barter-data/src/exchange/gateio/message.rs b/barter-data/src/exchange/gateio/message.rs new file mode 100644 index 00000000..cf5d7028 --- /dev/null +++ b/barter-data/src/exchange/gateio/message.rs @@ -0,0 +1,72 @@ +use serde::{Deserialize, Serialize}; + +/// [`Gateio`](super::Gateio) WebSocket message. +/// +/// ### Raw Payload Examples +/// #### Subscription Trades Success +/// See docs: +/// ```json +/// { +/// "time": 1606292218, +/// "time_ms": 1606292218231, +/// "channel": "spot.trades", +/// "event": "subscribe", +/// "result": { +/// "status": "success" +/// } +/// } +/// ``` +/// +/// #### Subscription Trades Failure +/// See docs: +/// ```json +/// { +/// "time": 1606292218, +/// "time_ms": 1606292218231, +/// "channel": "spot.trades", +/// "event": "subscribe", +/// "error":{ +/// "code":2, +/// "message":"unknown currency pair GIBBERISH_USD" +/// }, +/// "result": null, +/// } +/// ``` +/// +/// #### Spot Trade +/// See docs: +/// ```json +/// { +/// "time": 1606292218, +/// "time_ms": 1606292218231, +/// "channel": "spot.trades", +/// "event": "update", +/// "result": { +/// "id": 309143071, +/// "create_time": 1606292218, +/// "create_time_ms": "1606292218213.4578", +/// "side": "sell", +/// "currency_pair": "GT_USDT", +/// "amount": "16.4700000000", +/// "price": "0.4705000000" +/// } +/// } +/// ``` +/// +/// See docs: +#[derive(Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Deserialize, Serialize)] +pub struct GateioMessage { + pub channel: String, + pub error: Option, + #[serde(rename = "result")] + pub data: T, +} + +/// [`Gateio`](super::Gateio) WebSocket error message. +/// +/// See docs: +#[derive(Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Deserialize, Serialize)] +pub struct GateioError { + pub code: u8, + pub message: String, +} diff --git a/barter-data/src/exchange/gateio/mod.rs b/barter-data/src/exchange/gateio/mod.rs new file mode 100644 index 00000000..05b1915e --- /dev/null +++ b/barter-data/src/exchange/gateio/mod.rs @@ -0,0 +1,125 @@ +use self::{channel::GateioChannel, market::GateioMarket, subscription::GateioSubResponse}; +use crate::{ + exchange::{subscription::ExchangeSub, Connector, ExchangeId, ExchangeServer}, + subscriber::{validator::WebSocketSubValidator, WebSocketSubscriber}, +}; +use barter_integration::{error::SocketError, protocol::websocket::WsMessage}; +use serde_json::json; +use std::{fmt::Debug, marker::PhantomData}; +use url::Url; + +/// Defines the type that translates a Barter [`Subscription`](crate::subscription::Subscription) +/// into an exchange [`Connector`] specific channel used for generating [`Connector::requests`]. +pub mod channel; + +/// [`ExchangeServer`] and [`StreamSelector`](super::StreamSelector) implementations for +/// [`GateioSpot`](spot::GateioSpot). +pub mod spot; + +/// [`ExchangeServer`] and [`StreamSelector`](super::StreamSelector) implementations for +/// [`GateioFutureUsd`](perpetual::GateioFutureUsd) and +/// [`GateioFutureBtc`](perpetual::GateioFutureBtc). +pub mod future; + +/// [`ExchangeServer`] and [`StreamSelector`](super::StreamSelector) implementations for +/// [`GateioPerpetualUsdt`](perpetual::GateioPerpetualsUsd) and +/// [`GateioPerpetualBtc`](perpetual::GateioPerpetualsBtc). +pub mod perpetual; + +/// [`ExchangeServer`] and [`StreamSelector`](super::StreamSelector) implementations for +/// [`GateioOptions`](option::GateioOptions) +pub mod option; + +/// Defines the type that translates a Barter [`Subscription`](crate::subscription::Subscription) +/// into an exchange [`Connector`] specific market used for generating [`Connector::requests`]. +pub mod market; + +/// Generic [`GateioMessage`](message::GateioMessage) type common to +/// [`GateioSpot`](spot::GateioSpot), [`GateioPerpetualUsdt`](perpetual::GateioPerpetualsUsd) +/// and [`GateioPerpetualBtc`](perpetual::GateioPerpetualsBtc). +pub mod message; + +/// [`Subscription`](crate::subscription::Subscription) response type and response +/// [`Validator`](barter_integration) common to [`GateioSpot`](spot::GateioSpot), +/// [`GateioPerpetualUsdt`](perpetual::GateioPerpetualsUsd) and +/// [`GateioPerpetualBtc`](perpetual::GateioPerpetualsBtc). +pub mod subscription; + +/// Generic [`Gateio`](Gateio) exchange. +/// +/// ### Notes +/// A `Server` [`ExchangeServer`](super::ExchangeServer) implementations exists for +/// [`GateioSpot`](spot::GateioSpot), [`GateioPerpetualUsdt`](perpetual::GateioPerpetualsUsd) and +/// [`GateioPerpetualBtc`](perpetual::GateioPerpetualsBtc). +#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Default)] +pub struct Gateio { + server: PhantomData, +} + +impl Connector for Gateio +where + Server: ExchangeServer, +{ + const ID: ExchangeId = Server::ID; + type Channel = GateioChannel; + type Market = GateioMarket; + type Subscriber = WebSocketSubscriber; + type SubValidator = WebSocketSubValidator; + type SubResponse = GateioSubResponse; + + fn url() -> Result { + Url::parse(Server::websocket_url()).map_err(SocketError::UrlParse) + } + + fn requests(exchange_subs: Vec>) -> Vec { + exchange_subs + .into_iter() + .map(|ExchangeSub { channel, market }| { + WsMessage::Text( + json!({ + "time": chrono::Utc::now().timestamp_millis(), + "channel": channel.as_ref(), + "event": "subscribe", + "payload": [market.as_ref()] + }) + .to_string(), + ) + }) + .collect() + } +} + +impl<'de, Server> serde::Deserialize<'de> for Gateio +where + Server: ExchangeServer, +{ + fn deserialize(deserializer: D) -> Result + where + D: serde::de::Deserializer<'de>, + { + let input = ::deserialize(deserializer)?; + let expected = Self::ID.as_str(); + + if input.as_str() == Self::ID.as_str() { + Ok(Self::default()) + } else { + Err(serde::de::Error::invalid_value( + serde::de::Unexpected::Str(input.as_str()), + &expected, + )) + } + } +} + +impl serde::Serialize for Gateio +where + Server: ExchangeServer, +{ + fn serialize(&self, serializer: S) -> Result + where + S: serde::ser::Serializer, + { + let exchange_id = Self::ID.as_str(); + serializer.serialize_str(exchange_id) + } +} diff --git a/barter-data/src/exchange/gateio/option/mod.rs b/barter-data/src/exchange/gateio/option/mod.rs new file mode 100644 index 00000000..71428ac2 --- /dev/null +++ b/barter-data/src/exchange/gateio/option/mod.rs @@ -0,0 +1,39 @@ +use crate::{ + exchange::{ + gateio::{perpetual::trade::GateioFuturesTrades, Gateio}, + ExchangeId, ExchangeServer, StreamSelector, + }, + instrument::InstrumentData, + subscription::trade::PublicTrades, + transformer::stateless::StatelessTransformer, + ExchangeWsStream, +}; + +/// [`GateioOptions`] WebSocket server base url. +/// +/// See docs: +pub const WEBSOCKET_BASE_URL_GATEIO_OPTIONS_USD: &str = "wss://op-ws.gateio.live/v4/ws"; + +/// [`Gateio`] options exchange. +pub type GateioOptions = Gateio; + +/// [`Gateio`] options [`ExchangeServer`]. +#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Default)] +pub struct GateioServerOptions; + +impl ExchangeServer for GateioServerOptions { + const ID: ExchangeId = ExchangeId::GateioOptions; + + fn websocket_url() -> &'static str { + WEBSOCKET_BASE_URL_GATEIO_OPTIONS_USD + } +} + +impl StreamSelector for GateioOptions +where + Instrument: InstrumentData, +{ + type Stream = ExchangeWsStream< + StatelessTransformer, + >; +} diff --git a/barter-data/src/exchange/gateio/perpetual/mod.rs b/barter-data/src/exchange/gateio/perpetual/mod.rs new file mode 100644 index 00000000..ba6b0445 --- /dev/null +++ b/barter-data/src/exchange/gateio/perpetual/mod.rs @@ -0,0 +1,70 @@ +use self::trade::GateioFuturesTrades; +use super::Gateio; +use crate::{ + exchange::{ExchangeId, ExchangeServer, StreamSelector}, + instrument::InstrumentData, + subscription::trade::PublicTrades, + transformer::stateless::StatelessTransformer, + ExchangeWsStream, +}; + +/// Public trades types. +pub mod trade; + +/// [`GateioPerpetualsUsd`] WebSocket server base url. +/// +/// See docs: +pub const WEBSOCKET_BASE_URL_GATEIO_PERPETUALS_USD: &str = "wss://fx-ws.gateio.ws/v4/ws/usdt"; + +/// [`Gateio`] perpetual usd exchange. +pub type GateioPerpetualsUsd = Gateio; + +/// [`Gateio`] perpetual usd [`ExchangeServer`]. +#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Default)] +pub struct GateioServerPerpetualsUsd; + +impl ExchangeServer for GateioServerPerpetualsUsd { + const ID: ExchangeId = ExchangeId::GateioPerpetualsUsd; + + fn websocket_url() -> &'static str { + WEBSOCKET_BASE_URL_GATEIO_PERPETUALS_USD + } +} + +impl StreamSelector for GateioPerpetualsUsd +where + Instrument: InstrumentData, +{ + type Stream = ExchangeWsStream< + StatelessTransformer, + >; +} + +/// [`GateioPerpetualsBtc`] WebSocket server base url. +/// +/// See docs: +pub const WEBSOCKET_BASE_URL_GATEIO_PERPETUALS_BTC: &str = "wss://fx-ws.gateio.ws/v4/ws/btc"; + +/// [`Gateio`] perpetual btc exchange. +pub type GateioPerpetualsBtc = Gateio; + +/// [`Gateio`] perpetual btc [`ExchangeServer`]. +#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Default)] +pub struct GateioServerPerpetualsBtc; + +impl ExchangeServer for GateioServerPerpetualsBtc { + const ID: ExchangeId = ExchangeId::GateioPerpetualsBtc; + + fn websocket_url() -> &'static str { + WEBSOCKET_BASE_URL_GATEIO_PERPETUALS_BTC + } +} + +impl StreamSelector for GateioPerpetualsBtc +where + Instrument: InstrumentData, +{ + type Stream = ExchangeWsStream< + StatelessTransformer, + >; +} diff --git a/barter-data/src/exchange/gateio/perpetual/trade.rs b/barter-data/src/exchange/gateio/perpetual/trade.rs new file mode 100644 index 00000000..49b58acb --- /dev/null +++ b/barter-data/src/exchange/gateio/perpetual/trade.rs @@ -0,0 +1,140 @@ +use super::super::message::GateioMessage; +use crate::{ + event::{MarketEvent, MarketIter}, + exchange::{ExchangeId, ExchangeSub}, + subscription::trade::PublicTrade, + Identifier, +}; +use barter_integration::model::{Exchange, Side, SubscriptionId}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +/// Terse type alias for a +/// [`GateioFuturesUsdt`](super::super::futures::GateioFuturesUsdt), +/// [`GateioFuturesBtc`](super::super::futures::GateioFuturesBtc), +/// [`GateioPerpetualUsdt`](super::GateioPerpetualsUsd) and +/// [`GateioPerpetualBtc`](super::GateioPerpetualsBtc) real-time trades WebSocket message. +pub type GateioFuturesTrades = GateioMessage>; + +/// [`GateioFuturesUsdt`](super::super::futures::GateioFuturesUsdt), +/// [`GateioFuturesBtc`](super::super::futures::GateioFuturesBtc), +/// [`GateioPerpetualUsdt`](super::GateioPerpetualsUsd) and +/// [`GateioPerpetualBtc`](super::GateioPerpetualsBtc) real-time trade WebSocket message. +/// +/// ### Raw Payload Examples +/// #### Future Sell Trade +/// See docs: +/// ```json +/// { +/// "id": 27753479, +/// "create_time": 1545136464, +/// "create_time_ms": 1545136464123, +/// "price": "96.4", +/// "size": -108, +/// "contract": "ETH_USDT_QUARTERLY_20201225" +/// } +/// ``` +/// +/// #### Future Perpetual Sell Trade +/// See docs: +/// ```json +/// { +/// "id": 27753479, +/// "create_time": 1545136464, +/// "create_time_ms": 1545136464123, +/// "price": "96.4", +/// "size": -108, +/// "contract": "BTC_USD" +/// } +/// ``` +#[derive(Clone, PartialEq, PartialOrd, Debug, Deserialize, Serialize)] +pub struct GateioFuturesTradeInner { + #[serde(rename = "contract")] + pub market: String, + #[serde( + rename = "create_time_ms", + deserialize_with = "barter_integration::de::de_u64_epoch_ms_as_datetime_utc" + )] + pub time: DateTime, + pub id: u64, + #[serde(deserialize_with = "barter_integration::de::de_str")] + pub price: f64, + #[serde(rename = "size")] + pub amount: f64, +} + +impl Identifier> for GateioFuturesTrades { + fn id(&self) -> Option { + self.data + .first() + .map(|trade| ExchangeSub::from((&self.channel, &trade.market)).id()) + } +} + +impl From<(ExchangeId, InstrumentId, GateioFuturesTrades)> + for MarketIter +{ + fn from( + (exchange_id, instrument, trades): (ExchangeId, InstrumentId, GateioFuturesTrades), + ) -> Self { + trades + .data + .into_iter() + .map(|trade| { + Ok(MarketEvent { + exchange_time: trade.time, + received_time: Utc::now(), + exchange: Exchange::from(exchange_id), + instrument: instrument.clone(), + kind: PublicTrade { + id: trade.id.to_string(), + price: trade.price, + amount: trade.amount, + side: if trade.amount.is_sign_positive() { + Side::Buy + } else { + Side::Sell + }, + }, + }) + }) + .collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + mod de { + use super::*; + + #[test] + fn test_gateio_message_perpetual_trade() { + let input = "{\"time\":1669843487,\"time_ms\":1669843487733,\"channel\":\"perpetual.trades\",\"event\":\"update\",\"result\":[{\"contract\":\"ETH_USDT\",\"create_time\":1669843487,\"create_time_ms\":1669843487724,\"id\":180276616,\"price\":\"1287\",\"size\":3}]}"; + serde_json::from_str::(input).unwrap(); + } + + #[test] + fn test_gateio_message_futures_trade() { + let input = r#" + { + "channel": "futures.trades", + "event": "update", + "time": 1541503698, + "result": [ + { + "size": -108, + "id": 27753479, + "create_time": 1545136464, + "create_time_ms": 1545136464123, + "price": "96.4", + "contract": "ETH_USDT_QUARTERLY_20201225" + } + ] + }"#; + + serde_json::from_str::(input).unwrap(); + } + } +} diff --git a/barter-data/src/exchange/gateio/spot/mod.rs b/barter-data/src/exchange/gateio/spot/mod.rs new file mode 100644 index 00000000..147e496c --- /dev/null +++ b/barter-data/src/exchange/gateio/spot/mod.rs @@ -0,0 +1,43 @@ +use self::trade::GateioSpotTrade; +use super::Gateio; +use crate::{ + exchange::{ExchangeId, ExchangeServer, StreamSelector}, + instrument::InstrumentData, + subscription::trade::PublicTrades, + transformer::stateless::StatelessTransformer, + ExchangeWsStream, +}; +use barter_macro::{DeExchange, SerExchange}; + +/// Public trades types. +pub mod trade; + +/// [`GateioSpot`] WebSocket server base url. +/// +/// See docs: +pub const WEBSOCKET_BASE_URL_GATEIO_SPOT: &str = "wss://api.gateio.ws/ws/v4/"; + +/// [`Gateio`] spot exchange. +pub type GateioSpot = Gateio; + +/// [`Gateio`] spot [`ExchangeServer`]. +#[derive( + Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Default, DeExchange, SerExchange, +)] +pub struct GateioServerSpot; + +impl ExchangeServer for GateioServerSpot { + const ID: ExchangeId = ExchangeId::GateioSpot; + + fn websocket_url() -> &'static str { + WEBSOCKET_BASE_URL_GATEIO_SPOT + } +} + +impl StreamSelector for GateioSpot +where + Instrument: InstrumentData, +{ + type Stream = + ExchangeWsStream>; +} diff --git a/barter-data/src/exchange/gateio/spot/trade.rs b/barter-data/src/exchange/gateio/spot/trade.rs new file mode 100644 index 00000000..326ad2aa --- /dev/null +++ b/barter-data/src/exchange/gateio/spot/trade.rs @@ -0,0 +1,104 @@ +use super::super::message::GateioMessage; +use crate::{ + event::{MarketEvent, MarketIter}, + exchange::{ExchangeId, ExchangeSub}, + subscription::trade::PublicTrade, + Identifier, +}; +use barter_integration::model::{Exchange, Side, SubscriptionId}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +/// Terse type alias for an [`GateioSpot`](super::GateioSpot) real-time trades WebSocket message. +pub type GateioSpotTrade = GateioMessage; + +/// [`GateioSpot`](super::GateioSpot) real-time trade WebSocket message. +/// +/// ### Raw Payload Examples +/// See docs: +/// ```json +/// { +/// "id": 309143071, +/// "create_time": 1606292218, +/// "create_time_ms": "1606292218213.4578", +/// "side": "sell", +/// "currency_pair": "GT_USDT", +/// "amount": "16.4700000000", +/// "price": "0.4705000000" +/// } +/// ``` +#[derive(Clone, PartialEq, PartialOrd, Debug, Deserialize, Serialize)] +pub struct GateioSpotTradeInner { + #[serde(rename = "currency_pair")] + pub market: String, + #[serde( + rename = "create_time_ms", + deserialize_with = "barter_integration::de::de_str_f64_epoch_ms_as_datetime_utc" + )] + pub time: DateTime, + pub id: u64, + #[serde(deserialize_with = "barter_integration::de::de_str")] + pub price: f64, + + #[serde(alias = "size", deserialize_with = "barter_integration::de::de_str")] + pub amount: f64, + + /// Taker [`Side`] of the trade. + pub side: Side, +} + +impl Identifier> for GateioSpotTrade { + fn id(&self) -> Option { + Some(ExchangeSub::from((&self.channel, &self.data.market)).id()) + } +} + +impl From<(ExchangeId, InstrumentId, GateioSpotTrade)> + for MarketIter +{ + fn from((exchange_id, instrument, trade): (ExchangeId, InstrumentId, GateioSpotTrade)) -> Self { + Self(vec![Ok(MarketEvent { + exchange_time: trade.data.time, + received_time: Utc::now(), + exchange: Exchange::from(exchange_id), + instrument, + kind: PublicTrade { + id: trade.data.id.to_string(), + price: trade.data.price, + amount: trade.data.amount, + side: trade.data.side, + }, + })]) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + mod de { + use super::*; + + #[test] + fn test_gateio_message_futures_trade() { + let input = r#" + { + "time": 1606292218, + "time_ms": 1606292218231, + "channel": "spot.trades", + "event": "update", + "result": { + "id": 309143071, + "create_time": 1606292218, + "create_time_ms": "1606292218213.4578", + "side": "sell", + "currency_pair": "GT_USDT", + "amount": "16.4700000000", + "price": "0.4705000000" + } + } + "#; + serde_json::from_str::(input).unwrap(); + } + } +} diff --git a/barter-data/src/exchange/gateio/subscription.rs b/barter-data/src/exchange/gateio/subscription.rs new file mode 100644 index 00000000..a0e38c43 --- /dev/null +++ b/barter-data/src/exchange/gateio/subscription.rs @@ -0,0 +1,130 @@ +use super::message::GateioMessage; +use barter_integration::{error::SocketError, Validator}; +use serde::{Deserialize, Serialize}; + +/// Expected [`Gateio`](super::Gateio) [`Subscription`](crate::subscription::Subscription) response +/// type wrapped in the generic [`GateioMessage`](GateioMessage). +pub type GateioSubResponse = GateioMessage; + +/// Expected [`Gateio`](super::Gateio) [`Subscription`](crate::subscription::Subscription) +/// response type. +/// +/// See [`GateioMessage`](super::message::GateioMessage) for full raw payload examples. +/// +/// See docs: +#[derive(Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Deserialize, Serialize)] +pub struct GateioSubResult { + pub status: String, +} + +impl Validator for GateioSubResponse { + fn validate(self) -> Result + where + Self: Sized, + { + match &self.error { + None => Ok(self), + Some(failure) => Err(SocketError::Subscribe(format!( + "received failure subscription response code: {} with message: {}", + failure.code, failure.message, + ))), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::exchange::gateio::message::GateioError; + + mod de { + use super::*; + + #[test] + fn test_gateio_sub_response() { + struct TestCase { + input: &'static str, + expected: Result, + } + + let tests = vec![TestCase { + // TC0: input response is Subscribed + input: r#" + { + "time": 1606292218, + "time_ms": 1606292218231, + "channel": "spot.trades", + "event": "subscribe", + "result": { + "status": "success" + } + } + "#, + expected: Ok(GateioSubResponse { + channel: "spot.trades".to_string(), + error: None, + data: GateioSubResult { + status: "success".to_string(), + }, + }), + }]; + + for (index, test) in tests.into_iter().enumerate() { + let actual = serde_json::from_str::(test.input); + match (actual, test.expected) { + (Ok(actual), Ok(expected)) => { + assert_eq!(actual, expected, "TC{} failed", index) + } + (Err(_), Err(_)) => { + // Test passed + } + (actual, expected) => { + // Test failed + panic!("TC{index} failed because actual != expected. \nActual: {actual:?}\nExpected: {expected:?}\n"); + } + } + } + } + } + + #[test] + fn test_validate_gateio_sub_response() { + struct TestCase { + input_response: GateioSubResponse, + is_valid: bool, + } + + let cases = vec![ + TestCase { + // TC0: input response is successful subscription + input_response: GateioSubResponse { + channel: "spot.trades".to_string(), + error: None, + data: GateioSubResult { + status: "success".to_string(), + }, + }, + is_valid: true, + }, + TestCase { + // TC1: input response is failed subscription + input_response: GateioSubResponse { + channel: "spot.trades".to_string(), + error: Some(GateioError { + code: 0, + message: "".to_string(), + }), + data: GateioSubResult { + status: "not used".to_string(), + }, + }, + is_valid: false, + }, + ]; + + for (index, test) in cases.into_iter().enumerate() { + let actual = test.input_response.validate().is_ok(); + assert_eq!(actual, test.is_valid, "TestCase {} failed", index); + } + } +} diff --git a/barter-data/src/exchange/kraken/book/l1.rs b/barter-data/src/exchange/kraken/book/l1.rs new file mode 100644 index 00000000..f0dfcd8f --- /dev/null +++ b/barter-data/src/exchange/kraken/book/l1.rs @@ -0,0 +1,197 @@ +use super::super::KrakenMessage; +use crate::{ + event::{MarketEvent, MarketIter}, + exchange::{kraken::channel::KrakenChannel, subscription::ExchangeSub, ExchangeId}, + subscription::book::{Level, OrderBookL1}, + Identifier, +}; +use barter_integration::{ + de::extract_next, + model::{Exchange, SubscriptionId}, +}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +/// Terse type alias for an [`Kraken`](super::super::Kraken) real-time OrderBook Level1 +/// (top of book) WebSocket message. +pub type KrakenOrderBookL1 = KrakenMessage; + +/// [`Kraken`](super::super::Kraken) real-time OrderBook Level1 (top of book) data and the +/// associated [`SubscriptionId`]. +/// +/// See [`KrakenMessage`](super::super::message::KrakenMessage) for full raw payload examples. +/// +/// See docs: +#[derive(Clone, PartialEq, PartialOrd, Debug, Serialize)] +pub struct KrakenOrderBookL1Inner { + pub subscription_id: SubscriptionId, + pub spread: KrakenSpread, +} + +/// [`Kraken`](super::super::Kraken) best bid and ask. +/// +/// See [`KrakenMessage`](super::super::message::KrakenMessage) for full raw payload examples. +/// +/// See docs: +#[derive(Clone, Copy, PartialEq, PartialOrd, Debug, Deserialize, Serialize)] +pub struct KrakenSpread { + #[serde(deserialize_with = "barter_integration::de::de_str")] + pub best_bid_price: f64, + #[serde(deserialize_with = "barter_integration::de::de_str")] + pub best_ask_price: f64, + #[serde(deserialize_with = "barter_integration::de::de_str_f64_epoch_s_as_datetime_utc")] + pub time: DateTime, + #[serde(deserialize_with = "barter_integration::de::de_str")] + pub best_bid_amount: f64, + #[serde(deserialize_with = "barter_integration::de::de_str")] + pub best_ask_amount: f64, +} + +impl Identifier> for KrakenOrderBookL1Inner { + fn id(&self) -> Option { + Some(self.subscription_id.clone()) + } +} + +impl From<(ExchangeId, InstrumentId, KrakenOrderBookL1)> + for MarketIter +{ + fn from( + (exchange_id, instrument, book): (ExchangeId, InstrumentId, KrakenOrderBookL1), + ) -> Self { + match book { + KrakenOrderBookL1::Data(book) => Self(vec![Ok(MarketEvent { + exchange_time: book.spread.time, + received_time: Utc::now(), + exchange: Exchange::from(exchange_id), + instrument, + kind: OrderBookL1 { + last_update_time: book.spread.time, + best_bid: Level::new(book.spread.best_bid_price, book.spread.best_bid_amount), + best_ask: Level::new(book.spread.best_ask_price, book.spread.best_ask_amount), + }, + })]), + KrakenOrderBookL1::Event(_) => MarketIter(vec![]), + } + } +} + +impl<'de> serde::de::Deserialize<'de> for KrakenOrderBookL1Inner { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + struct SeqVisitor; + + impl<'de> serde::de::Visitor<'de> for SeqVisitor { + type Value = KrakenOrderBookL1Inner; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("KrakenOrderBookL1Inner struct from the Kraken WebSocket API") + } + + fn visit_seq( + self, + mut seq: SeqAccessor, + ) -> Result + where + SeqAccessor: serde::de::SeqAccess<'de>, + { + // KrakenOrderBookL1Inner Sequence Format: + // [channelID, [bid, ask, timestamp, bidVolume, askVolume], channelName, pair] + // + + // Extract deprecated channelID & ignore + let _: serde::de::IgnoredAny = extract_next(&mut seq, "channelID")?; + + // Extract spread + let spread = extract_next(&mut seq, "spread")?; + + // Extract channelName (eg/ "spread") & ignore + let _: serde::de::IgnoredAny = extract_next(&mut seq, "channelName")?; + + // Extract pair (eg/ "XBT/USD") & map to SubscriptionId (ie/ "spread|{pair}") + let subscription_id = extract_next::(&mut seq, "pair") + .map(|market| ExchangeSub::from((KrakenChannel::ORDER_BOOK_L1, market)).id())?; + + // Ignore any additional elements or SerDe will fail + // '--> Exchange may add fields without warning + while seq.next_element::()?.is_some() {} + + Ok(KrakenOrderBookL1Inner { + subscription_id, + spread, + }) + } + } + + // Use Visitor implementation to deserialize the KrakenOrderBookL1Inner + deserializer.deserialize_seq(SeqVisitor) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + mod de { + use super::*; + use barter_integration::{ + de::datetime_utc_from_epoch_duration, error::SocketError, model::SubscriptionId, + }; + + #[test] + fn test_kraken_message_order_book_l1() { + struct TestCase { + input: &'static str, + expected: Result, + } + + let tests = vec![TestCase { + // TC0: valid KrakenOrderBookL1::Data(KrakenOrderBookL1Inner) + input: r#" + [ + 0, + [ + "5698.40000", + "5700.00000", + "1542057299.545897", + "1.01234567", + "0.98765432" + ], + "spread", + "XBT/USD" + ] + "#, + expected: Ok(KrakenOrderBookL1::Data(KrakenOrderBookL1Inner { + subscription_id: SubscriptionId::from("spread|XBT/USD"), + spread: KrakenSpread { + best_bid_price: 5698.4, + best_bid_amount: 1.01234567, + time: datetime_utc_from_epoch_duration(std::time::Duration::from_secs_f64( + 1542057299.545897, + )), + best_ask_price: 5700.0, + best_ask_amount: 0.98765432, + }, + })), + }]; + + for (index, test) in tests.into_iter().enumerate() { + let actual = serde_json::from_str::(test.input); + match (actual, test.expected) { + (Ok(actual), Ok(expected)) => { + assert_eq!(actual, expected, "TC{} failed", index) + } + (Err(_), Err(_)) => { + // Test passed + } + (actual, expected) => { + // Test failed + panic!("TC{index} failed because actual != expected. \nActual: {actual:?}\nExpected: {expected:?}\n"); + } + } + } + } + } +} diff --git a/barter-data/src/exchange/kraken/book/mod.rs b/barter-data/src/exchange/kraken/book/mod.rs new file mode 100644 index 00000000..3fe45b91 --- /dev/null +++ b/barter-data/src/exchange/kraken/book/mod.rs @@ -0,0 +1,2 @@ +/// Level 1 OrderBook types (top of book). +pub mod l1; diff --git a/barter-data/src/exchange/kraken/channel.rs b/barter-data/src/exchange/kraken/channel.rs new file mode 100644 index 00000000..1cfc794b --- /dev/null +++ b/barter-data/src/exchange/kraken/channel.rs @@ -0,0 +1,43 @@ +use super::Kraken; +use crate::{ + subscription::{book::OrderBooksL1, trade::PublicTrades, Subscription}, + Identifier, +}; +use serde::Serialize; + +/// Type that defines how to translate a Barter [`Subscription`] into a +/// [`Kraken`](super::Kraken) channel to be subscribed to. +/// +/// See docs: +#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Serialize)] +pub struct KrakenChannel(pub &'static str); + +impl KrakenChannel { + /// [`Kraken`] real-time trades channel name. + /// + /// See docs: + pub const TRADES: Self = Self("trade"); + + /// [`Kraken`] real-time OrderBook Level1 (top of book) channel name. + /// + /// See docs: + pub const ORDER_BOOK_L1: Self = Self("spread"); +} + +impl Identifier for Subscription { + fn id(&self) -> KrakenChannel { + KrakenChannel::TRADES + } +} + +impl Identifier for Subscription { + fn id(&self) -> KrakenChannel { + KrakenChannel::ORDER_BOOK_L1 + } +} + +impl AsRef for KrakenChannel { + fn as_ref(&self) -> &str { + self.0 + } +} diff --git a/barter-data/src/exchange/kraken/market.rs b/barter-data/src/exchange/kraken/market.rs new file mode 100644 index 00000000..255d2dd7 --- /dev/null +++ b/barter-data/src/exchange/kraken/market.rs @@ -0,0 +1,43 @@ +use super::Kraken; +use crate::{ + instrument::{KeyedInstrument, MarketInstrumentData}, + subscription::Subscription, + Identifier, +}; +use barter_integration::model::instrument::{symbol::Symbol, Instrument}; +use serde::{Deserialize, Serialize}; + +/// Type that defines how to translate a Barter [`Subscription`] into a +/// [`Kraken`] market that can be subscribed to. +/// +/// See docs: +#[derive(Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Deserialize, Serialize)] +pub struct KrakenMarket(pub String); + +impl Identifier for Subscription { + fn id(&self) -> KrakenMarket { + kraken_market(&self.instrument.base, &self.instrument.quote) + } +} + +impl Identifier for Subscription { + fn id(&self) -> KrakenMarket { + kraken_market(&self.instrument.data.base, &self.instrument.data.quote) + } +} + +impl Identifier for Subscription { + fn id(&self) -> KrakenMarket { + KrakenMarket(self.instrument.name_exchange.clone()) + } +} + +impl AsRef for KrakenMarket { + fn as_ref(&self) -> &str { + &self.0 + } +} + +fn kraken_market(base: &Symbol, quote: &Symbol) -> KrakenMarket { + KrakenMarket(format!("{base}/{quote}").to_uppercase()) +} diff --git a/barter-data/src/exchange/kraken/message.rs b/barter-data/src/exchange/kraken/message.rs new file mode 100644 index 00000000..953b5b1b --- /dev/null +++ b/barter-data/src/exchange/kraken/message.rs @@ -0,0 +1,169 @@ +use crate::Identifier; +use barter_integration::model::SubscriptionId; +use serde::{Deserialize, Serialize}; + +/// [`Kraken`](super::Kraken) message variants that can be received over +/// [`WebSocket`](barter_integration::protocol::websocket::WebSocket). +/// +/// ### Raw Payload Examples +/// See docs: +/// +/// #### OrderBookL1 +/// See docs: +/// ```json +/// [ +/// 0, +/// [ +/// "5698.40000", +/// "5700.00000", +/// "1542057299.545897", +/// "1.01234567", +/// "0.98765432" +/// ], +/// "spread", +/// "XBT/USD" +/// ] +/// ``` +/// +/// #### Trades +/// See docs: +/// ```json +/// [ +/// 0, +/// [ +/// [ +/// "5541.20000", +/// "0.15850568", +/// "1534614057.321597", +/// "s", +/// "l", +/// "" +/// ], +/// [ +/// "6060.00000", +/// "0.02455000", +/// "1534614057.324998", +/// "b", +/// "l", +/// "" +/// ] +/// ], +/// "trade", +/// "XBT/USD" +/// ] +/// ``` +/// +/// #### Heartbeat +/// See docs: +/// ```json +/// { +/// "event": "heartbeat" +/// } +/// ``` +/// +/// #### KrakenError Generic +/// See docs: +/// ```json +/// { +/// "errorMessage": "Malformed request", +/// "event": "error" +/// } +/// ``` +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Debug, Deserialize, Serialize)] +#[serde(untagged, rename_all = "snake_case")] +pub enum KrakenMessage { + Data(T), + Event(KrakenEvent), +} + +impl Identifier> for KrakenMessage +where + T: Identifier>, +{ + fn id(&self) -> Option { + match self { + Self::Data(data) => data.id(), + Self::Event(_) => None, + } + } +} + +/// [`Kraken`](super::Kraken) messages received over the WebSocket which are not subscription data. +/// +/// eg/ [`Kraken`](super::Kraken) sends a [`KrakenEvent::Heartbeat`] if no subscription traffic +/// has been sent within the last second. +/// +/// See [`KrakenMessage`] for full raw payload examples. +/// +/// See docs: +#[derive(Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Deserialize, Serialize)] +#[serde(tag = "event", rename_all = "camelCase")] +pub enum KrakenEvent { + Heartbeat, + Error(KrakenError), +} + +/// [`Kraken`](super::Kraken) generic error message String received over the WebSocket. +/// +/// Note that since the [`KrakenError`] is only made up of a renamed message String field, it can +/// be used flexible as a [`KrakenSubResponse::Error`](super::subscription::KrakenSubResponse) +/// or as a generic error received over the WebSocket while subscriptions are active. +/// +/// See [`KrakenMessage`] for full raw payload examples. +/// +/// See docs:
+/// See docs: +#[derive(Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Deserialize, Serialize)] +pub struct KrakenError { + #[serde(alias = "errorMessage")] + pub message: String, +} + +#[cfg(test)] +mod tests { + use super::*; + + mod de { + use super::*; + use barter_integration::error::SocketError; + + #[test] + fn test_kraken_message_event() { + struct TestCase { + input: &'static str, + expected: Result, SocketError>, + } + + let tests = vec![ + TestCase { + // TC0: valid KrakenTrades::Event(KrakenEvent::Heartbeat) + input: r#"{"event": "heartbeat"}"#, + expected: Ok(KrakenMessage::Event(KrakenEvent::Heartbeat)), + }, + TestCase { + // TC1: valid KrakenTrades::Event(KrakenEvent::Error(KrakenError)) + input: r#"{"errorMessage": "Malformed request", "event": "error"}"#, + expected: Ok(KrakenMessage::Event(KrakenEvent::Error(KrakenError { + message: "Malformed request".to_string(), + }))), + }, + ]; + + for (index, test) in tests.into_iter().enumerate() { + let actual = serde_json::from_str::>(test.input); + match (actual, test.expected) { + (Ok(actual), Ok(expected)) => { + assert_eq!(actual, expected, "TC{} failed", index) + } + (Err(_), Err(_)) => { + // Test passed + } + (actual, expected) => { + // Test failed + panic!("TC{index} failed because actual != expected. \nActual: {actual:?}\nExpected: {expected:?}\n"); + } + } + } + } + } +} diff --git a/barter-data/src/exchange/kraken/mod.rs b/barter-data/src/exchange/kraken/mod.rs new file mode 100644 index 00000000..8c9e6755 --- /dev/null +++ b/barter-data/src/exchange/kraken/mod.rs @@ -0,0 +1,98 @@ +use self::{ + book::l1::KrakenOrderBookL1, channel::KrakenChannel, market::KrakenMarket, + message::KrakenMessage, subscription::KrakenSubResponse, trade::KrakenTrades, +}; +use crate::{ + exchange::{Connector, ExchangeId, ExchangeSub, StreamSelector}, + instrument::InstrumentData, + subscriber::{validator::WebSocketSubValidator, WebSocketSubscriber}, + subscription::{book::OrderBooksL1, trade::PublicTrades}, + transformer::stateless::StatelessTransformer, + ExchangeWsStream, +}; +use barter_integration::{error::SocketError, protocol::websocket::WsMessage}; +use barter_macro::{DeExchange, SerExchange}; +use serde_json::json; +use url::Url; + +/// Order book types for [`Kraken`] +pub mod book; + +/// Defines the type that translates a Barter [`Subscription`](crate::subscription::Subscription) +/// into an exchange [`Connector`] specific channel used for generating [`Connector::requests`]. +pub mod channel; + +/// Defines the type that translates a Barter [`Subscription`](crate::subscription::Subscription) +/// into an exchange [`Connector`] specific market used for generating [`Connector::requests`]. +pub mod market; + +/// [`KrakenMessage`](message::KrakenMessage) type for [`Kraken`]. +pub mod message; + +/// [`Subscription`](crate::subscription::Subscription) response type and response +/// [`Validator`](barter_integration) for [`Kraken`]. +pub mod subscription; + +/// Public trade types for [`Kraken`]. +pub mod trade; + +/// [`Kraken`] server base url. +/// +/// See docs: +pub const BASE_URL_KRAKEN: &str = "wss://ws.kraken.com/"; + +/// [`Kraken`] exchange. +/// +/// See docs: +#[derive( + Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Default, DeExchange, SerExchange, +)] +pub struct Kraken; + +impl Connector for Kraken { + const ID: ExchangeId = ExchangeId::Kraken; + type Channel = KrakenChannel; + type Market = KrakenMarket; + type Subscriber = WebSocketSubscriber; + type SubValidator = WebSocketSubValidator; + type SubResponse = KrakenSubResponse; + + fn url() -> Result { + Url::parse(BASE_URL_KRAKEN).map_err(SocketError::UrlParse) + } + + fn requests(exchange_subs: Vec>) -> Vec { + exchange_subs + .into_iter() + .map(|ExchangeSub { channel, market }| { + WsMessage::Text( + json!({ + "event": "subscribe", + "pair": [market.as_ref()], + "subscription": { + "name": channel.as_ref() + } + }) + .to_string(), + ) + }) + .collect() + } +} + +impl StreamSelector for Kraken +where + Instrument: InstrumentData, +{ + type Stream = + ExchangeWsStream>; +} + +impl StreamSelector for Kraken +where + Instrument: InstrumentData, +{ + type Stream = ExchangeWsStream< + StatelessTransformer, + >; +} diff --git a/barter-data/src/exchange/kraken/subscription.rs b/barter-data/src/exchange/kraken/subscription.rs new file mode 100644 index 00000000..e97a95ba --- /dev/null +++ b/barter-data/src/exchange/kraken/subscription.rs @@ -0,0 +1,166 @@ +use super::message::KrakenError; +use barter_integration::{error::SocketError, Validator}; +use serde::{Deserialize, Serialize}; + +/// [`Kraken`](super::Kraken) message received in response to WebSocket subscription requests. +/// +/// ### Raw Payload Examples +/// See docs: +/// #### Subscription Trade Success +/// ```json +/// { +/// "channelID": 10001, +/// "channelName": "ticker", +/// "event": "subscriptionStatus", +/// "pair": "XBT/EUR", +/// "status": "subscribed", +/// "subscription": { +/// "name": "ticker" +/// } +/// } +/// ``` +/// +/// #### Subscription Trade Failure +/// ```json +/// { +/// "errorMessage": "Subscription name invalid", +/// "event": "subscriptionStatus", +/// "pair": "XBT/USD", +/// "status": "error", +/// "subscription": { +/// "name": "trades" +/// } +/// } +/// ``` +#[derive(Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Deserialize, Serialize)] +#[serde(tag = "status", rename_all = "camelCase")] +pub enum KrakenSubResponse { + Subscribed { + #[serde(alias = "channelID")] + channel_id: u64, + #[serde(alias = "channelName")] + channel_name: String, + pair: String, + }, + Error(KrakenError), +} + +impl Validator for KrakenSubResponse { + fn validate(self) -> Result + where + Self: Sized, + { + match &self { + KrakenSubResponse::Subscribed { .. } => Ok(self), + KrakenSubResponse::Error(error) => Err(SocketError::Subscribe(format!( + "received failure subscription response: {}", + error.message + ))), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + mod de { + use super::*; + + #[test] + fn test_kraken_sub_response() { + struct TestCase { + input: &'static str, + expected: Result, + } + + let cases = vec![ + TestCase { + // TC0: input response is Subscribed + input: r#" + { + "channelID": 10001, + "channelName": "ticker", + "event": "subscriptionStatus", + "pair": "XBT/EUR", + "status": "subscribed", + "subscription": { + "name": "ticker" + } + } + "#, + expected: Ok(KrakenSubResponse::Subscribed { + channel_id: 10001, + channel_name: "ticker".to_string(), + pair: "XBT/EUR".to_string(), + }), + }, + TestCase { + // TC1: input response is failed subscription + input: r#" + { + "errorMessage": "Subscription name invalid", + "event": "subscriptionStatus", + "pair": "XBT/USD", + "status": "error", + "subscription": { + "name": "trades" + } + } + "#, + expected: Ok(KrakenSubResponse::Error(KrakenError { + message: "Subscription name invalid".to_string(), + })), + }, + ]; + + for (index, test) in cases.into_iter().enumerate() { + let actual = serde_json::from_str::(test.input); + match (actual, test.expected) { + (Ok(actual), Ok(expected)) => { + assert_eq!(actual, expected, "TC{} failed", index) + } + (Err(_), Err(_)) => { + // Test passed + } + (actual, expected) => { + // Test failed + panic!("TC{index} failed because actual != expected. \nActual: {actual:?}\nExpected: {expected:?}\n"); + } + } + } + } + } + + #[test] + fn test_kraken_sub_response_validate() { + struct TestCase { + input_response: KrakenSubResponse, + is_valid: bool, + } + + let cases = vec![ + TestCase { + // TC0: input response is successful subscription + input_response: KrakenSubResponse::Subscribed { + channel_id: 10001, + channel_name: "ticker".to_string(), + pair: "XBT/EUR".to_string(), + }, + is_valid: true, + }, + TestCase { + // TC1: input response is failed subscription + input_response: KrakenSubResponse::Error(KrakenError { + message: "Subscription name invalid".to_string(), + }), + is_valid: false, + }, + ]; + + for (index, test) in cases.into_iter().enumerate() { + let actual = test.input_response.validate().is_ok(); + assert_eq!(actual, test.is_valid, "TestCase {} failed", index); + } + } +} diff --git a/barter-data/src/exchange/kraken/trade.rs b/barter-data/src/exchange/kraken/trade.rs new file mode 100644 index 00000000..6d368a8a --- /dev/null +++ b/barter-data/src/exchange/kraken/trade.rs @@ -0,0 +1,293 @@ +use super::KrakenMessage; +use crate::{ + event::{MarketEvent, MarketIter}, + exchange::ExchangeId, + subscription::trade::PublicTrade, + Identifier, +}; +use barter_integration::{ + de::{datetime_utc_from_epoch_duration, extract_next}, + model::{Exchange, Side, SubscriptionId}, +}; +use chrono::{DateTime, Utc}; +use serde::Serialize; + +/// Terse type alias for an [`Kraken`](super::Kraken) real-time trades WebSocket message. +pub type KrakenTrades = KrakenMessage; + +/// Collection of [`KrakenTrade`] items with an associated [`SubscriptionId`] (eg/ "trade|XBT/USD"). +/// +/// See [`KrakenMessage`](super::message::KrakenMessage) for full raw payload examples. +/// +/// See docs: +#[derive(Clone, PartialEq, PartialOrd, Debug, Serialize)] +pub struct KrakenTradesInner { + pub subscription_id: SubscriptionId, + pub trades: Vec, +} + +/// [`Kraken`](super::Kraken) trade. +/// +/// See [`KrakenMessage`](super::message::KrakenMessage) for full raw payload examples. +/// +/// See docs: +#[derive(Copy, Clone, PartialEq, PartialOrd, Debug, Serialize)] +pub struct KrakenTrade { + pub price: f64, + #[serde(rename = "quantity")] + pub amount: f64, + pub time: DateTime, + pub side: Side, +} + +impl Identifier> for KrakenTradesInner { + fn id(&self) -> Option { + Some(self.subscription_id.clone()) + } +} + +/// Generate a custom [`Kraken`](super::Kraken) trade identifier since it is not provided in the +/// [`KrakenTrade`] model. +fn custom_kraken_trade_id(trade: &KrakenTrade) -> String { + format!( + "{}_{}_{}_{}", + trade.time.timestamp_nanos(), + trade.side, + trade.price, + trade.amount + ) +} + +impl From<(ExchangeId, InstrumentId, KrakenTrades)> + for MarketIter +{ + fn from((exchange_id, instrument, trades): (ExchangeId, InstrumentId, KrakenTrades)) -> Self { + match trades { + KrakenTrades::Data(trades) => trades + .trades + .into_iter() + .map(|trade| { + Ok(MarketEvent { + exchange_time: trade.time, + received_time: Utc::now(), + exchange: Exchange::from(exchange_id), + instrument: instrument.clone(), + kind: PublicTrade { + id: custom_kraken_trade_id(&trade), + price: trade.price, + amount: trade.amount, + side: trade.side, + }, + }) + }) + .collect(), + KrakenTrades::Event(_) => Self(vec![]), + } + } +} + +impl<'de> serde::de::Deserialize<'de> for KrakenTradesInner { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + struct SeqVisitor; + + impl<'de> serde::de::Visitor<'de> for SeqVisitor { + type Value = KrakenTradesInner; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("KrakenTradesInner struct from the Kraken WebSocket API") + } + + fn visit_seq( + self, + mut seq: SeqAccessor, + ) -> Result + where + SeqAccessor: serde::de::SeqAccess<'de>, + { + // KrakenTrades Sequence Format: + // [channelID, [[price, volume, time, side, orderType, misc]], channelName, pair] + // + + // Extract deprecated channelID & ignore + let _: serde::de::IgnoredAny = extract_next(&mut seq, "channelID")?; + + // Extract Vec + let trades = extract_next(&mut seq, "Vec")?; + + // Extract channelName (eg/ "trade") & ignore + let _: serde::de::IgnoredAny = extract_next(&mut seq, "channelName")?; + + // Extract pair (eg/ "XBT/USD") & map to SubscriptionId (ie/ "trade|{pair}") + let subscription_id = extract_next::(&mut seq, "pair") + .map(|pair| SubscriptionId::from(format!("trade|{pair}")))?; + + // Ignore any additional elements or SerDe will fail + // '--> Exchange may add fields without warning + while seq.next_element::()?.is_some() {} + + Ok(KrakenTradesInner { + subscription_id, + trades, + }) + } + } + + // Use Visitor implementation to deserialise the KrakenTrades + deserializer.deserialize_seq(SeqVisitor) + } +} + +impl<'de> serde::de::Deserialize<'de> for KrakenTrade { + fn deserialize(deserializer: D) -> Result + where + D: serde::de::Deserializer<'de>, + { + struct SeqVisitor; + + impl<'de> serde::de::Visitor<'de> for SeqVisitor { + type Value = KrakenTrade; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("KrakenTrade struct from the Kraken WebSocket API") + } + + fn visit_seq( + self, + mut seq: SeqAccessor, + ) -> Result + where + SeqAccessor: serde::de::SeqAccess<'de>, + { + // KrakenTrade Sequence Format: + // [price, volume, time, side, orderType, misc] + // + + // Extract String price & parse to f64 + let price = extract_next::(&mut seq, "price")? + .parse() + .map_err(serde::de::Error::custom)?; + + // Extract String amount & parse to f64 + let amount = extract_next::(&mut seq, "quantity")? + .parse() + .map_err(serde::de::Error::custom)?; + + // Extract String price, parse to f64, map to DateTime + let time = extract_next::(&mut seq, "time")? + .parse() + .map(|time| { + datetime_utc_from_epoch_duration(std::time::Duration::from_secs_f64(time)) + }) + .map_err(serde::de::Error::custom)?; + + // Extract Side + let side: Side = extract_next(&mut seq, "side")?; + + // Ignore any additional elements or SerDe will fail + // '--> Exchange may add fields without warning + while seq.next_element::()?.is_some() {} + + Ok(KrakenTrade { + price, + amount, + time, + side, + }) + } + } + + // Use Visitor implementation to deserialise the KrakenTrade + deserializer.deserialize_seq(SeqVisitor) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + mod de { + use super::*; + use barter_integration::{ + de::datetime_utc_from_epoch_duration, + error::SocketError, + model::{Side, SubscriptionId}, + }; + + #[test] + fn test_kraken_message_trades() { + struct TestCase { + input: &'static str, + expected: Result, + } + + let tests = vec![TestCase { + // TC0: valid KrakenTrades::Data(KrakenTradesInner) + input: r#" + [ + 0, + [ + [ + "5541.20000", + "0.15850568", + "1534614057.321597", + "s", + "l", + "" + ], + [ + "6060.00000", + "0.02455000", + "1534614057.324998", + "b", + "l", + "" + ] + ], + "trade", + "XBT/USD" + ] + "#, + expected: Ok(KrakenTrades::Data(KrakenTradesInner { + subscription_id: SubscriptionId::from("trade|XBT/USD"), + trades: vec![ + KrakenTrade { + price: 5541.2, + amount: 0.15850568, + time: datetime_utc_from_epoch_duration( + std::time::Duration::from_secs_f64(1534614057.321597), + ), + side: Side::Sell, + }, + KrakenTrade { + price: 6060.0, + amount: 0.02455000, + time: datetime_utc_from_epoch_duration( + std::time::Duration::from_secs_f64(1534614057.324998), + ), + side: Side::Buy, + }, + ], + })), + }]; + + for (index, test) in tests.into_iter().enumerate() { + let actual = serde_json::from_str::(test.input); + match (actual, test.expected) { + (Ok(actual), Ok(expected)) => { + assert_eq!(actual, expected, "TC{} failed", index) + } + (Err(_), Err(_)) => { + // Test passed + } + (actual, expected) => { + // Test failed + panic!("TC{index} failed because actual != expected. \nActual: {actual:?}\nExpected: {expected:?}\n"); + } + } + } + } + } +} diff --git a/barter-data/src/exchange/mod.rs b/barter-data/src/exchange/mod.rs new file mode 100644 index 00000000..4e4c8f8e --- /dev/null +++ b/barter-data/src/exchange/mod.rs @@ -0,0 +1,283 @@ +use self::subscription::ExchangeSub; +use crate::{ + instrument::InstrumentData, + subscriber::{validator::SubscriptionValidator, Subscriber}, + subscription::{Map, SubKind, SubscriptionKind}, + MarketStream, +}; +use barter_integration::{ + error::SocketError, model::instrument::kind::InstrumentKind, protocol::websocket::WsMessage, + Validator, +}; +use serde::{de::DeserializeOwned, Deserialize, Serialize}; +use std::{ + fmt::{Debug, Display}, + time::Duration, +}; +use url::Url; + +/// `BinanceSpot` & `BinanceFuturesUsd` [`Connector`] and [`StreamSelector`] implementations. +pub mod binance; + +/// `Bitfinex` [`Connector`] and [`StreamSelector`] implementations. +pub mod bitfinex; + +/// `Bitmex [`Connector`] and [`StreamSelector`] implementations. +pub mod bitmex; + +/// `Bybit` ['Connector'] and ['StreamSelector'] implementation +pub mod bybit; + +/// `Coinbase` [`Connector`] and [`StreamSelector`] implementations. +pub mod coinbase; + +/// `GateioSpot`, `GateioFuturesUsd` & `GateioFuturesBtc` [`Connector`] and [`StreamSelector`] +/// implementations. +pub mod gateio; + +/// `Kraken` [`Connector`] and [`StreamSelector`] implementations. +pub mod kraken; + +/// `Okx` [`Connector`] and [`StreamSelector`] implementations. +pub mod okx; + +/// Defines the generic [`ExchangeSub`] containing a market and channel combination used by an +/// exchange [`Connector`] to build [`WsMessage`] subscription payloads. +pub mod subscription; + +/// Default [`Duration`] the [`Connector::SubValidator`] will wait to receive all success responses to actioned +/// [`Subscription`](subscription::Subscription) requests. +pub const DEFAULT_SUBSCRIPTION_TIMEOUT: Duration = Duration::from_secs(10); + +/// Defines the [`MarketStream`] kind associated with an exchange +/// [`Subscription`](subscription::Subscription) [`SubscriptionKind`]. +/// +/// ### Notes +/// Must be implemented by an exchange [`Connector`] if it supports a specific +/// [`SubscriptionKind`]. +pub trait StreamSelector +where + Self: Connector, + Instrument: InstrumentData, + Kind: SubscriptionKind, +{ + type Stream: MarketStream; +} + +/// Primary exchange abstraction. Defines how to translate Barter types into exchange specific +/// types, as well as connecting, subscribing, and interacting with the exchange server. +/// +/// ### Notes +/// This must be implemented for a new exchange integration! +pub trait Connector +where + Self: Clone + Default + Debug + for<'de> Deserialize<'de> + Serialize + Sized, +{ + /// Unique identifier for the exchange server being connected with. + const ID: ExchangeId; + + /// Type that defines how to translate a Barter + /// [`Subscription`](ubscription::Subscription) into an exchange specific channel + /// to be subscribed to. + /// + /// ### Examples + /// - [`BinanceChannel("@depth@100ms")`](binance::channel::BinanceChannel) + /// - [`KrakenChannel("trade")`](kraken::channel::KrakenChannel) + type Channel: AsRef; + + /// Type that defines how to translate a Barter + /// [`Subscription`](subscription::Subscription) into an exchange specific market that + /// can be subscribed to. + /// + /// ### Examples + /// - [`BinanceMarket("btcusdt")`](binance::market::BinanceMarket) + /// - [`KrakenMarket("BTC/USDT")`](kraken::market::KrakenMarket) + type Market: AsRef; + + /// [`Subscriber`] type that establishes a connection with the exchange server, and actions + /// [`Subscription`](subscription::Subscription)s over the socket. + type Subscriber: Subscriber; + + /// [`SubscriptionValidator`] type that listens to responses from the exchange server and + /// validates if the actioned [`Subscription`](subscription::Subscription)s were + /// successful. + type SubValidator: SubscriptionValidator; + + /// Deserialisable type that the [`Self::SubValidator`] expects to receive from the exchange server in + /// response to the [`Subscription`](subscription::Subscription) [`Self::requests`] + /// sent over the [`WebSocket`](barter_integration::protocol::websocket::WebSocket). Implements + /// [`Validator`] in order to determine if [`Self`] + /// communicates a successful [`Subscription`](subscription::Subscription) outcome. + type SubResponse: Validator + Debug + DeserializeOwned; + + /// Base [`Url`] of the exchange server being connected with. + fn url() -> Result; + + /// Defines [`PingInterval`] of custom application-level + /// [`WebSocket`](barter_integration::protocol::websocket::WebSocket) pings for the exchange + /// server being connected with. + /// + /// Defaults to `None`, meaning that no custom pings are sent. + fn ping_interval() -> Option { + None + } + + /// Defines how to translate a collection of [`ExchangeSub`]s into the [`WsMessage`] + /// subscription payloads sent to the exchange server. + fn requests(exchange_subs: Vec>) -> Vec; + + /// Number of [`Subscription`](subscription::Subscription) responses expected from the + /// exchange server in responses to the requests send. Used to validate all + /// [`Subscription`](subscription::Subscription)s were accepted. + fn expected_responses(map: &Map) -> usize { + map.0.len() + } + + /// Expected [`Duration`] the [`SubscriptionValidator`] will wait to receive all success + /// responses to actioned [`Subscription`](subscription::Subscription) requests. + fn subscription_timeout() -> Duration { + DEFAULT_SUBSCRIPTION_TIMEOUT + } +} + +/// Used when an exchange has servers different +/// [`InstrumentKind`] market data on distinct servers, +/// allowing all the [`Connector`] logic to be identical apart from what this trait provides. +/// +/// ### Examples +/// - [`BinanceServerSpot`](binance::spot::BinanceServerSpot) +/// - [`BinanceServerFuturesUsd`](binance::futures::BinanceServerFuturesUsd) +pub trait ExchangeServer: Default + Debug + Clone + Send { + const ID: ExchangeId; + fn websocket_url() -> &'static str; +} + +/// Defines the frequency and construction function for custom +/// [`WebSocket`](barter_integration::protocol::websocket::WebSocket) pings - used for exchanges +/// that require additional application-level pings. +#[derive(Debug)] +pub struct PingInterval { + pub interval: tokio::time::Interval, + pub ping: fn() -> WsMessage, +} + +/// Unique identifier an exchange server [`Connector`]. +/// +/// ### Notes +/// An exchange may server different [`InstrumentKind`] +/// market data on distinct servers (eg/ Binance, Gateio). Such exchanges have multiple [`Self`] +/// variants, and often utilise the [`ExchangeServer`] trait. +#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Deserialize, Serialize)] +#[serde(rename = "exchange", rename_all = "snake_case")] +pub enum ExchangeId { + BinanceFuturesUsd, + BinanceSpot, + Bitfinex, + Bitmex, + BybitSpot, + BybitPerpetualsUsd, + Coinbase, + GateioSpot, + GateioFuturesUsd, + GateioFuturesBtc, + GateioPerpetualsBtc, + GateioPerpetualsUsd, + GateioOptions, + Kraken, + Okx, +} + +impl From for barter_integration::model::Exchange { + fn from(exchange_id: ExchangeId) -> Self { + barter_integration::model::Exchange::from(exchange_id.as_str()) + } +} + +impl Display for ExchangeId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.as_str()) + } +} + +impl ExchangeId { + /// Return the &str representation of this [`ExchangeId`] + pub fn as_str(&self) -> &'static str { + match self { + ExchangeId::BinanceSpot => "binance_spot", + ExchangeId::BinanceFuturesUsd => "binance_futures_usd", + ExchangeId::Bitfinex => "bitfinex", + ExchangeId::Bitmex => "bitmex", + ExchangeId::BybitSpot => "bybit_spot", + ExchangeId::BybitPerpetualsUsd => "bybit_perpetuals_usd", + ExchangeId::Coinbase => "coinbase", + ExchangeId::GateioSpot => "gateio_spot", + ExchangeId::GateioFuturesUsd => "gateio_futures_usd", + ExchangeId::GateioFuturesBtc => "gateio_futures_btc", + ExchangeId::GateioPerpetualsUsd => "gateio_perpetuals_usd", + ExchangeId::GateioPerpetualsBtc => "gateio_perpetuals_btc", + ExchangeId::GateioOptions => "gateio_options", + ExchangeId::Kraken => "kraken", + ExchangeId::Okx => "okx", + } + } + + pub fn supports(&self, instrument_kind: InstrumentKind, sub_kind: SubKind) -> bool { + use crate::subscription::SubKind::*; + use ExchangeId::*; + use InstrumentKind::*; + + match (self, instrument_kind, sub_kind) { + (BinanceSpot, Spot, PublicTrades | OrderBooksL1) => true, + (BinanceFuturesUsd, Perpetual, PublicTrades | OrderBooksL1 | Liquidations) => true, + (Bitfinex, Spot, PublicTrades) => true, + (Bitmex, Perpetual, PublicTrades) => true, + (BybitSpot, Spot, PublicTrades) => true, + (BybitPerpetualsUsd, Perpetual, PublicTrades) => true, + (Coinbase, Spot, PublicTrades) => true, + (GateioSpot, Spot, PublicTrades) => true, + (GateioFuturesUsd, Future(_), PublicTrades) => true, + (GateioFuturesBtc, Future(_), PublicTrades) => true, + (GateioPerpetualsUsd, Perpetual, PublicTrades) => true, + (GateioPerpetualsBtc, Perpetual, PublicTrades) => true, + (GateioOptions, Option(_), PublicTrades) => true, + (Kraken, Spot, PublicTrades | OrderBooksL1) => true, + (Okx, Spot | Future(_) | Perpetual | Option(_), PublicTrades) => true, + + (_, _, _) => false, + } + } + + /// Determines whether the [`Connector`] associated with this [`ExchangeId`] supports the + /// ingestion of market data for the provided [`InstrumentKind`]. + #[allow(clippy::match_like_matches_macro)] + pub fn supports_instrument_kind(&self, instrument_kind: InstrumentKind) -> bool { + use ExchangeId::*; + use InstrumentKind::*; + + match (self, instrument_kind) { + // Spot + ( + BinanceFuturesUsd | Bitmex | BybitPerpetualsUsd | GateioPerpetualsUsd + | GateioPerpetualsBtc, + Spot, + ) => false, + (_, Spot) => true, + + // Future + (GateioFuturesUsd | GateioFuturesBtc | Okx, Future(_)) => true, + (_, Future(_)) => false, + + // Future Perpetual Swaps + ( + BinanceFuturesUsd | Bitmex | Okx | BybitPerpetualsUsd | GateioPerpetualsUsd + | GateioPerpetualsBtc, + Perpetual, + ) => true, + (_, Perpetual) => false, + + // Option + (GateioOptions | Okx, Option(_)) => true, + (_, Option(_)) => false, + } + } +} diff --git a/barter-data/src/exchange/okx/channel.rs b/barter-data/src/exchange/okx/channel.rs new file mode 100644 index 00000000..dd9ea015 --- /dev/null +++ b/barter-data/src/exchange/okx/channel.rs @@ -0,0 +1,32 @@ +use super::Okx; +use crate::{ + subscription::{trade::PublicTrades, Subscription}, + Identifier, +}; +use serde::Serialize; + +/// Type that defines how to translate a Barter [`Subscription`] into a +/// [`Okx`] channel to be subscribed to. +/// +/// See docs: +#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Serialize)] +pub struct OkxChannel(pub &'static str); + +impl OkxChannel { + /// [`Okx`] real-time trades channel. + /// + /// See docs: + pub const TRADES: Self = Self("trades"); +} + +impl Identifier for Subscription { + fn id(&self) -> OkxChannel { + OkxChannel::TRADES + } +} + +impl AsRef for OkxChannel { + fn as_ref(&self) -> &str { + self.0 + } +} diff --git a/barter-data/src/exchange/okx/market.rs b/barter-data/src/exchange/okx/market.rs new file mode 100644 index 00000000..734dec08 --- /dev/null +++ b/barter-data/src/exchange/okx/market.rs @@ -0,0 +1,76 @@ +use super::Okx; +use crate::{ + instrument::{KeyedInstrument, MarketInstrumentData}, + subscription::Subscription, + Identifier, +}; +use barter_integration::model::instrument::{ + kind::{InstrumentKind, OptionKind}, + Instrument, +}; +use chrono::{ + format::{DelayedFormat, StrftimeItems}, + DateTime, Utc, +}; +use serde::{Deserialize, Serialize}; + +/// Type that defines how to translate a Barter [`Subscription`] into a +/// [`Okx`] market that can be subscribed to. +/// +/// See docs: +#[derive(Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Deserialize, Serialize)] +pub struct OkxMarket(pub String); + +impl Identifier for Subscription { + fn id(&self) -> OkxMarket { + okx_market(&self.instrument) + } +} + +impl Identifier for Subscription { + fn id(&self) -> OkxMarket { + okx_market(&self.instrument.data) + } +} + +impl Identifier for Subscription { + fn id(&self) -> OkxMarket { + OkxMarket(self.instrument.name_exchange.clone()) + } +} + +impl AsRef for OkxMarket { + fn as_ref(&self) -> &str { + &self.0 + } +} + +fn okx_market(instrument: &Instrument) -> OkxMarket { + use InstrumentKind::*; + let Instrument { base, quote, kind } = instrument; + + OkxMarket(match kind { + Spot => format!("{base}-{quote}").to_uppercase(), + Future(future) => format!("{base}-{quote}-{}", format_expiry(future.expiry)).to_uppercase(), + Perpetual => format!("{base}-{quote}-SWAP").to_uppercase(), + Option(option) => format!( + "{base}-{quote}-{}-{}-{}", + format_expiry(option.expiry), + option.strike, + match option.kind { + OptionKind::Call => "C", + OptionKind::Put => "P", + }, + ) + .to_uppercase(), + }) +} + +/// Format the expiry DateTime to be Okx API compatible. +/// +/// eg/ "230526" (26th of May 2023) +/// +/// See docs: +fn format_expiry<'a>(expiry: DateTime) -> DelayedFormat> { + expiry.date_naive().format("%g%m%d") +} diff --git a/barter-data/src/exchange/okx/mod.rs b/barter-data/src/exchange/okx/mod.rs new file mode 100644 index 00000000..a8af230c --- /dev/null +++ b/barter-data/src/exchange/okx/mod.rs @@ -0,0 +1,87 @@ +use self::{ + channel::OkxChannel, market::OkxMarket, subscription::OkxSubResponse, trade::OkxTrades, +}; +use crate::{ + exchange::{Connector, ExchangeId, ExchangeSub, PingInterval, StreamSelector}, + instrument::InstrumentData, + subscriber::{validator::WebSocketSubValidator, WebSocketSubscriber}, + subscription::trade::PublicTrades, + transformer::stateless::StatelessTransformer, + ExchangeWsStream, +}; +use barter_integration::{error::SocketError, protocol::websocket::WsMessage}; +use barter_macro::{DeExchange, SerExchange}; +use serde_json::json; +use std::time::Duration; +use url::Url; + +/// Defines the type that translates a Barter [`Subscription`](crate::subscription::Subscription) +/// into an exchange [`Connector`] specific channel used for generating [`Connector::requests`]. +pub mod channel; + +/// Defines the type that translates a Barter [`Subscription`](crate::subscription::Subscription) +/// into an exchange [`Connector`] specific market used for generating [`Connector::requests`]. +pub mod market; + +/// [`Subscription`](crate::subscription::Subscription) response type and response +/// [`Validator`](barter_integration::Validator) for [`Okx`]. +pub mod subscription; + +/// Public trade types for [`Okx`]. +pub mod trade; + +/// [`Okx`] server base url. +/// +/// See docs: +pub const BASE_URL_OKX: &str = "wss://wsaws.okx.com:8443/ws/v5/public"; + +/// [`Okx`] server [`PingInterval`] duration. +/// +/// See docs: +pub const PING_INTERVAL_OKX: Duration = Duration::from_secs(29); + +/// [`Okx`] exchange. +/// +/// See docs: +#[derive( + Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Default, DeExchange, SerExchange, +)] +pub struct Okx; + +impl Connector for Okx { + const ID: ExchangeId = ExchangeId::Okx; + type Channel = OkxChannel; + type Market = OkxMarket; + type Subscriber = WebSocketSubscriber; + type SubValidator = WebSocketSubValidator; + type SubResponse = OkxSubResponse; + + fn url() -> Result { + Url::parse(BASE_URL_OKX).map_err(SocketError::UrlParse) + } + + fn ping_interval() -> Option { + Some(PingInterval { + interval: tokio::time::interval(PING_INTERVAL_OKX), + ping: || WsMessage::text("ping"), + }) + } + + fn requests(exchange_subs: Vec>) -> Vec { + vec![WsMessage::Text( + json!({ + "op": "subscribe", + "args": &exchange_subs, + }) + .to_string(), + )] + } +} + +impl StreamSelector for Okx +where + Instrument: InstrumentData, +{ + type Stream = + ExchangeWsStream>; +} diff --git a/barter-data/src/exchange/okx/subscription.rs b/barter-data/src/exchange/okx/subscription.rs new file mode 100644 index 00000000..39c820f1 --- /dev/null +++ b/barter-data/src/exchange/okx/subscription.rs @@ -0,0 +1,156 @@ +use super::{channel::OkxChannel, market::OkxMarket}; +use crate::exchange::subscription::ExchangeSub; +use barter_integration::{error::SocketError, Validator}; +use serde::{ser::SerializeStruct, Deserialize, Serialize, Serializer}; + +// Implement custom Serialize to assist aesthetics of ::requests() function. +impl Serialize for ExchangeSub { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + let mut state = serializer.serialize_struct("OkxSubArg", 2)?; + state.serialize_field("channel", self.channel.as_ref())?; + state.serialize_field("instId", self.market.as_ref())?; + state.end() + } +} + +/// [`Okx`](super::Okx) WebSocket subscription response. +/// +/// ### Raw Payload Examples +/// #### Subscription Trades Ok Response +/// ```json +/// { +/// "event": "subscribe", +/// "args": { +/// "channel": "trades", +/// "instId": "BTC-USD-191227" +/// } +/// } +/// ``` +/// +/// #### Subscription Trades Error Response +/// ```json +/// { +/// "event": "error", +/// "code": "60012", +/// "msg": "Invalid request: {\"op\": \"subscribe\", \"args\":[{ \"channel\" : \"trades\", \"instId\" : \"BTC-USD-191227\"}]}" +/// } +/// ``` +/// +/// See docs: +#[derive(Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Deserialize, Serialize)] +#[serde(tag = "event", rename_all = "lowercase")] +pub enum OkxSubResponse { + #[serde(rename = "subscribe")] + Subscribed, + Error { + code: String, + #[serde(rename = "msg")] + message: String, + }, +} + +impl Validator for OkxSubResponse { + fn validate(self) -> Result + where + Self: Sized, + { + match self { + Self::Subscribed => Ok(self), + Self::Error { code, message } => Err(SocketError::Subscribe(format!( + "received failure subscription response code: {code} with message: {message}", + ))), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + mod de { + use super::*; + + #[test] + fn test_okx_subscription_response() { + struct TestCase { + input: &'static str, + expected: Result, + } + + let cases = vec![ + TestCase { + // TC0: input response is subscription success + input: r#" + { + "event": "subscribe", + "args": {"channel": "trades", "instId": "BTC-USD-191227"} + } + "#, + expected: Ok(OkxSubResponse::Subscribed), + }, + TestCase { + // TC1: input response is failed subscription + input: r#" + { + "event": "error", + "code": "60012", + "msg": "Invalid request: {\"op\": \"subscribe\", \"args\":[{ \"channel\" : \"trades\", \"instId\" : \"BTC-USD-191227\"}]}" + } + "#, + expected: Ok(OkxSubResponse::Error { + code: "60012".to_string(), + message: "Invalid request: {\"op\": \"subscribe\", \"args\":[{ \"channel\" : \"trades\", \"instId\" : \"BTC-USD-191227\"}]}".to_string() + }), + }, + ]; + + for (index, test) in cases.into_iter().enumerate() { + let actual = serde_json::from_str::(test.input); + match (actual, test.expected) { + (Ok(actual), Ok(expected)) => { + assert_eq!(actual, expected, "TC{} failed", index) + } + (Err(_), Err(_)) => { + // Test passed + } + (actual, expected) => { + // Test failed + panic!("TC{index} failed because actual != expected. \nActual: {actual:?}\nExpected: {expected:?}\n"); + } + } + } + } + } + + #[test] + fn test_validate_okx_sub_response() { + struct TestCase { + input_response: OkxSubResponse, + is_valid: bool, + } + + let cases = vec![ + TestCase { + // TC0: input response is subscription success + input_response: OkxSubResponse::Subscribed, + is_valid: true, + }, + TestCase { + // TC1: input response is failed subscription + input_response: OkxSubResponse::Error { + code: "60012".to_string(), + message: "Invalid request: {\"op\": \"subscribe\", \"args\":[{ \"channel\" : \"trades\", \"instId\" : \"BTC-USD-191227\"}]}".to_string() + }, + is_valid: false, + }, + ]; + + for (index, test) in cases.into_iter().enumerate() { + let actual = test.input_response.validate().is_ok(); + assert_eq!(actual, test.is_valid, "TestCase {} failed", index); + } + } +} diff --git a/barter-data/src/exchange/okx/trade.rs b/barter-data/src/exchange/okx/trade.rs new file mode 100644 index 00000000..3094145d --- /dev/null +++ b/barter-data/src/exchange/okx/trade.rs @@ -0,0 +1,193 @@ +use crate::{ + event::{MarketEvent, MarketIter}, + exchange::{ExchangeId, ExchangeSub}, + subscription::trade::PublicTrade, + Identifier, +}; +use barter_integration::model::{Exchange, Side, SubscriptionId}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +/// Terse type alias for an [`Okx`](super::Okx) real-time trades WebSocket message. +pub type OkxTrades = OkxMessage; + +/// [`Okx`](super::Okx) market data WebSocket message. +/// +/// ### Raw Payload Examples +/// See docs: +/// #### Spot Buy Trade +/// ```json +/// { +/// "arg": { +/// "channel": "trades", +/// "instId": "BTC-USDT" +/// }, +/// "data": [ +/// { +/// "instId": "BTC-USDT", +/// "tradeId": "130639474", +/// "px": "42219.9", +/// "sz": "0.12060306", +/// "side": "buy", +/// "ts": "1630048897897" +/// } +/// ] +/// } +/// ``` +/// +/// #### Option Call Sell Trade +/// ```json +/// { +/// "arg": { +/// "channel": "trades", +/// "instId": "BTC-USD-231229-35000-C" +/// }, +/// "data": [ +/// { +/// "instId": "BTC-USD-231229-35000-C", +/// "tradeId": "4", +/// "px": "0.1525", +/// "sz": "21", +/// "side": "sell", +/// "ts": "1681473269025" +/// } +/// ] +/// } +/// ``` +#[derive(Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Deserialize, Serialize)] +pub struct OkxMessage { + #[serde( + rename = "arg", + deserialize_with = "de_okx_message_arg_as_subscription_id" + )] + pub subscription_id: SubscriptionId, + pub data: Vec, +} + +impl Identifier> for OkxMessage { + fn id(&self) -> Option { + Some(self.subscription_id.clone()) + } +} + +/// [`Okx`](super::Okx) real-time trade WebSocket message. +/// +/// See [`OkxMessage`] for full raw payload examples. +/// +/// See docs: +#[derive(Clone, PartialEq, PartialOrd, Debug, Deserialize, Serialize)] +pub struct OkxTrade { + #[serde(rename = "tradeId")] + pub id: String, + #[serde(rename = "px", deserialize_with = "barter_integration::de::de_str")] + pub price: f64, + #[serde(rename = "sz", deserialize_with = "barter_integration::de::de_str")] + pub amount: f64, + pub side: Side, + #[serde( + rename = "ts", + deserialize_with = "barter_integration::de::de_str_u64_epoch_ms_as_datetime_utc" + )] + pub time: DateTime, +} + +impl From<(ExchangeId, InstrumentId, OkxTrades)> + for MarketIter +{ + fn from((exchange_id, instrument, trades): (ExchangeId, InstrumentId, OkxTrades)) -> Self { + trades + .data + .into_iter() + .map(|trade| { + Ok(MarketEvent { + exchange_time: trade.time, + received_time: Utc::now(), + exchange: Exchange::from(exchange_id), + instrument: instrument.clone(), + kind: PublicTrade { + id: trade.id, + price: trade.price, + amount: trade.amount, + side: trade.side, + }, + }) + }) + .collect() + } +} + +/// Deserialize an [`OkxMessage`] "arg" field as a Barter [`SubscriptionId`]. +fn de_okx_message_arg_as_subscription_id<'de, D>( + deserializer: D, +) -> Result +where + D: serde::de::Deserializer<'de>, +{ + #[derive(Deserialize)] + #[serde(rename_all = "camelCase")] + struct Arg<'a> { + channel: &'a str, + inst_id: &'a str, + } + + Deserialize::deserialize(deserializer) + .map(|arg: Arg<'_>| ExchangeSub::from((arg.channel, arg.inst_id)).id()) +} + +#[cfg(test)] +mod tests { + use super::*; + + mod de { + use super::*; + use barter_integration::{de::datetime_utc_from_epoch_duration, error::SocketError}; + use std::time::Duration; + + #[test] + fn test_okx_message_trades() { + let input = r#" + { + "arg": { + "channel": "trades", + "instId": "BTC-USDT" + }, + "data": [ + { + "instId": "BTC-USDT", + "tradeId": "130639474", + "px": "42219.9", + "sz": "0.12060306", + "side": "buy", + "ts": "1630048897897" + } + ] + } + "#; + + let actual = serde_json::from_str::(input); + let expected: Result = Ok(OkxTrades { + subscription_id: SubscriptionId::from("trades|BTC-USDT"), + data: vec![OkxTrade { + id: "130639474".to_string(), + price: 42219.9, + amount: 0.12060306, + side: Side::Buy, + time: datetime_utc_from_epoch_duration(Duration::from_millis(1630048897897)), + }], + }); + + match (actual, expected) { + (Ok(actual), Ok(expected)) => { + assert_eq!(actual, expected, "TC failed") + } + (Err(_), Err(_)) => { + // Test passed + } + (actual, expected) => { + // Test failed + panic!("TC failed because actual != expected. \nActual: {actual:?}\nExpected: {expected:?}\n"); + } + } + } + } +} diff --git a/barter-data/src/exchange/subscription.rs b/barter-data/src/exchange/subscription.rs new file mode 100644 index 00000000..cb77664a --- /dev/null +++ b/barter-data/src/exchange/subscription.rs @@ -0,0 +1,83 @@ +use crate::{subscription::Subscription, Identifier}; +use barter_integration::model::SubscriptionId; +use serde::Deserialize; + +/// Defines an exchange specific market and channel combination used by an exchange +/// [`Connector`](super::Connector) to build the +/// [`WsMessage`](barter_integration::protocol::websocket::WsMessage) subscription payloads to +/// send to the exchange server. +/// +/// ### Examples +/// #### Binance OrderBooksL2 +/// ```json +/// ExchangeSub { +/// channel: BinanceChannel("@depth@100ms"), +/// market: BinanceMarket("btcusdt"), +/// } +/// ``` +/// #### Kraken PublicTrades +/// ```json +/// ExchangeSub { +/// channel: KrakenChannel("trade"), +/// market: KrakenChannel("BTC/USDT") +/// } +/// ``` +#[derive(Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Deserialize)] +pub struct ExchangeSub { + /// Type that defines how to translate a Barter [`Subscription`] into an exchange specific + /// channel to be subscribed to. + /// + /// ### Examples + /// - [`BinanceChannel("@depth@100ms")`](super::binance::channel::BinanceChannel) + /// - [`KrakenChannel("trade")`](super::kraken::channel::KrakenChannel) + pub channel: Channel, + + /// Type that defines how to translate a Barter [`Subscription`] into an exchange specific + /// market that can be subscribed to. + /// + /// ### Examples + /// - [`BinanceMarket("btcusdt")`](super::binance::market::BinanceMarket) + /// - [`KrakenMarket("BTC/USDT")`](super::kraken::market::KrakenMarket) + pub market: Market, +} + +impl Identifier for ExchangeSub +where + Channel: AsRef, + Market: AsRef, +{ + fn id(&self) -> SubscriptionId { + SubscriptionId::from(format!( + "{}|{}", + self.channel.as_ref(), + self.market.as_ref() + )) + } +} + +impl ExchangeSub +where + Channel: AsRef, + Market: AsRef, +{ + /// Construct a new exchange specific [`Self`] with the Barter [`Subscription`] provided. + pub fn new(sub: &Subscription) -> Self + where + Subscription: Identifier + Identifier, + { + Self { + channel: sub.id(), + market: sub.id(), + } + } +} + +impl From<(Channel, Market)> for ExchangeSub +where + Channel: AsRef, + Market: AsRef, +{ + fn from((channel, market): (Channel, Market)) -> Self { + Self { channel, market } + } +} diff --git a/barter-data/src/instrument.rs b/barter-data/src/instrument.rs new file mode 100644 index 00000000..780c945c --- /dev/null +++ b/barter-data/src/instrument.rs @@ -0,0 +1,85 @@ +use barter_integration::model::instrument::{kind::InstrumentKind, Instrument}; +use derive_more::{Constructor, Display}; +use serde::{Deserialize, Serialize}; +use std::fmt::Debug; + +/// Concise unique identifier for an instrument. Used to key +/// [MarketEvents](crate::event::MarketEvent) in a memory efficient way. +#[derive( + Debug, Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize, Display, +)] +pub struct InstrumentId(pub u64); + +/// Instrument related data that defines an associated unique `Id`. +/// +/// Verbose `InstrumentData` is often used to subscribe to market data feeds, but it's unique `Id` +/// can then be used to key consumed [MarketEvents](crate::event::MarketEvent), significantly reducing +/// duplication in the case of complex instruments (eg/ options). +pub trait InstrumentData +where + Self: Clone + Debug + Send + Sync, +{ + type Id: Debug + Clone + Send + Sync; + fn id(&self) -> &Self::Id; + fn kind(&self) -> InstrumentKind; +} + +#[derive( + Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize, Constructor, +)] +pub struct KeyedInstrument { + pub id: Id, + pub data: Instrument, +} + +impl InstrumentData for KeyedInstrument +where + Id: Debug + Clone + Send + Sync, +{ + type Id = Id; + + fn id(&self) -> &Self::Id { + &self.id + } + + fn kind(&self) -> InstrumentKind { + self.data.kind + } +} + +impl AsRef for KeyedInstrument { + fn as_ref(&self) -> &Instrument { + &self.data + } +} + +impl InstrumentData for Instrument { + type Id = Self; + + fn id(&self) -> &Self::Id { + self + } + + fn kind(&self) -> InstrumentKind { + self.kind + } +} + +#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize)] +pub struct MarketInstrumentData { + pub id: InstrumentId, + pub name_exchange: String, + pub kind: InstrumentKind, +} + +impl InstrumentData for MarketInstrumentData { + type Id = InstrumentId; + + fn id(&self) -> &Self::Id { + &self.id + } + + fn kind(&self) -> InstrumentKind { + self.kind + } +} diff --git a/barter-data/src/lib.rs b/barter-data/src/lib.rs new file mode 100644 index 00000000..168e5ee1 --- /dev/null +++ b/barter-data/src/lib.rs @@ -0,0 +1,271 @@ +#![forbid(unsafe_code)] +#![warn(clippy::all)] +#![allow(clippy::pedantic, clippy::type_complexity)] +#![warn( + missing_debug_implementations, + missing_copy_implementations, + rust_2018_idioms +)] + +//! # Barter-Data +//! A high-performance WebSocket integration library for streaming public market data from leading cryptocurrency +//! exchanges - batteries included. It is: +//! * **Easy**: Barter-Data's simple [`StreamBuilder`](streams::builder::StreamBuilder) and [`DynamicStreams`](streams::builder::DynamicStreams) interface allows for easy & quick setup (see example below and /examples!). +//! * **Normalised**: Barter-Data's unified interface for consuming public WebSocket data means every Exchange returns a normalised data model. +//! * **Real-Time**: Barter-Data utilises real-time WebSocket integrations enabling the consumption of normalised tick-by-tick data. +//! * **Extensible**: Barter-Data is highly extensible, and therefore easy to contribute to with coding new integrations! +//! +//! ## User API +//! - [`StreamBuilder`](streams::builder::StreamBuilder) for initialising [`MarketStream`]s of specific data kinds. +//! - [`DynamicStreams`](streams::builder::DynamicStreams) for initialising [`MarketStream`]s of every supported data kind at once. +//! - Define what exchange market data you want to stream using the [`Subscription`] type. +//! - Pass [`Subscription`]s to the [`StreamBuilder::subscribe`](streams::builder::StreamBuilder::subscribe) or [`DynamicStreams::init`](streams::builder::DynamicStreams::init) methods. +//! - Each call to the [`StreamBuilder::subscribe`](streams::builder::StreamBuilder::subscribe) (or each batch passed to the [`DynamicStreams::init`](streams::builder::DynamicStreams::init)) +//! method opens a new WebSocket connection to the exchange - giving you full control. +//! +//! ## Examples +//! For a comprehensive collection of examples, see the /examples directory. +//! +//! ### Multi Exchange Public Trades +//! ```rust,no_run +//! use barter_data::exchange::gateio::spot::GateioSpot; +//! use barter_data::{ +//! exchange::{ +//! binance::{futures::BinanceFuturesUsd, spot::BinanceSpot}, +//! coinbase::Coinbase, +//! okx::Okx, +//! }, +//! streams::Streams, +//! subscription::trade::PublicTrades, +//! }; +//! use barter_integration::model::instrument::kind::InstrumentKind; +//! use futures::StreamExt; +//! +//! #[tokio::main] +//! async fn main() { +//! // Initialise PublicTrades Streams for various exchanges +//! // '--> each call to StreamBuilder::subscribe() initialises a separate WebSocket connection +//! let streams = Streams::::builder() +//! .subscribe([ +//! (BinanceSpot::default(), "btc", "usdt", InstrumentKind::Spot, PublicTrades), +//! (BinanceSpot::default(), "eth", "usdt", InstrumentKind::Spot, PublicTrades), +//! ]) +//! .subscribe([ +//! (BinanceFuturesUsd::default(), "btc", "usdt", InstrumentKind::Perpetual, PublicTrades), +//! (BinanceFuturesUsd::default(), "eth", "usdt", InstrumentKind::Perpetual, PublicTrades), +//! ]) +//! .subscribe([ +//! (Coinbase, "btc", "usd", InstrumentKind::Spot, PublicTrades), +//! (Coinbase, "eth", "usd", InstrumentKind::Spot, PublicTrades), +//! ]) +//! .subscribe([ +//! (GateioSpot::default(), "btc", "usdt", InstrumentKind::Spot, PublicTrades), +//! (GateioSpot::default(), "eth", "usdt", InstrumentKind::Spot, PublicTrades), +//! ]) +//! .subscribe([ +//! (Okx, "btc", "usdt", InstrumentKind::Spot, PublicTrades), +//! (Okx, "eth", "usdt", InstrumentKind::Spot, PublicTrades), +//! (Okx, "btc", "usdt", InstrumentKind::Perpetual, PublicTrades), +//! (Okx, "eth", "usdt", InstrumentKind::Perpetual, PublicTrades), +//! ]) +//! .init() +//! .await +//! .unwrap(); +//! +//! // Join all exchange PublicTrades streams into a single tokio_stream::StreamMap +//! // Notes: +//! // - Use `streams.select(ExchangeId)` to interact with the individual exchange streams! +//! // - Use `streams.join()` to join all exchange streams into a single mpsc::UnboundedReceiver! +//! let mut joined_stream = streams.join_map().await; +//! +//! while let Some((exchange, trade)) = joined_stream.next().await { +//! println!("Exchange: {exchange}, Market: {trade:?}"); +//! } +//! } +//! ``` + +use crate::{ + error::DataError, + event::MarketEvent, + exchange::{Connector, ExchangeId, PingInterval}, + instrument::InstrumentData, + subscriber::Subscriber, + subscription::{Subscription, SubscriptionKind}, + transformer::ExchangeTransformer, +}; +use async_trait::async_trait; +use barter_integration::{ + protocol::websocket::{WebSocketParser, WsMessage, WsSink, WsStream}, + ExchangeStream, +}; +use futures::{SinkExt, Stream, StreamExt}; +use tokio::sync::mpsc; +use tracing::{debug, error}; + +/// All [`Error`](std::error::Error)s generated in Barter-Data. +pub mod error; + +/// Defines the generic [`MarketEvent`](MarketEvent) used in every [`MarketStream`]. +pub mod event; + +/// [`Connector`] implementations for each exchange. +pub mod exchange; + +/// High-level API types used for building [`MarketStream`]s from collections +/// of Barter [`Subscription`]s. +pub mod streams; + +/// [`Subscriber`], [`SubscriptionMapper`](subscriber::mapper::SubscriptionMapper) and +/// [`SubscriptionValidator`](subscriber::validator::SubscriptionValidator) traits that define how a +/// [`Connector`] will subscribe to exchange [`MarketStream`]s. +/// +/// Standard implementations for subscribing to WebSocket [`MarketStream`]s are included. +pub mod subscriber; + +/// Types that communicate the type of each [`MarketStream`] to initialise, and what normalised +/// Barter output type the exchange will be transformed into. +pub mod subscription; + +/// [`InstrumentData`] trait for instrument describing data. +pub mod instrument; + +/// Generic [`ExchangeTransformer`] implementations used by [`MarketStream`]s to translate exchange +/// specific types to normalised Barter types. +/// +/// Standard implementations that work for most exchanges are included such as:
+/// - [`StatelessTransformer`](transformer::stateless::StatelessTransformer) for +/// [`PublicTrades`](subscription::trade::PublicTrades) +/// and [`OrderBooksL1`](subscription::book::OrderBooksL1) streams.
+/// - [`MultiBookTransformer`](transformer::book::MultiBookTransformer) for +/// [`OrderBooksL2`](subscription::book::OrderBooksL2) and +/// [`OrderBooksL3`](subscription::book::OrderBooksL3) streams. +pub mod transformer; + +/// Convenient type alias for an [`ExchangeStream`] utilising a tungstenite +/// [`WebSocket`](barter_integration::protocol::websocket::WebSocket). +pub type ExchangeWsStream = ExchangeStream; + +/// Defines a generic identification type for the implementor. +pub trait Identifier { + fn id(&self) -> T; +} + +/// [`Stream`] that yields [`Market`](MarketEvent) events. The type of [`Market`](MarketEvent) +/// depends on the provided [`SubscriptionKind`] of the passed [`Subscription`]s. +#[async_trait] +pub trait MarketStream +where + Self: Stream, DataError>> + + Send + + Sized + + Unpin, + Exchange: Connector, + Instrument: InstrumentData, + Kind: SubscriptionKind, +{ + async fn init( + subscriptions: &[Subscription], + ) -> Result + where + Subscription: + Identifier + Identifier; +} + +#[async_trait] +impl MarketStream + for ExchangeWsStream +where + Exchange: Connector + Send + Sync, + Instrument: InstrumentData, + Kind: SubscriptionKind + Send + Sync, + Transformer: ExchangeTransformer + Send, + Kind::Event: Send, +{ + async fn init( + subscriptions: &[Subscription], + ) -> Result + where + Subscription: + Identifier + Identifier, + { + // Connect & subscribe + let (websocket, map) = Exchange::Subscriber::subscribe(subscriptions).await?; + + // Split WebSocket into WsStream & WsSink components + let (ws_sink, ws_stream) = websocket.split(); + + // Spawn task to distribute Transformer messages (eg/ custom pongs) to the exchange + let (ws_sink_tx, ws_sink_rx) = mpsc::unbounded_channel(); + tokio::spawn(distribute_messages_to_exchange( + Exchange::ID, + ws_sink, + ws_sink_rx, + )); + + // Spawn optional task to distribute custom application-level pings to the exchange + if let Some(ping_interval) = Exchange::ping_interval() { + tokio::spawn(schedule_pings_to_exchange( + Exchange::ID, + ws_sink_tx.clone(), + ping_interval, + )); + } + + // Construct Transformer associated with this Exchange and SubscriptionKind + let transformer = Transformer::new(ws_sink_tx, map).await?; + + Ok(ExchangeWsStream::new(ws_stream, transformer)) + } +} + +/// Transmit [`WsMessage`]s sent from the [`ExchangeTransformer`] to the exchange via +/// the [`WsSink`]. +/// +/// **Note:** +/// ExchangeTransformer is operating in a synchronous trait context so we use this separate task +/// to avoid adding `#[\async_trait\]` to the transformer - this avoids allocations. +pub async fn distribute_messages_to_exchange( + exchange: ExchangeId, + mut ws_sink: WsSink, + mut ws_sink_rx: mpsc::UnboundedReceiver, +) { + while let Some(message) = ws_sink_rx.recv().await { + if let Err(error) = ws_sink.send(message).await { + if barter_integration::protocol::websocket::is_websocket_disconnected(&error) { + break; + } + + // Log error only if WsMessage failed to send over a connected WebSocket + error!( + %exchange, + %error, + "failed to send output message to the exchange via WsSink" + ); + } + } +} + +/// Schedule the sending of custom application-level ping [`WsMessage`]s to the exchange using +/// the provided [`PingInterval`]. +/// +/// **Notes:** +/// - This is only used for those exchanges that require custom application-level pings. +/// - This is additional to the protocol-level pings already handled by `tokio_tungstenite`. +pub async fn schedule_pings_to_exchange( + exchange: ExchangeId, + ws_sink_tx: mpsc::UnboundedSender, + PingInterval { mut interval, ping }: PingInterval, +) { + loop { + // Wait for next scheduled ping + interval.tick().await; + + // Construct exchange custom application-level ping payload + let payload = ping(); + debug!(%exchange, %payload, "sending custom application-level ping to exchange"); + + if ws_sink_tx.send(payload).is_err() { + break; + } + } +} diff --git a/barter-data/src/streams/builder/dynamic.rs b/barter-data/src/streams/builder/dynamic.rs new file mode 100644 index 00000000..65d9ccb5 --- /dev/null +++ b/barter-data/src/streams/builder/dynamic.rs @@ -0,0 +1,531 @@ +use crate::{ + error::DataError, + event::MarketEvent, + exchange::{ + binance::{futures::BinanceFuturesUsd, market::BinanceMarket, spot::BinanceSpot}, + bitfinex::{market::BitfinexMarket, Bitfinex}, + bitmex::{market::BitmexMarket, Bitmex}, + bybit::{futures::BybitPerpetualsUsd, market::BybitMarket, spot::BybitSpot}, + coinbase::{market::CoinbaseMarket, Coinbase}, + gateio::{ + future::{GateioFuturesBtc, GateioFuturesUsd}, + market::GateioMarket, + option::GateioOptions, + perpetual::{GateioPerpetualsBtc, GateioPerpetualsUsd}, + spot::GateioSpot, + }, + kraken::{market::KrakenMarket, Kraken}, + okx::{market::OkxMarket, Okx}, + ExchangeId, + }, + instrument::InstrumentData, + streams::{builder::ExchangeChannel, consumer::consume}, + subscription::{ + book::{OrderBook, OrderBookL1, OrderBooksL1}, + liquidation::{Liquidation, Liquidations}, + trade::{PublicTrade, PublicTrades}, + SubKind, Subscription, + }, + Identifier, +}; +use barter_integration::{error::SocketError, Validator}; +use futures::{ + stream::{select_all, SelectAll}, + Stream, StreamExt, +}; +use itertools::Itertools; +use std::collections::HashMap; +use tokio_stream::wrappers::UnboundedReceiverStream; +use vecmap::VecMap; + +#[derive(Debug)] +pub struct DynamicStreams { + pub trades: VecMap>>, + pub l1s: VecMap>>, + pub l2s: VecMap>>, + pub liquidations: + VecMap>>, +} + +impl DynamicStreams { + /// Initialise a set of `Streams` by providing one or more [`Subscription`] batches. + /// + /// Each batch (ie/ `impl Iterator`) will initialise at-least-one + /// WebSocket `Stream` under the hood. If the batch contains more-than-one [`ExchangeId`] and/or + /// [`SubKind`], it will be further split under the hood for compile-time reasons. + /// + /// ## Examples + /// Please see barter-data-rs/examples/dynamic_multi_stream_multi_exchange.rs for a + /// comprehensive example of how to use this market data stream initialiser. + pub async fn init( + subscription_batches: SubBatchIter, + ) -> Result + where + SubBatchIter: IntoIterator, + SubIter: IntoIterator, + Sub: Into>, + Instrument: InstrumentData + Ord + 'static, + InstrumentId: Clone + Send, + Subscription: Identifier, + Subscription: Identifier, + Subscription: Identifier, + Subscription: Identifier, + Subscription: Identifier, + Subscription: Identifier, + Subscription: Identifier, + Subscription: Identifier, + Subscription: Identifier, + Subscription: Identifier, + Subscription: Identifier, + Subscription: Identifier, + Subscription: Identifier, + Subscription: Identifier, + Subscription: Identifier, + Subscription: Identifier, + Subscription: Identifier, + Subscription: Identifier, + Subscription: Identifier, + Subscription: Identifier, + { + // Validate & dedup Subscription batches + let batches = validate_batches(subscription_batches)?; + + let mut channels = Channels::::default(); + + for mut batch in batches { + batch.sort_unstable_by_key(|sub| (sub.exchange, sub.kind)); + let by_exchange_by_sub_kind = + batch.into_iter().chunk_by(|sub| (sub.exchange, sub.kind)); + + for ((exchange, sub_kind), subs) in by_exchange_by_sub_kind.into_iter() { + match (exchange, sub_kind) { + (ExchangeId::BinanceSpot, SubKind::PublicTrades) => { + tokio::spawn(consume::( + subs.into_iter() + .map(|sub| { + Subscription::new( + BinanceSpot::default(), + sub.instrument, + PublicTrades, + ) + }) + .collect(), + channels.trades.entry(exchange).or_default().tx.clone(), + )); + } + (ExchangeId::BinanceSpot, SubKind::OrderBooksL1) => { + tokio::spawn(consume::( + subs.into_iter() + .map(|sub| { + Subscription::new( + BinanceSpot::default(), + sub.instrument, + OrderBooksL1, + ) + }) + .collect(), + channels.l1s.entry(exchange).or_default().tx.clone(), + )); + } + (ExchangeId::BinanceFuturesUsd, SubKind::PublicTrades) => { + tokio::spawn(consume::( + subs.into_iter() + .map(|sub| { + Subscription::new( + BinanceFuturesUsd::default(), + sub.instrument, + PublicTrades, + ) + }) + .collect(), + channels.trades.entry(exchange).or_default().tx.clone(), + )); + } + (ExchangeId::BinanceFuturesUsd, SubKind::OrderBooksL1) => { + tokio::spawn(consume::( + subs.into_iter() + .map(|sub| { + Subscription::<_, Instrument, _>::new( + BinanceFuturesUsd::default(), + sub.instrument, + OrderBooksL1, + ) + }) + .collect(), + channels.l1s.entry(exchange).or_default().tx.clone(), + )); + } + (ExchangeId::BinanceFuturesUsd, SubKind::Liquidations) => { + tokio::spawn(consume::( + subs.into_iter() + .map(|sub| { + Subscription::<_, Instrument, _>::new( + BinanceFuturesUsd::default(), + sub.instrument, + Liquidations, + ) + }) + .collect(), + channels + .liquidations + .entry(exchange) + .or_default() + .tx + .clone(), + )); + } + (ExchangeId::Bitfinex, SubKind::PublicTrades) => { + tokio::spawn(consume::( + subs.into_iter() + .map(|sub| { + Subscription::new(Bitfinex, sub.instrument, PublicTrades) + }) + .collect(), + channels.trades.entry(exchange).or_default().tx.clone(), + )); + } + (ExchangeId::Bitmex, SubKind::PublicTrades) => { + tokio::spawn(consume::( + subs.into_iter() + .map(|sub| Subscription::new(Bitmex, sub.instrument, PublicTrades)) + .collect(), + channels.trades.entry(exchange).or_default().tx.clone(), + )); + } + (ExchangeId::BybitSpot, SubKind::PublicTrades) => { + tokio::spawn(consume::( + subs.into_iter() + .map(|sub| { + Subscription::new( + BybitSpot::default(), + sub.instrument, + PublicTrades, + ) + }) + .collect(), + channels.trades.entry(exchange).or_default().tx.clone(), + )); + } + (ExchangeId::BybitPerpetualsUsd, SubKind::PublicTrades) => { + tokio::spawn(consume::( + subs.into_iter() + .map(|sub| { + Subscription::new( + BybitPerpetualsUsd::default(), + sub.instrument, + PublicTrades, + ) + }) + .collect(), + channels.trades.entry(exchange).or_default().tx.clone(), + )); + } + (ExchangeId::Coinbase, SubKind::PublicTrades) => { + tokio::spawn(consume::( + subs.into_iter() + .map(|sub| { + Subscription::new(Coinbase, sub.instrument, PublicTrades) + }) + .collect(), + channels.trades.entry(exchange).or_default().tx.clone(), + )); + } + (ExchangeId::GateioSpot, SubKind::PublicTrades) => { + tokio::spawn(consume::( + subs.into_iter() + .map(|sub| { + Subscription::new( + GateioSpot::default(), + sub.instrument, + PublicTrades, + ) + }) + .collect(), + channels.trades.entry(exchange).or_default().tx.clone(), + )); + } + (ExchangeId::GateioFuturesUsd, SubKind::PublicTrades) => { + tokio::spawn(consume::( + subs.into_iter() + .map(|sub| { + Subscription::new( + GateioFuturesUsd::default(), + sub.instrument, + PublicTrades, + ) + }) + .collect(), + channels.trades.entry(exchange).or_default().tx.clone(), + )); + } + (ExchangeId::GateioFuturesBtc, SubKind::PublicTrades) => { + tokio::spawn(consume::( + subs.into_iter() + .map(|sub| { + Subscription::new( + GateioFuturesBtc::default(), + sub.instrument, + PublicTrades, + ) + }) + .collect(), + channels.trades.entry(exchange).or_default().tx.clone(), + )); + } + (ExchangeId::GateioPerpetualsUsd, SubKind::PublicTrades) => { + tokio::spawn(consume::( + subs.into_iter() + .map(|sub| { + Subscription::new( + GateioPerpetualsUsd::default(), + sub.instrument, + PublicTrades, + ) + }) + .collect(), + channels.trades.entry(exchange).or_default().tx.clone(), + )); + } + (ExchangeId::GateioPerpetualsBtc, SubKind::PublicTrades) => { + tokio::spawn(consume::( + subs.into_iter() + .map(|sub| { + Subscription::new( + GateioPerpetualsBtc::default(), + sub.instrument, + PublicTrades, + ) + }) + .collect(), + channels.trades.entry(exchange).or_default().tx.clone(), + )); + } + (ExchangeId::GateioOptions, SubKind::PublicTrades) => { + tokio::spawn(consume::( + subs.into_iter() + .map(|sub| { + Subscription::new( + GateioOptions::default(), + sub.instrument, + PublicTrades, + ) + }) + .collect(), + channels.trades.entry(exchange).or_default().tx.clone(), + )); + } + (ExchangeId::Kraken, SubKind::PublicTrades) => { + tokio::spawn(consume::( + subs.into_iter() + .map(|sub| Subscription::new(Kraken, sub.instrument, PublicTrades)) + .collect(), + channels.trades.entry(exchange).or_default().tx.clone(), + )); + } + (ExchangeId::Kraken, SubKind::OrderBooksL1) => { + tokio::spawn(consume::( + subs.into_iter() + .map(|sub| Subscription::new(Kraken, sub.instrument, OrderBooksL1)) + .collect(), + channels.l1s.entry(exchange).or_default().tx.clone(), + )); + } + (ExchangeId::Okx, SubKind::PublicTrades) => { + tokio::spawn(consume::( + subs.into_iter() + .map(|sub| Subscription::new(Okx, sub.instrument, PublicTrades)) + .collect(), + channels.trades.entry(exchange).or_default().tx.clone(), + )); + } + (exchange, sub_kind) => { + return Err(DataError::Unsupported { exchange, sub_kind }) + } + } + } + } + + Ok(Self { + trades: channels + .trades + .into_iter() + .map(|(exchange, channel)| (exchange, UnboundedReceiverStream::new(channel.rx))) + .collect(), + l1s: channels + .l1s + .into_iter() + .map(|(exchange, channel)| (exchange, UnboundedReceiverStream::new(channel.rx))) + .collect(), + l2s: channels + .l2s + .into_iter() + .map(|(exchange, channel)| (exchange, UnboundedReceiverStream::new(channel.rx))) + .collect(), + liquidations: channels + .liquidations + .into_iter() + .map(|(exchange, channel)| (exchange, UnboundedReceiverStream::new(channel.rx))) + .collect(), + }) + } + + /// Remove an exchange [`PublicTrade`] `Stream` from the [`DynamicStreams`] collection. + /// + /// Note that calling this method will permanently remove this `Stream` from [`Self`]. + pub fn select_trades( + &mut self, + exchange: ExchangeId, + ) -> Option>> { + self.trades.remove(&exchange) + } + + /// Select and merge every exchange [`PublicTrade`] `Stream` using + /// [`SelectAll`](futures_util::stream::select_all). + pub fn select_all_trades( + &mut self, + ) -> SelectAll>> { + select_all(std::mem::take(&mut self.trades).into_values()) + } + + /// Remove an exchange [`OrderBookL1`] `Stream` from the [`DynamicStreams`] collection. + /// + /// Note that calling this method will permanently remove this `Stream` from [`Self`]. + pub fn select_l1s( + &mut self, + exchange: ExchangeId, + ) -> Option>> { + self.l1s.remove(&exchange) + } + + /// Select and merge every exchange [`OrderBookL1`] `Stream` using + /// [`SelectAll`](futures_util::stream::select_all). + pub fn select_all_l1s( + &mut self, + ) -> SelectAll>> { + select_all(std::mem::take(&mut self.l1s).into_values()) + } + + /// Remove an exchange [`OrderBook`] `Stream` from the [`DynamicStreams`] collection. + /// + /// Note that calling this method will permanently remove this `Stream` from [`Self`]. + pub fn select_l2s( + &mut self, + exchange: ExchangeId, + ) -> Option>> { + self.l2s.remove(&exchange) + } + + /// Select and merge every exchange [`OrderBook`] `Stream` using + /// [`SelectAll`](futures_util::stream::select_all). + pub fn select_all_l2s( + &mut self, + ) -> SelectAll>> { + select_all(std::mem::take(&mut self.l2s).into_values()) + } + + /// Remove an exchange [`Liquidation`] `Stream` from the [`DynamicStreams`] collection. + /// + /// Note that calling this method will permanently remove this `Stream` from [`Self`]. + pub fn select_liquidations( + &mut self, + exchange: ExchangeId, + ) -> Option>> { + self.liquidations.remove(&exchange) + } + + /// Select and merge every exchange [`Liquidation`] `Stream` using + /// [`SelectAll`](futures_util::stream::select_all). + pub fn select_all_liquidations( + &mut self, + ) -> SelectAll>> { + select_all(std::mem::take(&mut self.liquidations).into_values()) + } + + /// Select and merge every exchange `Stream` for every data type using + /// [`SelectAll`](futures_util::stream::select_all). + /// + /// Note that using [`MarketEvent`] as the `Output` is suitable for most + /// use cases. + pub fn select_all(self) -> impl Stream + where + InstrumentId: Send + 'static, + Output: 'static, + MarketEvent: Into, + MarketEvent: Into, + MarketEvent: Into, + MarketEvent: Into, + { + let Self { + trades, + l1s, + l2s, + liquidations, + } = self; + + let trades = trades + .into_values() + .map(|stream| stream.map(MarketEvent::into).boxed()); + + let l1s = l1s + .into_values() + .map(|stream| stream.map(MarketEvent::into).boxed()); + + let l2s = l2s + .into_values() + .map(|stream| stream.map(MarketEvent::into).boxed()); + + let liquidations = liquidations + .into_values() + .map(|stream| stream.map(MarketEvent::into).boxed()); + + let all = trades.chain(l1s).chain(l2s).chain(liquidations); + + select_all(all) + } +} + +pub fn validate_batches( + batches: SubBatchIter, +) -> Result>>, DataError> +where + SubBatchIter: IntoIterator, + SubIter: IntoIterator, + Sub: Into>, + Instrument: InstrumentData + Ord, +{ + batches + .into_iter() + .map(|batch| { + // Validate Subscriptions + let mut batch = batch + .into_iter() + .map(Sub::into) + .map(Validator::validate) + .collect::, SocketError>>()?; + + // Remove duplicate Subscriptions + batch.sort(); + batch.dedup(); + + Ok(batch) + }) + .collect() +} + +struct Channels { + trades: HashMap>>, + l1s: HashMap>>, + l2s: HashMap>>, + liquidations: HashMap>>, +} + +impl Default for Channels { + fn default() -> Self { + Self { + trades: Default::default(), + l1s: Default::default(), + l2s: Default::default(), + liquidations: Default::default(), + } + } +} diff --git a/barter-data/src/streams/builder/mod.rs b/barter-data/src/streams/builder/mod.rs new file mode 100644 index 00000000..c8023fb4 --- /dev/null +++ b/barter-data/src/streams/builder/mod.rs @@ -0,0 +1,232 @@ +use super::{consumer::consume, Streams}; +use crate::{ + error::DataError, + event::MarketEvent, + exchange::{Connector, ExchangeId, StreamSelector}, + subscription::{Subscription, SubscriptionKind}, + Identifier, +}; +use barter_integration::{error::SocketError, model::instrument::Instrument, Validator}; +use std::{collections::HashMap, fmt::Debug, future::Future, pin::Pin}; +use tokio::sync::mpsc; + +/// Defines the [`MultiStreamBuilder`](multi::MultiStreamBuilder) API for ergonomically +/// initialising a common [`Streams`](Streams) from multiple +/// [`StreamBuilder`](StreamBuilder)s. +pub mod multi; + +pub mod dynamic; + +/// Communicative type alias representing the [`Future`] result of a [`Subscription`] [`validate`] +/// call generated whilst executing [`StreamBuilder::subscribe`]. +pub type SubscribeFuture = Pin>>>; + +/// Builder to configure and initialise a [`Streams`](Streams) instance +/// for a specific [`SubscriptionKind`]. +#[derive(Default)] +pub struct StreamBuilder +where + Kind: SubscriptionKind, +{ + pub channels: HashMap>>, + pub futures: Vec, +} + +impl Debug for StreamBuilder +where + Kind: SubscriptionKind, +{ + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("StreamBuilder") + .field("channels", &self.channels) + .field("num_futures", &self.futures.len()) + .finish() + } +} + +impl StreamBuilder +where + Kind: SubscriptionKind, +{ + /// Construct a new [`Self`]. + pub fn new() -> Self { + Self { + channels: HashMap::new(), + futures: Vec::new(), + } + } + + /// Add a collection of [`Subscription`]s to the [`StreamBuilder`] that will be actioned on + /// a distinct [`WebSocket`](barter_integration::protocol::websocket::WebSocket) connection. + /// + /// Note that [`Subscription`]s are not actioned until the + /// [`init()`](StreamBuilder::init()) method is invoked. + pub fn subscribe(mut self, subscriptions: SubIter) -> Self + where + SubIter: IntoIterator, + Sub: Into>, + Exchange: StreamSelector + Ord + Send + Sync + 'static, + Kind: Ord + Send + Sync + 'static, + Kind::Event: Send, + Subscription: + Identifier + Identifier, + { + // Construct Vec from input SubIter + let mut subscriptions = subscriptions.into_iter().map(Sub::into).collect::>(); + + // Acquire channel Sender to send Market from consumer loop to user + // '--> Add ExchangeChannel Entry if this Exchange <--> SubscriptionKind combination is new + let exchange_tx = self.channels.entry(Exchange::ID).or_default().tx.clone(); + + // Add Future that once awaited will yield the Result<(), SocketError> of subscribing + self.futures.push(Box::pin(async move { + // Validate Subscriptions + validate(&subscriptions)?; + + // Remove duplicate Subscriptions + subscriptions.sort(); + subscriptions.dedup(); + + // Spawn a MarketStream consumer loop with these Subscriptions + tokio::spawn(consume(subscriptions, exchange_tx)); + + Ok(()) + })); + + self + } + + /// Spawn a [`MarketEvent`](MarketEvent) consumer loop for each collection of + /// [`Subscription`]s added to [`StreamBuilder`] via the + /// [`subscribe()`](StreamBuilder::subscribe()) method. + /// + /// Each consumer loop distributes consumed [`MarketEvents`](MarketEvent) to + /// the [`Streams`] `HashMap` returned by this method. + pub async fn init(self) -> Result>, DataError> { + // Await Stream initialisation perpetual and ensure success + futures::future::try_join_all(self.futures).await?; + + // Construct Streams using each ExchangeChannel receiver + Ok(Streams { + streams: self + .channels + .into_iter() + .map(|(exchange, channel)| (exchange, channel.rx)) + .collect(), + }) + } +} + +/// Convenient type that holds the [`mpsc::UnboundedSender`] and [`mpsc::UnboundedReceiver`] for a +/// [`MarketEvent`](MarketEvent) channel. +#[derive(Debug)] +pub struct ExchangeChannel { + tx: mpsc::UnboundedSender, + rx: mpsc::UnboundedReceiver, +} + +impl ExchangeChannel { + /// Construct a new [`Self`]. + pub fn new() -> Self { + let (tx, rx) = mpsc::unbounded_channel(); + Self { tx, rx } + } +} + +impl Default for ExchangeChannel { + fn default() -> Self { + Self::new() + } +} + +/// Validate the provided collection of [`Subscription`]s, ensuring that the associated exchange +/// supports every [`Subscription`] [`InstrumentKind`](barter_integration::model::InstrumentKind). +pub fn validate( + subscriptions: &[Subscription], +) -> Result<(), DataError> +where + Exchange: Connector, +{ + // Ensure at least one Subscription has been provided + if subscriptions.is_empty() { + return Err(DataError::Socket(SocketError::Subscribe( + "StreamBuilder contains no Subscription to action".to_owned(), + ))); + } + + // Validate the Exchange supports each Subscription InstrumentKind + subscriptions + .iter() + .map(|subscription| subscription.validate()) + .collect::, SocketError>>()?; + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{exchange::coinbase::Coinbase, subscription::trade::PublicTrades}; + use barter_integration::model::instrument::{kind::InstrumentKind, Instrument}; + + #[test] + fn test_validate() { + struct TestCase { + input: Vec>, + expected: Result>, SocketError>, + } + + let cases = vec![ + TestCase { + // TC0: Invalid Vec w/ empty vector + input: vec![], + expected: Err(SocketError::Subscribe("".to_string())), + }, + TestCase { + // TC1: Valid Vec w/ valid Coinbase Spot sub + input: vec![Subscription::from(( + Coinbase, + "base", + "quote", + InstrumentKind::Spot, + PublicTrades, + ))], + expected: Ok(vec![Subscription::from(( + Coinbase, + "base", + "quote", + InstrumentKind::Spot, + PublicTrades, + ))]), + }, + TestCase { + // TC2: Invalid StreamBuilder w/ invalid Coinbase FuturePerpetual sub + input: vec![Subscription::from(( + Coinbase, + "base", + "quote", + InstrumentKind::Perpetual, + PublicTrades, + ))], + expected: Err(SocketError::Subscribe("".to_string())), + }, + ]; + + for (index, test) in cases.into_iter().enumerate() { + let actual = validate(&test.input); + + match (actual, test.expected) { + (Ok(_), Ok(_)) => { + // Test passed + } + (Err(_), Err(_)) => { + // Test passed + } + (actual, expected) => { + // Test failed + panic!("TC{index} failed because actual != expected. \nActual: {actual:?}\nExpected: {expected:?}\n"); + } + } + } + } +} diff --git a/barter-data/src/streams/builder/multi.rs b/barter-data/src/streams/builder/multi.rs new file mode 100644 index 00000000..2c4e45b4 --- /dev/null +++ b/barter-data/src/streams/builder/multi.rs @@ -0,0 +1,110 @@ +use super::{ExchangeChannel, StreamBuilder, Streams}; +use crate::{ + error::DataError, event::MarketEvent, exchange::ExchangeId, subscription::SubscriptionKind, +}; +use barter_integration::model::instrument::Instrument; +use std::{collections::HashMap, fmt::Debug, future::Future, pin::Pin}; + +/// Communicative type alias representing the [`Future`] result of a [`StreamBuilder::init`] call +/// generated whilst executing [`MultiStreamBuilder::add`]. +pub type BuilderInitFuture = Pin>>>; + +/// Builder to configure and initialise a common [`Streams`](Streams) instance from +/// multiple [`StreamBuilder`](StreamBuilder)s. +#[derive(Default)] +pub struct MultiStreamBuilder { + pub channels: HashMap>, + pub futures: Vec, +} + +impl Debug for MultiStreamBuilder +where + Output: Debug, +{ + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("MultiStreamBuilder") + .field("channels", &self.channels) + .field("num_futures", &self.futures.len()) + .finish() + } +} + +impl MultiStreamBuilder { + /// Construct a new [`Self`]. + pub fn new() -> Self { + Self { + channels: HashMap::new(), + futures: Vec::new(), + } + } + + /// Add a [`StreamBuilder`](StreamBuilder) to the [`MultiStreamBuilder`]. Creates a + /// [`Future`] that calls [`StreamBuilder::init`] and maps the [`SubscriptionKind::Event`](SubscriptionKind) + /// into a common `Output`. + /// + /// Note that the created [`Future`] is not awaited until the [`MultiStreamBuilder::init`] + /// method is invoked. + #[allow(clippy::should_implement_trait)] + pub fn add(mut self, builder: StreamBuilder) -> Self + where + Output: From> + Send + 'static, + Kind: SubscriptionKind + 'static, + Kind::Event: Send, + { + // Allocate HashMap to hold the exchange_tx for each StreamBuilder exchange present + let mut exchange_txs = HashMap::with_capacity(builder.channels.len()); + + // Iterate over each StreamBuilder exchange present + for exchange in builder.channels.keys().copied() { + // Insert ExchangeChannel Entry to Self for each exchange + let exchange_tx = self.channels.entry(exchange).or_default().tx.clone(); + + // Insert new exchange_tx into HashMap for each exchange + exchange_txs.insert(exchange, exchange_tx); + } + + // Init Streams & send mapped Outputs to the associated exchange_tx + self.futures.push(Box::pin(async move { + builder + .init() + .await? + .streams + .into_iter() + .for_each(|(exchange, mut exchange_rx)| { + // Remove exchange_tx from HashMap that's associated with this tuple: + // (ExchangeId, exchange_rx>) + let exchange_tx = exchange_txs + .remove(&exchange) + .expect("all exchange_txs should be present here"); + + // Task to receive MarketEvent and send Outputs via exchange_tx + tokio::spawn(async move { + while let Some(event) = exchange_rx.recv().await { + let _ = exchange_tx.send(Output::from(event)); + } + }); + }); + + Ok(()) + })); + + self + } + + /// Initialise each [`StreamBuilder`](StreamBuilder) that was added to the + /// [`MultiStreamBuilder`] and map all [`Streams`](Streams) into a common + /// [`Streams`](Streams). + pub async fn init(self) -> Result, DataError> { + // Await Stream initialisation perpetual and ensure success + futures::future::try_join_all(self.futures).await?; + + // Construct Streams using each ExchangeChannel receiver + Ok(Streams { + streams: self + .channels + .into_iter() + .map(|(exchange, channel)| (exchange, channel.rx)) + .collect(), + }) + } +} diff --git a/barter-data/src/streams/consumer.rs b/barter-data/src/streams/consumer.rs new file mode 100644 index 00000000..b86a563a --- /dev/null +++ b/barter-data/src/streams/consumer.rs @@ -0,0 +1,123 @@ +use crate::{ + error::DataError, + event::MarketEvent, + exchange::StreamSelector, + instrument::InstrumentData, + subscription::{Subscription, SubscriptionKind}, + Identifier, MarketStream, +}; +use futures::StreamExt; +use std::time::Duration; +use tokio::sync::mpsc; +use tracing::{debug, error, info, warn}; + +/// Initial duration that the [`consume`] function should wait after disconnecting before attempting +/// to re-initialise a [`MarketStream`]. This duration will increase exponentially as a result +/// of repeated disconnections with re-initialisation failures. +pub const STARTING_RECONNECT_BACKOFF_MS: u64 = 125; + +/// Central [`MarketEvent`](MarketEvent) consumer loop. +/// +/// Initialises an exchange [`MarketStream`] using a collection of [`Subscription`]s. Consumed +/// events are distributed downstream via the `exchange_tx mpsc::UnboundedSender`. A re-connection +/// mechanism with an exponential backoff policy is utilised to ensure maximum up-time. +pub async fn consume( + subscriptions: Vec>, + exchange_tx: mpsc::UnboundedSender>, +) -> Result<(), DataError> +where + Exchange: StreamSelector, + Kind: SubscriptionKind, + Instrument: InstrumentData, + Subscription: + Identifier + Identifier, +{ + // Determine ExchangeId associated with these Subscriptions + let exchange = Exchange::ID; + + info!( + %exchange, + ?subscriptions, + policy = "retry connection with exponential backoff", + "MarketStream consumer loop running", + ); + + // Consumer loop retry parameters + let mut attempt: u32 = 0; + let mut backoff_ms: u64 = STARTING_RECONNECT_BACKOFF_MS; + + 'retry: loop { + // Increment retry parameters at start of every iteration + attempt += 1; + backoff_ms *= 2; + info!(%exchange, attempt, "attempting to initialise MarketStream"); + + // Attempt to initialise MarketStream: if it fails on first attempt return DataError + let mut stream = match Exchange::Stream::init(&subscriptions).await { + Ok(stream) => { + info!(%exchange, attempt, "successfully initialised MarketStream"); + attempt = 0; + backoff_ms = STARTING_RECONNECT_BACKOFF_MS; + stream + } + Err(error) => { + error!(%exchange, attempt, ?error, "failed to initialise MarketStream"); + + // Exit function if Stream::init failed the first attempt, else retry + if attempt == 1 { + return Err(error); + } else { + continue; + } + } + }; + + // Consume Result, DataError> from MarketStream + while let Some(event_result) = stream.next().await { + match event_result { + // If Ok: send MarketEvent to exchange receiver + Ok(market_event) => { + if let Err(error) = exchange_tx.send(market_event) { + debug!( + payload = ?error.0, + why = "receiver dropped", + action = "shutting down Stream", + "failed to send Event to Exchange receiver" + ); + break 'retry Ok(()); + } + } + // If terminal DataError: break + Err(error) if error.is_terminal() => { + error!( + %exchange, + %error, + action = "re-initialising Stream", + "consumed DataError from MarketStream", + ); + break; + } + + // If non-terminal DataError: log & continue + Err(error) => { + warn!( + %exchange, + %error, + action = "skipping message", + "consumed DataError from MarketStream", + ); + continue; + } + } + } + + // If MarketStream ends unexpectedly, attempt re-connection after backoff_ms + warn!( + %exchange, + backoff_ms, + action = "attempt re-connection after backoff", + "exchange MarketStream unexpectedly ended" + ); + tokio::time::sleep(Duration::from_millis(backoff_ms)).await; + } +} diff --git a/barter-data/src/streams/mod.rs b/barter-data/src/streams/mod.rs new file mode 100644 index 00000000..caffafe5 --- /dev/null +++ b/barter-data/src/streams/mod.rs @@ -0,0 +1,72 @@ +use self::builder::{multi::MultiStreamBuilder, StreamBuilder}; +use crate::{exchange::ExchangeId, subscription::SubscriptionKind}; +use std::collections::HashMap; +use tokio::sync::mpsc; +use tokio_stream::{wrappers::UnboundedReceiverStream, StreamMap}; + +/// Defines the [`StreamBuilder`](builder::StreamBuilder) and +/// [`MultiStreamBuilder`](builder::multi::MultiStreamBuilder) APIs for ergonomically initialising +/// [`MarketStream`](super::MarketStream) [`Streams`]. +pub mod builder; + +/// Central consumer loop functionality used by the [`StreamBuilder`](builder::StreamBuilder) to +/// to drive a re-connecting [`MarketStream`](super::MarketStream). +pub mod consumer; + +/// Ergonomic collection of exchange [`MarketEvent`](crate::event::MarketEvent) receivers. +#[derive(Debug)] +pub struct Streams { + pub streams: HashMap>, +} + +impl Streams { + /// Construct a [`StreamBuilder`] for configuring new + /// [`MarketEvent`](crate::event::MarketEvent) [`Streams`]. + pub fn builder() -> StreamBuilder + where + Kind: SubscriptionKind, + { + StreamBuilder::::new() + } + + /// Construct a [`MultiStreamBuilder`] for configuring new + /// [`MarketEvent`](crate::event::MarketEvent) [`Streams`]. + pub fn builder_multi() -> MultiStreamBuilder { + MultiStreamBuilder::::new() + } + + /// Remove an exchange [`mpsc::UnboundedReceiver`] from the [`Streams`] `HashMap`. + pub fn select(&mut self, exchange: ExchangeId) -> Option> { + self.streams.remove(&exchange) + } + + /// Join all exchange [`mpsc::UnboundedReceiver`] streams into a unified + /// [`mpsc::UnboundedReceiver`]. + pub async fn join(self) -> mpsc::UnboundedReceiver + where + T: Send + 'static, + { + let (joined_tx, joined_rx) = mpsc::unbounded_channel(); + + for mut exchange_rx in self.streams.into_values() { + let joined_tx = joined_tx.clone(); + tokio::spawn(async move { + while let Some(event) = exchange_rx.recv().await { + let _ = joined_tx.send(event); + } + }); + } + + joined_rx + } + + /// Join all exchange [`mpsc::UnboundedReceiver`] streams into a unified [`StreamMap`]. + pub async fn join_map(self) -> StreamMap> { + self.streams + .into_iter() + .fold(StreamMap::new(), |mut map, (exchange, rx)| { + map.insert(exchange, UnboundedReceiverStream::new(rx)); + map + }) + } +} diff --git a/barter-data/src/subscriber/mapper.rs b/barter-data/src/subscriber/mapper.rs new file mode 100644 index 00000000..d0207f3f --- /dev/null +++ b/barter-data/src/subscriber/mapper.rs @@ -0,0 +1,72 @@ +use crate::{ + exchange::{subscription::ExchangeSub, Connector}, + instrument::InstrumentData, + subscription::{Map, Subscription, SubscriptionKind, SubscriptionMeta}, + Identifier, +}; +use barter_integration::model::SubscriptionId; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +/// Defines how to map a collection of Barter [`Subscription`]s into exchange specific +/// [`SubscriptionMeta`], containing subscription payloads that are sent to the exchange. +pub trait SubscriptionMapper { + fn map( + subscriptions: &[Subscription], + ) -> SubscriptionMeta + where + Exchange: Connector, + Instrument: InstrumentData, + Kind: SubscriptionKind, + Subscription: + Identifier + Identifier; +} + +/// Standard [`SubscriptionMapper`] for +/// [`WebSocket`](barter_integration::protocol::websocket::WebSocket)s suitable for most exchanges. +#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Deserialize, Serialize)] +pub struct WebSocketSubMapper; + +impl SubscriptionMapper for WebSocketSubMapper { + fn map( + subscriptions: &[Subscription], + ) -> SubscriptionMeta + where + Exchange: Connector, + Kind: SubscriptionKind, + Instrument: InstrumentData, + Subscription: + Identifier + Identifier, + ExchangeSub: Identifier, + { + // Allocate SubscriptionIds HashMap to track identifiers for each actioned Subscription + let mut instrument_map = Map(HashMap::with_capacity(subscriptions.len())); + + // Map Barter Subscriptions to exchange specific subscriptions + let exchange_subs = subscriptions + .iter() + .map(|subscription| { + // Translate Barter Subscription to exchange specific subscription + let exchange_sub = ExchangeSub::new(subscription); + + // Determine the SubscriptionId associated with this exchange specific subscription + let subscription_id = exchange_sub.id(); + + // Use ExchangeSub SubscriptionId as the link to this Barter Subscription + instrument_map + .0 + .insert(subscription_id, subscription.instrument.id().clone()); + + exchange_sub + }) + .collect::>>(); + + // Construct WebSocket message subscriptions requests + let subscriptions = Exchange::requests(exchange_subs); + + SubscriptionMeta { + instrument_map, + subscriptions, + } + } +} diff --git a/barter-data/src/subscriber/mod.rs b/barter-data/src/subscriber/mod.rs new file mode 100644 index 00000000..f98a5b99 --- /dev/null +++ b/barter-data/src/subscriber/mod.rs @@ -0,0 +1,93 @@ +use self::{ + mapper::{SubscriptionMapper, WebSocketSubMapper}, + validator::SubscriptionValidator, +}; +use crate::{ + exchange::Connector, + instrument::InstrumentData, + subscription::{Map, Subscription, SubscriptionKind, SubscriptionMeta}, + Identifier, +}; +use async_trait::async_trait; +use barter_integration::{ + error::SocketError, + protocol::websocket::{connect, WebSocket}, +}; +use futures::SinkExt; +use serde::{Deserialize, Serialize}; +use tracing::{debug, info}; + +/// [`SubscriptionMapper`] implementations defining how to map a +/// collection of Barter [`Subscription`]s into exchange specific [`SubscriptionMeta`]. +pub mod mapper; + +/// [`SubscriptionValidator`] implementations defining how to +/// validate actioned [`Subscription`]s were successful. +pub mod validator; + +/// Defines how to connect to a socket and subscribe to market data streams. +#[async_trait] +pub trait Subscriber { + type SubMapper: SubscriptionMapper; + + async fn subscribe( + subscriptions: &[Subscription], + ) -> Result<(WebSocket, Map), SocketError> + where + Exchange: Connector + Send + Sync, + Kind: SubscriptionKind + Send + Sync, + Instrument: InstrumentData, + Subscription: + Identifier + Identifier; +} + +/// Standard [`Subscriber`] for [`WebSocket`]s suitable for most exchanges. +#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Deserialize, Serialize)] +pub struct WebSocketSubscriber; + +#[async_trait] +impl Subscriber for WebSocketSubscriber { + type SubMapper = WebSocketSubMapper; + + async fn subscribe( + subscriptions: &[Subscription], + ) -> Result<(WebSocket, Map), SocketError> + where + Exchange: Connector + Send + Sync, + Kind: SubscriptionKind + Send + Sync, + Instrument: InstrumentData, + Subscription: + Identifier + Identifier, + { + // Define variables for logging ergonomics + let exchange = Exchange::ID; + let url = Exchange::url()?; + debug!(%exchange, %url, ?subscriptions, "subscribing to WebSocket"); + + // Connect to exchange + let mut websocket = connect(url).await?; + debug!(%exchange, ?subscriptions, "connected to WebSocket"); + + // Map &[Subscription] to SubscriptionMeta + let SubscriptionMeta { + instrument_map, + subscriptions, + } = Self::SubMapper::map::(subscriptions); + + // Send Subscriptions over WebSocket + for subscription in subscriptions { + debug!(%exchange, payload = ?subscription, "sending exchange subscription"); + websocket.send(subscription).await?; + } + + // Validate Subscription responses + let map = Exchange::SubValidator::validate::( + instrument_map, + &mut websocket, + ) + .await?; + + info!(%exchange, "subscribed to WebSocket"); + Ok((websocket, map)) + } +} diff --git a/barter-data/src/subscriber/validator.rs b/barter-data/src/subscriber/validator.rs new file mode 100644 index 00000000..38ce59f5 --- /dev/null +++ b/barter-data/src/subscriber/validator.rs @@ -0,0 +1,123 @@ +use crate::{ + exchange::Connector, + instrument::InstrumentData, + subscription::{Map, SubscriptionKind}, +}; +use async_trait::async_trait; +use barter_integration::{ + error::SocketError, + protocol::{ + websocket::{WebSocket, WebSocketParser}, + StreamParser, + }, + Validator, +}; +use futures::StreamExt; +use serde::{Deserialize, Serialize}; +use tracing::debug; + +/// Defines how to validate that actioned market data +/// [`Subscription`](crate::subscription::Subscription)s were accepted by the exchange. +#[async_trait] +pub trait SubscriptionValidator { + type Parser: StreamParser; + + async fn validate( + instrument_map: Map, + websocket: &mut WebSocket, + ) -> Result, SocketError> + where + Exchange: Connector + Send, + Instrument: InstrumentData, + Kind: SubscriptionKind + Send; +} + +/// Standard [`SubscriptionValidator`] for [`WebSocket`]s suitable for most exchanges. +#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Deserialize, Serialize)] +pub struct WebSocketSubValidator; + +#[async_trait] +impl SubscriptionValidator for WebSocketSubValidator { + type Parser = WebSocketParser; + + async fn validate( + instrument_map: Map, + websocket: &mut WebSocket, + ) -> Result, SocketError> + where + Exchange: Connector + Send, + Instrument: InstrumentData, + Kind: SubscriptionKind + Send, + { + // Establish exchange specific subscription validation parameters + let timeout = Exchange::subscription_timeout(); + let expected_responses = Exchange::expected_responses(&instrument_map); + + // Parameter to keep track of successful Subscription outcomes + let mut success_responses = 0usize; + + loop { + // Break if all Subscriptions were a success + if success_responses == expected_responses { + debug!(exchange = %Exchange::ID, "validated exchange WebSocket subscriptions"); + break Ok(instrument_map); + } + + tokio::select! { + // If timeout reached, return SubscribeError + _ = tokio::time::sleep(timeout) => { + break Err(SocketError::Subscribe( + format!("subscription validation timeout reached: {:?}", timeout) + )) + }, + // Parse incoming messages and determine subscription outcomes + message = websocket.next() => { + let response = match message { + Some(response) => response, + None => break Err(SocketError::Subscribe("WebSocket stream terminated unexpectedly".to_string())) + }; + + match Self::Parser::parse::(response) { + Some(Ok(response)) => match response.validate() { + // Subscription success + Ok(response) => { + success_responses += 1; + debug!( + exchange = %Exchange::ID, + %success_responses, + %expected_responses, + payload = ?response, + "received valid Ok subscription response", + ); + } + + // Subscription failure + Err(err) => break Err(err) + } + Some(Err(SocketError::Deserialise { error, payload })) if success_responses >= 1 => { + // Already active subscription payloads, so skip to next SubResponse + debug!( + exchange = %Exchange::ID, + ?error, + %success_responses, + %expected_responses, + %payload, + "failed to deserialise non SubResponse payload" + ); + continue + } + Some(Err(SocketError::Terminated(close_frame))) => { + break Err(SocketError::Subscribe( + format!("received WebSocket CloseFrame: {close_frame}") + )) + } + _ => { + // Pings, Pongs, Frames, etc. + continue + } + } + } + } + } + } +} diff --git a/barter-data/src/subscription/book.rs b/barter-data/src/subscription/book.rs new file mode 100644 index 00000000..7d02742d --- /dev/null +++ b/barter-data/src/subscription/book.rs @@ -0,0 +1,923 @@ +use super::SubscriptionKind; +use crate::{ + event::{MarketEvent, MarketIter}, + exchange::ExchangeId, +}; +use barter_integration::model::{Exchange, Side}; +use barter_macro::{DeSubKind, SerSubKind}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use std::cmp::Ordering; +use tracing::debug; + +/// Barter [`Subscription`](super::Subscription) [`SubscriptionKind`] that yields level 1 [`OrderBook`] +/// [`MarketEvent`](MarketEvent) events. +/// +/// Level 1 refers to the best non-aggregated bid and ask [`Level`] on each side of the +/// [`OrderBook`]. +#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, DeSubKind, SerSubKind)] +pub struct OrderBooksL1; + +impl SubscriptionKind for OrderBooksL1 { + type Event = OrderBookL1; +} + +/// Normalised Barter [`OrderBookL1`] snapshot containing the latest best bid and ask. +#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Debug, Deserialize, Serialize)] +pub struct OrderBookL1 { + pub last_update_time: DateTime, + pub best_bid: Level, + pub best_ask: Level, +} + +impl OrderBookL1 { + /// Calculate the mid price by taking the average of the best bid and ask prices. + /// + /// See Docs: + pub fn mid_price(&self) -> f64 { + mid_price(self.best_bid.price, self.best_ask.price) + } + + /// Calculate the volume weighted mid price (micro-price), weighing the best bid and ask prices + /// with their associated amount. + /// + /// See Docs: + pub fn volume_weighed_mid_price(&self) -> f64 { + volume_weighted_mid_price(self.best_bid, self.best_ask) + } +} + +/// Barter [`Subscription`](super::Subscription) [`SubscriptionKind`] that yields level 2 [`OrderBook`] +/// [`MarketEvent`](MarketEvent) events. +/// +/// Level 2 refers to the [`OrderBook`] aggregated by price. +#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, DeSubKind, SerSubKind)] +pub struct OrderBooksL2; + +impl SubscriptionKind for OrderBooksL2 { + type Event = OrderBook; +} + +/// Barter [`Subscription`](super::Subscription) [`SubscriptionKind`] that yields level 3 [`OrderBook`] +/// [`MarketEvent`](MarketEvent) events. +/// +/// Level 3 refers to the non-aggregated [`OrderBook`]. This is a direct replication of the exchange +/// [`OrderBook`]. +#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, DeSubKind, SerSubKind)] +pub struct OrderBooksL3; + +impl SubscriptionKind for OrderBooksL3 { + type Event = OrderBook; +} + +/// Normalised Barter [`OrderBook`] snapshot. +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Debug, Deserialize, Serialize)] +pub struct OrderBook { + pub last_update_time: DateTime, + pub bids: OrderBookSide, + pub asks: OrderBookSide, +} + +impl OrderBook { + /// Generate an [`OrderBook`] snapshot by cloning [`Self`] after sorting each [`OrderBookSide`]. + pub fn snapshot(&mut self) -> Self { + // Sort OrderBook & Clone + self.bids.sort(); + self.asks.sort(); + self.clone() + } + + /// Calculate the mid price by taking the average of the best bid and ask prices. + /// + /// See Docs: + pub fn mid_price(&self) -> Option { + match (self.bids.levels.first(), self.asks.levels.first()) { + (Some(best_bid), Some(best_ask)) => Some(mid_price(best_bid.price, best_ask.price)), + (Some(best_bid), None) => Some(best_bid.price), + (None, Some(best_ask)) => Some(best_ask.price), + (None, None) => None, + } + } + + /// Calculate the volume weighted mid price (micro-price), weighing the best bid and ask prices + /// with their associated amount. + /// + /// See Docs: + pub fn volume_weighed_mid_price(&self) -> Option { + match (self.bids.levels.first(), self.asks.levels.first()) { + (Some(best_bid), Some(best_ask)) => { + Some(volume_weighted_mid_price(*best_bid, *best_ask)) + } + (Some(best_bid), None) => Some(best_bid.price), + (None, Some(best_ask)) => Some(best_ask.price), + (None, None) => None, + } + } +} + +/// Normalised Barter [`Level`]s for one [`Side`] of the [`OrderBook`]. +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Debug, Deserialize, Serialize)] +pub struct OrderBookSide { + side: Side, + levels: Vec, +} + +impl OrderBookSide { + /// Construct a new [`Self`] with the [`Level`]s provided. + pub fn new(side: Side, levels: Iter) -> Self + where + Iter: IntoIterator, + L: Into, + { + Self { + side, + levels: levels.into_iter().map(L::into).collect(), + } + } + + /// Upsert a collection of [`Level`]s into this [`OrderBookSide`]. + pub fn upsert(&mut self, levels: Iter) + where + Iter: IntoIterator, + L: Into, + { + levels + .into_iter() + .for_each(|level| self.upsert_single(level)) + } + + /// Upsert a single [`Level`] into this [`OrderBookSide`]. + /// + /// ### Upsert Scenarios + /// #### 1 Level Already Exists + /// 1a) New value is 0, remove the level + /// 1b) New value is > 0, replace the level + /// + /// #### 2 Level Does Not Exist + /// 2a) New value is > 0, insert new level + /// 2b) New value is 0, log error and continue + pub fn upsert_single(&mut self, new_level: L) + where + L: Into, + { + let new_level = new_level.into(); + + match self + .levels + .iter_mut() + .enumerate() + .find(|(_index, level)| level.eq_price(new_level.price)) + { + // Scenario 1a: Level exists & new value is 0 => remove Level + Some((index, _)) if new_level.amount == 0.0 => { + self.levels.remove(index); + } + + // Scenario 1b: Level exists & new value is > 0 => replace Level + Some((_, level)) => { + *level = new_level; + } + + // Scenario 2a: Level does not exist & new value > 0 => insert new Level + None if new_level.amount > 0.0 => self.levels.push(new_level), + + // Scenario 2b: Level does not exist & new value is 0 => log error & continue + _ => { + debug!( + ?new_level, + side = %self.side, + "Level to remove not found", + ); + } + }; + } + + /// Sort this [`OrderBookSide`] (bids are reversed). + pub fn sort(&mut self) { + // Sort Levels + self.levels.sort_unstable(); + + // Reverse Bids + if let Side::Buy = self.side { + self.levels.reverse(); + } + } +} + +/// Normalised Barter OrderBook [`Level`]. +#[derive(Clone, Copy, PartialEq, Debug, Default, Deserialize, Serialize)] +pub struct Level { + pub price: f64, + pub amount: f64, +} + +impl From<(T, T)> for Level +where + T: Into, +{ + fn from((price, amount): (T, T)) -> Self { + Self::new(price, amount) + } +} + +impl Ord for Level { + fn cmp(&self, other: &Self) -> Ordering { + self.partial_cmp(other) + .unwrap_or_else(|| panic!("{:?}.partial_cmp({:?}) impossible", self, other)) + } +} + +impl PartialOrd for Level { + fn partial_cmp(&self, other: &Self) -> Option { + match self.price.partial_cmp(&other.price)? { + Ordering::Equal => self.amount.partial_cmp(&other.amount), + non_equal => Some(non_equal), + } + } +} + +impl Eq for Level {} + +impl Level { + pub fn new(price: T, amount: T) -> Self + where + T: Into, + { + Self { + price: price.into(), + amount: amount.into(), + } + } + + pub fn eq_price(&self, price: f64) -> bool { + let diff = (price - self.price).abs(); + f64::EPSILON > diff + } +} + +// Todo: Add tests + +/// Calculate the mid price by taking the average of the best bid and ask prices. +/// +/// See Docs: +pub fn mid_price(best_bid_price: f64, best_ask_price: f64) -> f64 { + (best_bid_price + best_ask_price) / 2.0 +} + +/// Calculate the volume weighted mid price (micro-price), weighing the best bid and ask prices +/// with their associated amount. +/// +/// See Docs: +pub fn volume_weighted_mid_price(best_bid: Level, best_ask: Level) -> f64 { + ((best_bid.price * best_ask.amount) + (best_ask.price * best_bid.amount)) + / (best_bid.amount + best_ask.amount) +} + +impl From<(ExchangeId, InstrumentId, OrderBook)> + for MarketIter +{ + fn from((exchange_id, instrument, book): (ExchangeId, InstrumentId, OrderBook)) -> Self { + Self(vec![Ok(MarketEvent { + exchange_time: book.last_update_time, + received_time: Utc::now(), + exchange: Exchange::from(exchange_id), + instrument, + kind: book, + })]) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + mod order_book_l1 { + use super::*; + + #[test] + fn test_mid_price() { + struct TestCase { + input: OrderBookL1, + expected: f64, + } + + let tests = vec![ + TestCase { + // TC0 + input: OrderBookL1 { + last_update_time: Default::default(), + best_bid: Level::new(100, 999999), + best_ask: Level::new(200, 1), + }, + expected: 150.0, + }, + TestCase { + // TC1 + input: OrderBookL1 { + last_update_time: Default::default(), + best_bid: Level::new(50, 1), + best_ask: Level::new(250, 999999), + }, + expected: 150.0, + }, + TestCase { + // TC2 + input: OrderBookL1 { + last_update_time: Default::default(), + best_bid: Level::new(10, 999999), + best_ask: Level::new(250, 999999), + }, + expected: 130.0, + }, + ]; + + for (index, test) in tests.into_iter().enumerate() { + assert_eq!(test.input.mid_price(), test.expected, "TC{index} failed") + } + } + + #[test] + fn test_volume_weighted_mid_price() { + struct TestCase { + input: OrderBookL1, + expected: f64, + } + + let tests = vec![ + TestCase { + // TC0: volume the same so should be equal to non-weighted mid price + input: OrderBookL1 { + last_update_time: Default::default(), + best_bid: Level::new(100, 100), + best_ask: Level::new(200, 100), + }, + expected: 150.0, + }, + TestCase { + // TC1: volume affects mid-price + input: OrderBookL1 { + last_update_time: Default::default(), + best_bid: Level::new(100, 600), + best_ask: Level::new(200, 1000), + }, + expected: 137.5, + }, + TestCase { + // TC2: volume the same and price the same + input: OrderBookL1 { + last_update_time: Default::default(), + best_bid: Level::new(1000, 999999), + best_ask: Level::new(1000, 999999), + }, + expected: 1000.0, + }, + ]; + + for (index, test) in tests.into_iter().enumerate() { + assert_eq!( + test.input.volume_weighed_mid_price(), + test.expected, + "TC{index} failed" + ) + } + } + } + + mod order_book { + use super::*; + + #[test] + fn test_mid_price() { + struct TestCase { + input: OrderBook, + expected: Option, + } + + let tests = vec![ + TestCase { + // TC0: no levels so 0.0 mid-price + input: OrderBook { + last_update_time: Default::default(), + bids: OrderBookSide { + side: Side::Buy, + levels: vec![], + }, + asks: OrderBookSide { + side: Side::Sell, + levels: vec![], + }, + }, + expected: None, + }, + TestCase { + // TC1: no asks in the book so take best bid price + input: OrderBook { + last_update_time: Default::default(), + bids: OrderBookSide { + side: Side::Buy, + levels: vec![Level::new(100.0, 100.0), Level::new(50.0, 100.0)], + }, + asks: OrderBookSide { + side: Side::Sell, + levels: vec![], + }, + }, + expected: Some(100.0), + }, + TestCase { + // TC2: no bids in the book so take ask price + input: OrderBook { + last_update_time: Default::default(), + bids: OrderBookSide { + side: Side::Buy, + levels: vec![], + }, + asks: OrderBookSide { + side: Side::Sell, + levels: vec![Level::new(50.0, 100.0), Level::new(100.0, 100.0)], + }, + }, + expected: Some(50.0), + }, + TestCase { + // TC3: best bid and ask amount is the same, so regular mid-price + input: OrderBook { + last_update_time: Default::default(), + bids: OrderBookSide { + side: Side::Buy, + levels: vec![Level::new(100.0, 100.0), Level::new(50.0, 100.0)], + }, + asks: OrderBookSide { + side: Side::Sell, + levels: vec![Level::new(200.0, 100.0), Level::new(300.0, 100.0)], + }, + }, + expected: Some(150.0), + }, + ]; + + for (index, test) in tests.into_iter().enumerate() { + assert_eq!(test.input.mid_price(), test.expected, "TC{index} failed") + } + } + + #[test] + fn test_volume_weighted_mid_price() { + struct TestCase { + input: OrderBook, + expected: Option, + } + + let tests = vec![ + TestCase { + // TC0: no levels so 0.0 mid-price + input: OrderBook { + last_update_time: Default::default(), + bids: OrderBookSide { + side: Side::Buy, + levels: vec![], + }, + asks: OrderBookSide { + side: Side::Sell, + levels: vec![], + }, + }, + expected: None, + }, + TestCase { + // TC1: no asks in the book so take best bid price + input: OrderBook { + last_update_time: Default::default(), + bids: OrderBookSide { + side: Side::Buy, + levels: vec![Level::new(100.0, 100.0), Level::new(50.0, 100.0)], + }, + asks: OrderBookSide { + side: Side::Sell, + levels: vec![], + }, + }, + expected: Some(100.0), + }, + TestCase { + // TC2: no bids in the book so take ask price + input: OrderBook { + last_update_time: Default::default(), + bids: OrderBookSide { + side: Side::Buy, + levels: vec![], + }, + asks: OrderBookSide { + side: Side::Sell, + levels: vec![Level::new(50.0, 100.0), Level::new(100.0, 100.0)], + }, + }, + expected: Some(50.0), + }, + TestCase { + // TC3: best bid and ask amount is the same, so regular mid-price + input: OrderBook { + last_update_time: Default::default(), + bids: OrderBookSide { + side: Side::Buy, + levels: vec![Level::new(100.0, 100.0), Level::new(50.0, 100.0)], + }, + asks: OrderBookSide { + side: Side::Sell, + levels: vec![Level::new(200.0, 100.0), Level::new(300.0, 100.0)], + }, + }, + expected: Some(150.0), + }, + TestCase { + // TC4: valid volume weighted mid-price + input: OrderBook { + last_update_time: Default::default(), + bids: OrderBookSide { + side: Side::Buy, + levels: vec![Level::new(100.0, 3000.0), Level::new(50.0, 100.0)], + }, + asks: OrderBookSide { + side: Side::Sell, + levels: vec![Level::new(200.0, 1000.0), Level::new(300.0, 100.0)], + }, + }, + expected: Some(175.0), + }, + ]; + + for (index, test) in tests.into_iter().enumerate() { + assert_eq!( + test.input.volume_weighed_mid_price(), + test.expected, + "TC{index} failed" + ) + } + } + } + + mod order_book_side { + use super::*; + + #[test] + fn test_upsert_single() { + struct TestCase { + book_side: OrderBookSide, + new_level: Level, + expected: OrderBookSide, + } + + let tests = vec![ + TestCase { + // TC0: Level exists & new value is 0 => remove Level + book_side: OrderBookSide::new( + Side::Buy, + vec![Level::new(80, 1), Level::new(90, 1), Level::new(100, 1)], + ), + new_level: Level::new(100, 0), + expected: OrderBookSide::new( + Side::Buy, + vec![Level::new(80, 1), Level::new(90, 1)], + ), + }, + TestCase { + // TC1: Level exists & new value is > 0 => replace Level + book_side: OrderBookSide::new( + Side::Buy, + vec![Level::new(80, 1), Level::new(90, 1), Level::new(100, 1)], + ), + new_level: Level::new(100, 10), + expected: OrderBookSide::new( + Side::Buy, + vec![Level::new(80, 1), Level::new(90, 1), Level::new(100, 10)], + ), + }, + TestCase { + // TC2: Level does not exist & new value > 0 => insert new Level + book_side: OrderBookSide::new( + Side::Buy, + vec![Level::new(80, 1), Level::new(90, 1), Level::new(100, 1)], + ), + new_level: Level::new(110, 1), + expected: OrderBookSide::new( + Side::Buy, + vec![ + Level::new(80, 1), + Level::new(90, 1), + Level::new(100, 1), + Level::new(110, 1), + ], + ), + }, + TestCase { + // TC3: Level does not exist & new value is 0 => no change + book_side: OrderBookSide::new( + Side::Buy, + vec![Level::new(80, 1), Level::new(90, 1), Level::new(100, 1)], + ), + new_level: Level::new(110, 0), + expected: OrderBookSide::new( + Side::Buy, + vec![Level::new(80, 1), Level::new(90, 1), Level::new(100, 1)], + ), + }, + ]; + + for (index, mut test) in tests.into_iter().enumerate() { + test.book_side.upsert_single(test.new_level); + assert_eq!(test.book_side, test.expected, "TC{} failed", index); + } + } + + #[test] + fn test_sort_bids() { + struct TestCase { + input: OrderBookSide, + expected: OrderBookSide, + } + + let tests = vec![ + TestCase { + // TC0: sorted correctly from reverse sorted + input: OrderBookSide::new( + Side::Buy, + vec![ + Level::new(80, 1), + Level::new(90, 1), + Level::new(100, 1), + Level::new(110, 1), + Level::new(120, 1), + ], + ), + expected: OrderBookSide::new( + Side::Buy, + vec![ + Level::new(120, 1), + Level::new(110, 1), + Level::new(100, 1), + Level::new(90, 1), + Level::new(80, 1), + ], + ), + }, + TestCase { + // TC1: sorted correctly from partially sorted + input: OrderBookSide::new( + Side::Buy, + vec![ + Level::new(120, 1), + Level::new(90, 1), + Level::new(80, 1), + Level::new(110, 1), + Level::new(100, 1), + ], + ), + expected: OrderBookSide::new( + Side::Buy, + vec![ + Level::new(120, 1), + Level::new(110, 1), + Level::new(100, 1), + Level::new(90, 1), + Level::new(80, 1), + ], + ), + }, + TestCase { + // TC1: sorted correctly from already sorted + input: OrderBookSide::new( + Side::Buy, + vec![ + Level::new(120, 1), + Level::new(110, 1), + Level::new(100, 1), + Level::new(90, 1), + Level::new(80, 1), + ], + ), + expected: OrderBookSide::new( + Side::Buy, + vec![ + Level::new(120, 1), + Level::new(110, 1), + Level::new(100, 1), + Level::new(90, 1), + Level::new(80, 1), + ], + ), + }, + ]; + + for (index, mut test) in tests.into_iter().enumerate() { + test.input.sort(); + assert_eq!(test.input, test.expected, "TC{} failed", index); + } + } + + #[test] + fn test_sort_asks() { + struct TestCase { + input: OrderBookSide, + expected: OrderBookSide, + } + + let tests = vec![ + TestCase { + // TC0: sorted correctly from already sorted + input: OrderBookSide::new( + Side::Sell, + vec![ + Level::new(80, 1), + Level::new(90, 1), + Level::new(100, 1), + Level::new(110, 1), + Level::new(120, 1), + ], + ), + expected: OrderBookSide::new( + Side::Sell, + vec![ + Level::new(80, 1), + Level::new(90, 1), + Level::new(100, 1), + Level::new(110, 1), + Level::new(120, 1), + ], + ), + }, + TestCase { + // TC1: sorted correctly from partially sorted + input: OrderBookSide::new( + Side::Sell, + vec![ + Level::new(120, 1), + Level::new(90, 1), + Level::new(80, 1), + Level::new(110, 1), + Level::new(100, 1), + ], + ), + expected: OrderBookSide::new( + Side::Sell, + vec![ + Level::new(80, 1), + Level::new(90, 1), + Level::new(100, 1), + Level::new(110, 1), + Level::new(120, 1), + ], + ), + }, + TestCase { + // TC1: sorted correctly from reverse sorted + input: OrderBookSide::new( + Side::Sell, + vec![ + Level::new(120, 1), + Level::new(110, 1), + Level::new(100, 1), + Level::new(90, 1), + Level::new(80, 1), + ], + ), + expected: OrderBookSide::new( + Side::Sell, + vec![ + Level::new(80, 1), + Level::new(90, 1), + Level::new(100, 1), + Level::new(110, 1), + Level::new(120, 1), + ], + ), + }, + ]; + + for (index, mut test) in tests.into_iter().enumerate() { + test.input.sort(); + assert_eq!(test.input, test.expected, "TC{} failed", index); + } + } + } + + mod level { + use super::*; + + #[test] + fn test_partial_ord() { + struct TestCase { + input_one: Level, + input_two: Level, + expected: Option, + } + + let tests = vec![ + TestCase { + // TC0: Input One has higher price and higher quantity -> Greater + input_one: Level::new(100, 100), + input_two: Level::new(10, 10), + expected: Some(Ordering::Greater), + }, + TestCase { + // TC1: Input One has higher price but same quantity -> Greater + input_one: Level::new(100, 100), + input_two: Level::new(10, 100), + expected: Some(Ordering::Greater), + }, + TestCase { + // TC2: Input One has higher price but lower quantity -> Greater + input_one: Level::new(100, 10), + input_two: Level::new(10, 100), + expected: Some(Ordering::Greater), + }, + TestCase { + // TC3: Input One has same price and higher quantity -> Greater + input_one: Level::new(10, 200), + input_two: Level::new(10, 100), + expected: Some(Ordering::Greater), + }, + TestCase { + // TC4: Input One has same price and same quantity -> Equal + input_one: Level::new(100, 100), + input_two: Level::new(100, 100), + expected: Some(Ordering::Equal), + }, + TestCase { + // TC5: Input One has same price but lower quantity -> Less + input_one: Level::new(10, 50), + input_two: Level::new(10, 100), + expected: Some(Ordering::Less), + }, + TestCase { + // TC6: Input One has lower price but higher quantity -> Less + input_one: Level::new(10, 100), + input_two: Level::new(100, 50), + expected: Some(Ordering::Less), + }, + TestCase { + // TC7: Input One has lower price and same quantity -> Less + input_one: Level::new(50, 100), + input_two: Level::new(100, 100), + expected: Some(Ordering::Less), + }, + TestCase { + // TC8: Input One has lower price and lower quantity -> Less + input_one: Level::new(50, 50), + input_two: Level::new(100, 100), + expected: Some(Ordering::Less), + }, + ]; + + for (index, test) in tests.into_iter().enumerate() { + let actual = test.input_one.partial_cmp(&test.input_two); + match (actual, test.expected) { + (None, None) => { + // Test passed + } + (Some(actual), Some(expected)) => { + assert_eq!(actual, expected, "TC{} failed", index) + } + (actual, expected) => { + // Test failed + panic!("TC{index} failed because actual != expected. \nActual: {actual:?}\nExpected: {expected:?}\n"); + } + } + } + } + + #[test] + fn test_eq_price() { + struct TestCase { + level: Level, + input_level: Level, + expected: bool, + } + + let tests = vec![ + TestCase { + // TC0: Input Level has higher price + level: Level::new(50, 100), + input_level: Level::new(100, 100), + expected: false, + }, + TestCase { + // TC1: Input Level an equal price + level: Level::new(50, 100), + input_level: Level::new(50, 100), + expected: true, + }, + TestCase { + // TC2: Input Level has lower price + level: Level::new(50, 100), + input_level: Level::new(10, 100), + expected: false, + }, + ]; + + for (index, test) in tests.into_iter().enumerate() { + let actual = test.level.eq_price(test.input_level.price); + assert_eq!(actual, test.expected, "TC{} failed", index); + } + } + } +} diff --git a/barter-data/src/subscription/candle.rs b/barter-data/src/subscription/candle.rs new file mode 100644 index 00000000..bf89d8d7 --- /dev/null +++ b/barter-data/src/subscription/candle.rs @@ -0,0 +1,24 @@ +use super::SubscriptionKind; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +/// Barter [`Subscription`](super::Subscription) [`SubscriptionKind`] that yields [`Candle`] +/// [`MarketEvent`](crate::event::MarketEvent) events. +#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Deserialize, Serialize)] +pub struct Candles; + +impl SubscriptionKind for Candles { + type Event = Candle; +} + +/// Normalised Barter OHLCV [`Candle`] model. +#[derive(Copy, Clone, PartialEq, PartialOrd, Debug, Deserialize, Serialize)] +pub struct Candle { + pub close_time: DateTime, + pub open: f64, + pub high: f64, + pub low: f64, + pub close: f64, + pub volume: f64, + pub trade_count: u64, +} diff --git a/barter-data/src/subscription/liquidation.rs b/barter-data/src/subscription/liquidation.rs new file mode 100644 index 00000000..d2b93fec --- /dev/null +++ b/barter-data/src/subscription/liquidation.rs @@ -0,0 +1,22 @@ +use super::SubscriptionKind; +use barter_integration::model::Side; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +/// Barter [`Subscription`](super::Subscription) [`SubscriptionKind`] that yields [`Liquidation`] +/// [`MarketEvent`](crate::event::MarketEvent) events. +#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Deserialize, Serialize)] +pub struct Liquidations; + +impl SubscriptionKind for Liquidations { + type Event = Liquidation; +} + +/// Normalised Barter [`Liquidation`] model. +#[derive(Clone, Copy, PartialEq, PartialOrd, Debug, Deserialize, Serialize)] +pub struct Liquidation { + pub side: Side, + pub price: f64, + pub quantity: f64, + pub time: DateTime, +} diff --git a/barter-data/src/subscription/mod.rs b/barter-data/src/subscription/mod.rs new file mode 100644 index 00000000..865e158b --- /dev/null +++ b/barter-data/src/subscription/mod.rs @@ -0,0 +1,486 @@ +use crate::{ + exchange::{Connector, ExchangeId}, + instrument::{InstrumentData, KeyedInstrument}, +}; +use barter_integration::{ + error::SocketError, + model::{ + instrument::{kind::InstrumentKind, symbol::Symbol, Instrument}, + SubscriptionId, + }, + protocol::websocket::WsMessage, + Validator, +}; +use derive_more::Display; +use serde::{Deserialize, Serialize}; +use std::{ + borrow::Borrow, + collections::HashMap, + fmt::{Debug, Display, Formatter}, + hash::Hash, +}; + +/// OrderBook [`SubscriptionKind`]s and the associated Barter output data models. +pub mod book; + +/// Candle [`SubscriptionKind`] and the associated Barter output data model. +pub mod candle; + +/// Liquidation [`SubscriptionKind`] and the associated Barter output data model. +pub mod liquidation; + +/// Public trade [`SubscriptionKind`] and the associated Barter output data model. +pub mod trade; + +/// Defines the type of a [`Subscription`], and the output [`Self::Event`] that it yields. +pub trait SubscriptionKind +where + Self: Debug + Clone, +{ + type Event: Debug; +} + +/// Barter [`Subscription`] used to subscribe to a [`SubscriptionKind`] for a particular exchange +/// [`Instrument`]. +#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize)] +pub struct Subscription { + pub exchange: Exchange, + #[serde(flatten)] + pub instrument: Inst, + #[serde(alias = "type")] + pub kind: Kind, +} + +#[derive( + Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Deserialize, Serialize, Display, +)] +pub enum SubKind { + PublicTrades, + OrderBooksL1, + OrderBooksL2, + OrderBooksL3, + Liquidations, + Candles, +} + +impl Display for Subscription +where + Exchange: Display, + Instrument: Display, + Kind: Display, +{ + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "{}_{}{}", self.exchange, self.kind, self.instrument) + } +} + +impl From<(Exchange, S, S, InstrumentKind, Kind)> + for Subscription +where + S: Into, +{ + fn from( + (exchange, base, quote, instrument_kind, kind): (Exchange, S, S, InstrumentKind, Kind), + ) -> Self { + Self::new(exchange, (base, quote, instrument_kind), kind) + } +} + +impl From<(InstrumentId, Exchange, S, S, InstrumentKind, Kind)> + for Subscription, Kind> +where + S: Into, +{ + fn from( + (instrument_id, exchange, base, quote, instrument_kind, kind): ( + InstrumentId, + Exchange, + S, + S, + InstrumentKind, + Kind, + ), + ) -> Self { + let instrument = KeyedInstrument::new(instrument_id, (base, quote, instrument_kind).into()); + + Self::new(exchange, instrument, kind) + } +} + +impl From<(Exchange, I, Kind)> + for Subscription +where + I: Into, +{ + fn from((exchange, instrument, kind): (Exchange, I, Kind)) -> Self { + Self::new(exchange, instrument, kind) + } +} + +impl Subscription { + /// Constructs a new [`Subscription`] using the provided configuration. + pub fn new(exchange: Exchange, instrument: I, kind: Kind) -> Self + where + I: Into, + { + Self { + exchange, + instrument: instrument.into(), + kind, + } + } +} + +impl Validator for &Subscription +where + Exchange: Connector, +{ + fn validate(self) -> Result + where + Self: Sized, + { + // Determine ExchangeId associated with this Subscription + let exchange = Exchange::ID; + + // Validate the Exchange supports the Subscription InstrumentKind + if exchange.supports_instrument_kind(self.instrument.kind) { + Ok(self) + } else { + Err(SocketError::Unsupported { + entity: exchange.as_str(), + item: self.instrument.kind.to_string(), + }) + } + } +} + +impl Validator for Subscription +where + Instrument: InstrumentData, +{ + fn validate(self) -> Result + where + Self: Sized, + { + // Validate the Exchange supports the Subscription InstrumentKind + if self.exchange.supports(self.instrument.kind(), self.kind) { + Ok(self) + } else { + Err(SocketError::Unsupported { + entity: self.exchange.as_str(), + item: self.instrument.kind().to_string(), + }) + } + } +} + +/// Metadata generated from a collection of Barter [`Subscription`]s, including the exchange +/// specific subscription payloads that are sent to the exchange. +#[derive(Clone, Eq, PartialEq, Debug)] +pub struct SubscriptionMeta { + /// `HashMap` containing the mapping between a [`SubscriptionId`] and + /// it's associated Barter [`Instrument`]. + pub instrument_map: Map, + /// Collection of [`WsMessage`]s containing exchange specific subscription payloads to be sent. + pub subscriptions: Vec, +} + +/// New type`HashMap` that maps a [`SubscriptionId`] to some associated type `T`. +/// +/// Used by [`ExchangeTransformer`](crate::transformer::ExchangeTransformer)s to identify the +/// Barter [`Instrument`] associated with incoming exchange messages. +#[derive(Clone, Eq, PartialEq, Debug, Deserialize, Serialize)] +pub struct Map(pub HashMap); + +impl FromIterator<(SubscriptionId, T)> for Map { + fn from_iter(iter: Iter) -> Self + where + Iter: IntoIterator, + { + Self(iter.into_iter().collect::>()) + } +} + +impl Map { + /// Find the `InstrumentId` associated with the provided [`SubscriptionId`]. + pub fn find(&self, id: &SubId) -> Result<&T, SocketError> + where + SubscriptionId: Borrow, + SubId: AsRef + Hash + Eq + ?Sized, + { + self.0 + .get(id) + .ok_or_else(|| SocketError::Unidentifiable(SubscriptionId(id.as_ref().to_string()))) + } + + /// Find the mutable reference to `T` associated with the provided [`SubscriptionId`]. + pub fn find_mut(&mut self, id: &SubId) -> Result<&mut T, SocketError> + where + SubscriptionId: Borrow, + SubId: AsRef + Hash + Eq + ?Sized, + { + self.0 + .get_mut(id) + .ok_or_else(|| SocketError::Unidentifiable(SubscriptionId(id.as_ref().to_string()))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + mod subscription { + use super::*; + use crate::{ + exchange::{coinbase::Coinbase, okx::Okx}, + subscription::trade::PublicTrades, + }; + use barter_integration::model::instrument::kind::InstrumentKind; + + mod de { + use super::*; + use crate::{ + exchange::{ + binance::{futures::BinanceFuturesUsd, spot::BinanceSpot}, + gateio::perpetual::GateioPerpetualsUsd, + okx::Okx, + }, + subscription::{book::OrderBooksL2, trade::PublicTrades}, + }; + + #[test] + fn test_subscription_okx_spot_public_trades() { + let input = r#" + { + "exchange": "okx", + "base": "btc", + "quote": "usdt", + "instrument_kind": "spot", + "kind": "public_trades" + } + "#; + + serde_json::from_str::>(input).unwrap(); + } + + #[test] + fn test_subscription_binance_spot_public_trades() { + let input = r#" + { + "exchange": "binance_spot", + "base": "btc", + "quote": "usdt", + "instrument_kind": "spot", + "kind": "public_trades" + } + "#; + + serde_json::from_str::>(input) + .unwrap(); + } + + #[test] + fn test_subscription_binance_futures_usd_order_books_l2() { + let input = r#" + { + "exchange": "binance_futures_usd", + "base": "btc", + "quote": "usdt", + "instrument_kind": "perpetual", + "kind": "order_books_l2" + } + "#; + + serde_json::from_str::>( + input, + ) + .unwrap(); + } + + #[test] + fn subscription_gateio_futures_usd_public_trades() { + let input = r#" + { + "exchange": "gateio_perpetuals_usd", + "base": "btc", + "quote": "usdt", + "instrument_kind": "perpetual", + "kind": "public_trades" + } + "#; + + serde_json::from_str::>(input) + .unwrap(); + } + } + + #[test] + fn test_validate_bitfinex_public_trades() { + struct TestCase { + input: Subscription, + expected: Result, SocketError>, + } + + let tests = vec![ + TestCase { + // TC0: Valid Coinbase Spot PublicTrades subscription + input: Subscription::from(( + Coinbase, + "base", + "quote", + InstrumentKind::Spot, + PublicTrades, + )), + expected: Ok(Subscription::from(( + Coinbase, + "base", + "quote", + InstrumentKind::Spot, + PublicTrades, + ))), + }, + TestCase { + // TC1: Invalid Coinbase FuturePerpetual PublicTrades subscription + input: Subscription::from(( + Coinbase, + "base", + "quote", + InstrumentKind::Perpetual, + PublicTrades, + )), + expected: Err(SocketError::Unsupported { + entity: "", + item: "".to_string(), + }), + }, + ]; + + for (index, test) in tests.into_iter().enumerate() { + let actual = test.input.validate(); + match (actual, &test.expected) { + (Ok(actual), Ok(expected)) => { + assert_eq!(actual, expected, "TC{} failed", index) + } + (Err(_), Err(_)) => { + // Test passed + } + (actual, expected) => { + // Test failed + panic!("TC{index} failed because actual != expected. \nActual: {actual:?}\nExpected: {expected:?}\n"); + } + } + } + } + + #[test] + fn test_validate_okx_public_trades() { + struct TestCase { + input: Subscription, + expected: Result, SocketError>, + } + + let tests = vec![ + TestCase { + // TC0: Valid Okx Spot PublicTrades subscription + input: Subscription::from(( + Okx, + "base", + "quote", + InstrumentKind::Spot, + PublicTrades, + )), + expected: Ok(Subscription::from(( + Okx, + "base", + "quote", + InstrumentKind::Spot, + PublicTrades, + ))), + }, + TestCase { + // TC1: Valid Okx FuturePerpetual PublicTrades subscription + input: Subscription::from(( + Okx, + "base", + "quote", + InstrumentKind::Perpetual, + PublicTrades, + )), + expected: Ok(Subscription::from(( + Okx, + "base", + "quote", + InstrumentKind::Perpetual, + PublicTrades, + ))), + }, + ]; + + for (index, test) in tests.into_iter().enumerate() { + let actual = test.input.validate(); + match (actual, &test.expected) { + (Ok(actual), Ok(expected)) => { + assert_eq!(actual, expected, "TC{} failed", index) + } + (Err(_), Err(_)) => { + // Test passed + } + (actual, expected) => { + // Test failed + panic!("TC{index} failed because actual != expected. \nActual: {actual:?}\nExpected: {expected:?}\n"); + } + } + } + } + } + + mod instrument_map { + use super::*; + use barter_integration::model::instrument::{kind::InstrumentKind, Instrument}; + + #[test] + fn test_find_instrument() { + // Initialise SubscriptionId-InstrumentId HashMap + let ids = Map(HashMap::from_iter([( + SubscriptionId::from("present"), + Instrument::from(("base", "quote", InstrumentKind::Spot)), + )])); + + struct TestCase { + input: SubscriptionId, + expected: Result, + } + + let cases = vec![ + TestCase { + // TC0: SubscriptionId (channel) is present in the HashMap + input: SubscriptionId::from("present"), + expected: Ok(Instrument::from(("base", "quote", InstrumentKind::Spot))), + }, + TestCase { + // TC1: SubscriptionId (channel) is not present in the HashMap + input: SubscriptionId::from("not present"), + expected: Err(SocketError::Unidentifiable(SubscriptionId::from( + "not present", + ))), + }, + ]; + + for (index, test) in cases.into_iter().enumerate() { + let actual = ids.find(&test.input); + match (actual, test.expected) { + (Ok(actual), Ok(expected)) => { + assert_eq!(*actual, expected, "TC{} failed", index) + } + (Err(_), Err(_)) => { + // Test passed + } + (actual, expected) => { + // Test failed + panic!("TC{index} failed because actual != expected. \nActual: {actual:?}\nExpected: {expected:?}\n"); + } + } + } + } + } +} diff --git a/barter-data/src/subscription/trade.rs b/barter-data/src/subscription/trade.rs new file mode 100644 index 00000000..a664f137 --- /dev/null +++ b/barter-data/src/subscription/trade.rs @@ -0,0 +1,22 @@ +use super::SubscriptionKind; +use barter_integration::model::Side; +use barter_macro::{DeSubKind, SerSubKind}; +use serde::{Deserialize, Serialize}; + +/// Barter [`Subscription`](super::Subscription) [`SubscriptionKind`] that yields [`PublicTrade`] +/// [`MarketEvent`](crate::event::MarketEvent) events. +#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, DeSubKind, SerSubKind)] +pub struct PublicTrades; + +impl SubscriptionKind for PublicTrades { + type Event = PublicTrade; +} + +/// Normalised Barter [`PublicTrade`] model. +#[derive(Clone, PartialEq, PartialOrd, Debug, Deserialize, Serialize)] +pub struct PublicTrade { + pub id: String, + pub price: f64, + pub amount: f64, + pub side: Side, +} diff --git a/barter-data/src/transformer/book.rs b/barter-data/src/transformer/book.rs new file mode 100644 index 00000000..c2ed220f --- /dev/null +++ b/barter-data/src/transformer/book.rs @@ -0,0 +1,156 @@ +use crate::{ + error::DataError, + event::{MarketEvent, MarketIter}, + exchange::Connector, + subscription::{book::OrderBook, Map, SubscriptionKind}, + transformer::ExchangeTransformer, + Identifier, +}; +use async_trait::async_trait; +use barter_integration::{ + model::{instrument::Instrument, SubscriptionId}, + protocol::websocket::WsMessage, + Transformer, +}; +use serde::{Deserialize, Serialize}; +use std::marker::PhantomData; +use tokio::sync::mpsc; + +/// Defines how to apply a [`Self::Update`] to an [`Self::OrderBook`]. +#[async_trait] +pub trait OrderBookUpdater +where + Self: Sized, +{ + type OrderBook; + type Update; + + /// Initialises the [`InstrumentOrderBook`] for the provided [`Instrument`]. This often requires + /// a HTTP call to receive a starting [`OrderBook`] snapshot. + async fn init( + ws_sink_tx: mpsc::UnboundedSender, + instrument: Instrument, + ) -> Result, DataError> + where + Exchange: Send, + Kind: Send; + + /// Apply the [`Self::Update`] to the provided mutable [`Self::OrderBook`]. + fn update( + &mut self, + book: &mut Self::OrderBook, + update: Self::Update, + ) -> Result, DataError>; +} + +/// [`OrderBook`] for an [`Instrument`] with an exchange specific [`OrderBookUpdater`] to define +/// how to update it. +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Debug, Deserialize, Serialize)] +pub struct InstrumentOrderBook { + pub instrument: InstrumentId, + pub updater: Updater, + pub book: OrderBook, +} + +/// Standard generic [`ExchangeTransformer`] to translate exchange specific OrderBook types into +/// normalised Barter OrderBook types. Requires an exchange specific [`OrderBookUpdater`] +/// implementation. +#[derive(Clone, PartialEq, Eq, Debug, Deserialize, Serialize)] +pub struct MultiBookTransformer { + pub book_map: Map>, + phantom: PhantomData<(Exchange, Kind)>, +} + +#[async_trait] +impl ExchangeTransformer + for MultiBookTransformer +where + Exchange: Connector + Send, + Kind: SubscriptionKind + Send, + Updater: OrderBookUpdater + Send, + Updater::Update: Identifier> + for<'de> Deserialize<'de>, +{ + async fn new( + ws_sink_tx: mpsc::UnboundedSender, + map: Map, + ) -> Result { + // Initialise InstrumentOrderBooks for all Subscriptions + let (sub_ids, init_book_requests): (Vec<_>, Vec<_>) = map + .0 + .into_iter() + .map(|(sub_id, instrument)| { + ( + sub_id, + Updater::init::(ws_sink_tx.clone(), instrument), + ) + }) + .unzip(); + + // Await all initial OrderBook snapshot requests + let init_order_books = futures::future::join_all(init_book_requests) + .await + .into_iter() + .collect::>, DataError>>()?; + + // Construct OrderBookMap if all requests successful + let book_map = sub_ids + .into_iter() + .zip(init_order_books.into_iter()) + .collect::>>(); + + Ok(Self { + book_map, + phantom: PhantomData, + }) + } +} + +impl Transformer + for MultiBookTransformer +where + Exchange: Connector, + InstrumentId: Clone, + Kind: SubscriptionKind, + Updater: OrderBookUpdater, + Updater::Update: Identifier> + for<'de> Deserialize<'de>, +{ + type Error = DataError; + type Input = Updater::Update; + type Output = MarketEvent; + type OutputIter = Vec>; + + fn transform(&mut self, update: Self::Input) -> Self::OutputIter { + // Determine if the update has an identifiable SubscriptionId + let subscription_id = match update.id() { + Some(subscription_id) => subscription_id, + None => return vec![], + }; + + // Retrieve the InstrumentOrderBook associated with this update (snapshot or delta) + let book = match self.book_map.find_mut(&subscription_id) { + Ok(book) => book, + Err(unidentifiable) => return vec![Err(DataError::Socket(unidentifiable))], + }; + + // De-structure for ease + let InstrumentOrderBook { + instrument, + book, + updater, + } = book; + + // Apply update (snapshot or delta) to OrderBook & generate Market snapshot + match updater.update(book, update) { + Ok(Some(book)) => { + MarketIter::::from(( + Exchange::ID, + instrument.clone(), + book, + )) + .0 + } + Ok(None) => vec![], + Err(error) => vec![Err(error)], + } + } +} diff --git a/barter-data/src/transformer/mod.rs b/barter-data/src/transformer/mod.rs new file mode 100644 index 00000000..693d1258 --- /dev/null +++ b/barter-data/src/transformer/mod.rs @@ -0,0 +1,32 @@ +use crate::{ + error::DataError, + event::MarketEvent, + subscription::{Map, SubscriptionKind}, +}; +use async_trait::async_trait; +use barter_integration::{protocol::websocket::WsMessage, Transformer}; +use tokio::sync::mpsc; + +/// Generic OrderBook [`ExchangeTransformer`]s. +pub mod book; + +/// Generic stateless [`ExchangeTransformer`] often used for transforming +/// [`PublicTrades`](crate::subscription::trade::PublicTrades) streams. +pub mod stateless; + +/// Defines how to construct a [`Transformer`] used by [`MarketStream`](super::MarketStream)s to +/// translate exchange specific types to normalised Barter types. +#[async_trait] +pub trait ExchangeTransformer +where + Self: Transformer, Error = DataError> + Sized, + Kind: SubscriptionKind, +{ + /// Construct a new [`Self`]. + /// + /// The [`mpsc::UnboundedSender`] can be used by [`Self`] to send messages back to the exchange. + async fn new( + ws_sink_tx: mpsc::UnboundedSender, + instrument_map: Map, + ) -> Result; +} diff --git a/barter-data/src/transformer/stateless.rs b/barter-data/src/transformer/stateless.rs new file mode 100644 index 00000000..1e338d6e --- /dev/null +++ b/barter-data/src/transformer/stateless.rs @@ -0,0 +1,80 @@ +use super::ExchangeTransformer; +use crate::{ + error::DataError, + event::{MarketEvent, MarketIter}, + exchange::{Connector, ExchangeId}, + subscription::{Map, SubscriptionKind}, + Identifier, +}; +use async_trait::async_trait; +use barter_integration::{model::SubscriptionId, protocol::websocket::WsMessage, Transformer}; +use serde::{Deserialize, Serialize}; +use std::marker::PhantomData; +use tokio::sync::mpsc; + +/// Standard generic stateless [`ExchangeTransformer`] to translate exchange specific types into +/// normalised Barter types. Often used with +/// [`PublicTrades`](crate::subscription::trade::PublicTrades) or +/// [`OrderBooksL1`](crate::subscription::book::OrderBooksL1) streams. +#[derive(Clone, Eq, PartialEq, Debug, Serialize)] +pub struct StatelessTransformer { + instrument_map: Map, + phantom: PhantomData<(Exchange, Kind, Input)>, +} + +#[async_trait] +impl ExchangeTransformer + for StatelessTransformer +where + Exchange: Connector + Send, + InstrumentId: Clone + Send, + Kind: SubscriptionKind + Send, + Input: Identifier> + for<'de> Deserialize<'de>, + MarketIter: From<(ExchangeId, InstrumentId, Input)>, +{ + async fn new( + _: mpsc::UnboundedSender, + instrument_map: Map, + ) -> Result { + Ok(Self { + instrument_map, + phantom: PhantomData, + }) + } +} + +impl Transformer + for StatelessTransformer +where + Exchange: Connector, + InstrumentId: Clone, + Kind: SubscriptionKind, + Input: Identifier> + for<'de> Deserialize<'de>, + MarketIter: From<(ExchangeId, InstrumentId, Input)>, +{ + type Error = DataError; + type Input = Input; + type Output = MarketEvent; + type OutputIter = Vec>; + + fn transform(&mut self, input: Self::Input) -> Self::OutputIter { + // Determine if the message has an identifiable SubscriptionId + let subscription_id = match input.id() { + Some(subscription_id) => subscription_id, + None => return vec![], + }; + + // Find Instrument associated with Input and transform + match self.instrument_map.find(&subscription_id) { + Ok(instrument) => { + MarketIter::::from(( + Exchange::ID, + instrument.clone(), + input, + )) + .0 + } + Err(unidentifiable) => vec![Err(DataError::Socket(unidentifiable))], + } + } +} diff --git a/barter-execution/Cargo.toml b/barter-execution/Cargo.toml new file mode 100644 index 00000000..810e0e74 --- /dev/null +++ b/barter-execution/Cargo.toml @@ -0,0 +1,47 @@ +[package] +name = "barter-execution" +version = "0.3.0" +edition = "2021" +authors = ["Just A Stream <93921983+just-a-stream@users.noreply.github.com>"] +license = "MIT" +documentation = "https://docs.rs/barter-execution/" +repository = "https://github.com/barter-rs/barter-rs" +readme = "README.md" +description = "High-performance and normalised trading interface capable of executing across many financial venues. Also provides a feature rich simulated exchange." +keywords = ["trading", "backtesting", "crypto", "stocks", "investment"] +categories = ["accessibility", "simulation"] + + +[dependencies] +# Barter Ecosystem +barter-integration = { path = "../barter-integration", version = "0.7.3" } +barter-data = { path = "../barter-data", version = "0.8.1" } + +# Logging +tracing = { workspace = true } + +# Async +tokio = { workspace = true, features = ["sync", "macros", "rt-multi-thread"] } +tokio-stream = { workspace = true, features = ["sync"] } +tokio-tungstenite = { workspace = true, features = ["rustls-tls-webpki-roots"] } +futures = { workspace = true } +async-trait = { workspace = true } +pin-project = { workspace = true } + +# Error +thiserror = { workspace = true } + +# SerDe +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } + +# Protocol +reqwest = { workspace = true, features = ["rustls-tls", "json"] } + +# Data Structures +parking_lot = { workspace = true } + +# Misc +uuid = { workspace = true, features = ["v4", "serde"]} +chrono = { workspace = true, features = ["serde"]} +#num-traits = "0.2.15" diff --git a/barter-execution/LICENSE b/barter-execution/LICENSE new file mode 100644 index 00000000..18fcb836 --- /dev/null +++ b/barter-execution/LICENSE @@ -0,0 +1,25 @@ +Copyright (c) 2022 Barter-Execution Contributors + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/barter-execution/README.md b/barter-execution/README.md new file mode 100644 index 00000000..14c3aec3 --- /dev/null +++ b/barter-execution/README.md @@ -0,0 +1,78 @@ +# Barter-Execution +High-performance and normalised trading interface capable of executing across many financial venues. Also provides +a feature rich simulated exchange to assist with backtesting and dry-trading. + +**It is:** +* **Easy**: ExecutionClient trait provides a unified and simple language for interacting with exchanges. +* **Normalised**: Allow your strategy to communicate with every real or simulated exchange using the same interface. +* **Extensible**: Barter-Execution is highly extensible, making it easy to contribute by adding new exchange integrations! + +**See: [`Barter`], [`Barter-Integration`], [`Barter-Data`]** + +[![Crates.io][crates-badge]][crates-url] +[![MIT licensed][mit-badge]][mit-url] +[![Build Status][actions-badge]][actions-url] +[![Discord chat][discord-badge]][discord-url] + +[crates-badge]: https://img.shields.io/crates/v/barter-execution.svg +[crates-url]: https://crates.io/crates/barter-execution + +[mit-badge]: https://img.shields.io/badge/license-MIT-blue.svg +[mit-url]: https://gitlab.com/open-source-keir/financial-modelling/trading/barter-execution-rs/-/blob/main/LICENCE + +[actions-badge]: https://gitlab.com/open-source-keir/financial-modelling/trading/barter-execution-rs/badges/-/blob/main/pipeline.svg +[actions-url]: https://gitlab.com/open-source-keir/financial-modelling/trading/barter-execution-rs/-/commits/main + +[discord-badge]: https://img.shields.io/discord/910237311332151317.svg?logo=discord&style=flat-square +[discord-url]: https://discord.gg/wE7RqhnQMV + +[API Documentation] | [Chat] + +[`Barter`]: https://crates.io/crates/barter +[`Barter-Integration`]: https://crates.io/crates/barter-integration +[`Barter-Data`]: https://crates.io/crates/barter-data +[API Documentation]: https://docs.rs/barter-execution/latest/barter_execution +[Chat]: https://discord.gg/wE7RqhnQMV + +## Overview +High-performance and normalised trading interface capable of executing across many financial venues. Also provides +a feature rich simulated exchange to assist with backtesting and dry-trading. Communicate with an exchange by +initialising it's associated `ExecutionClient` instance. + + +## Example +For now, see `tests/simulated_exchange.rs` for a taste. + +## Getting Help +Firstly, see if the answer to your question can be found in the [API Documentation]. If the answer is not there, I'd be +happy to help to [Chat] and try answer your question via Discord. + +## Contributing +Thanks for your help in improving the Barter ecosystem! Please do get in touch on the discord to discuss +development, new features, and the future roadmap. +* **Implement the `ExecutionClient` trait to integrate a new exchange.** + +## Related Projects +In addition to the Barter-Execution crate, the Barter project also maintains: +* [`Barter`]: High-performance, extensible & modular trading components with batteries-included. Contains a + pre-built trading Engine that can serve as a live-trading or backtesting system. +* [`Barter-Integration`]: High-performance, low-level framework for composing flexible web integrations. +* [`Barter-Data`]: High-performance WebSocket integration library for streaming public market data from leading +cryptocurrency exchanges. + +## Roadmap +* Improve sophistication of the SimulatedExchange. + * `OrderKind::Market` & `OrderKind::ImmediateOrCancel` execution support. + * Enable many clients to use the exchange simultaneously, pathing the way for simulated order books generated by + many market archetype actors. +* Add many more `ExecutionClient` implementations for exchanges. + +## Licence +This project is licensed under the [MIT license]. + +[MIT license]: https://gitlab.com/open-source-keir/financial-modelling/trading/barter-execution-rs/-/blob/main/LICENSE + +### Contribution +Unless you explicitly state otherwise, any contribution intentionally submitted +for inclusion in Barter-Data by you, shall be licensed as MIT, without any additional +terms or conditions. diff --git a/barter-execution/src/error.rs b/barter-execution/src/error.rs new file mode 100644 index 00000000..2b1cc512 --- /dev/null +++ b/barter-execution/src/error.rs @@ -0,0 +1,22 @@ +use crate::model::{order::OrderKind, ClientOrderId}; +use barter_integration::model::instrument::symbol::Symbol; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +#[derive(Error, PartialEq, Eq, PartialOrd, Debug, Clone, Deserialize, Serialize)] +pub enum ExecutionError { + #[error("failed to build component due to missing attributes: {0}")] + BuilderIncomplete(String), + + #[error("SimulatedExchange error: {0}")] + Simulated(String), + + #[error("Balance for symbol {0} insufficient to open order")] + InsufficientBalance(Symbol), + + #[error("failed to find Order with ClientOrderId: {0}")] + OrderNotFound(ClientOrderId), + + #[error("failed to open Order due to unsupported OrderKind: {0}")] + UnsupportedOrderKind(OrderKind), +} diff --git a/barter-execution/src/execution/binance/mod.rs b/barter-execution/src/execution/binance/mod.rs new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/barter-execution/src/execution/binance/mod.rs @@ -0,0 +1 @@ + diff --git a/barter-execution/src/execution/ftx/mod.rs b/barter-execution/src/execution/ftx/mod.rs new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/barter-execution/src/execution/ftx/mod.rs @@ -0,0 +1 @@ + diff --git a/barter-execution/src/execution/mod.rs b/barter-execution/src/execution/mod.rs new file mode 100644 index 00000000..a3fa5ba8 --- /dev/null +++ b/barter-execution/src/execution/mod.rs @@ -0,0 +1,5 @@ +/// `Binance` & `BinanceFuturesUsd` [`ExecutionClient`](crate::ExecutionClient) implementations. +pub mod binance; + +/// `Ftx` [`ExecutionClient`](crate::ExecutionClient) implementation. +pub mod ftx; diff --git a/barter-execution/src/lib.rs b/barter-execution/src/lib.rs new file mode 100644 index 00000000..831ad6b4 --- /dev/null +++ b/barter-execution/src/lib.rs @@ -0,0 +1,187 @@ +#![warn( + missing_debug_implementations, + missing_copy_implementations, + rust_2018_idioms, + // missing_docs +)] +#![allow(clippy::type_complexity)] + +//! # Barter-Execution +//! High-performance and normalised trading interface capable of executing across many financial +//! venues. Also provides a feature rich simulated exchange to assist with backtesting +//! and dry-trading. Communicate with an exchange by initialising it's associated +//! `ExecutionClient` instance. +//! **It is:** +//! * **Easy**: ExecutionClient trait provides a unified and simple language for interacting with +//! exchanges. +//! * **Normalised**: Allow your strategy to communicate with every real or simulated exchange +//! using the same interface. +//! * **Extensible**: Barter-Execution is highly extensible, making it easy to contribute by adding +//! new exchange integrations! +//! +//! See `README.md` for more information and examples. + +use crate::{ + error::ExecutionError, + model::{ + balance::SymbolBalance, + order::{Cancelled, Open, Order, OrderId, RequestCancel, RequestOpen}, + AccountEvent, + }, +}; +use async_trait::async_trait; +use barter_integration::model::Exchange; +use serde::{Deserialize, Serialize}; +use std::fmt::{Display, Formatter}; +use tokio::sync::mpsc; + +/// Errors generated during live, dry, or simulated execution. +pub mod error; + +/// Core data structures to support executing on exchanges. +/// +/// eg/ `Order`, `Balance`, `Trade` etc. +pub mod model; + +/// [`ExecutionClient`] implementations for official exchanges. +pub mod execution; + +/// Simulated Exchange and it's associated simulated [`ExecutionClient`]. +pub mod simulated; + +/// Defines the communication with the exchange. Each exchange integration requires it's own +/// implementation. +#[async_trait] +pub trait ExecutionClient { + const CLIENT: ExecutionId; + type Config; + + /// Initialise a new [`ExecutionClient`] with the provided [`Self::Config`] and + /// [`AccountEvent`] transmitter. + /// + /// **Note:** + /// Usually entails spawning an asynchronous WebSocket event loop to consume [`AccountEvent`]s + /// from the exchange, as well as returning the HTTP client `Self`. + async fn init(config: Self::Config, event_tx: mpsc::UnboundedSender) -> Self; + + /// Fetch account [`Order`]s. + async fn fetch_orders_open(&self) -> Result>, ExecutionError>; + + /// Fetch account [`SymbolBalance`]s. + async fn fetch_balances(&self) -> Result, ExecutionError>; + + /// Open orders. + async fn open_orders( + &self, + open_requests: Vec>, + ) -> Vec, ExecutionError>>; + + /// Cancel [`Order`]s. + async fn cancel_orders( + &self, + cancel_requests: Vec>, + ) -> Vec, ExecutionError>>; + + /// Cancel all account [`Order`]s. + async fn cancel_orders_all(&self) -> Result>, ExecutionError>; +} + +/// Unique identifier for an [`ExecutionClient`] implementation. +#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Deserialize, Serialize)] +#[serde(rename = "execution", rename_all = "snake_case")] +pub enum ExecutionId { + Simulated, + Ftx, +} + +impl From for Exchange { + fn from(execution_id: ExecutionId) -> Self { + Exchange::from(execution_id.as_str()) + } +} + +impl Display for ExecutionId { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.as_str()) + } +} + +impl ExecutionId { + pub fn as_str(&self) -> &'static str { + match self { + ExecutionId::Simulated => "simulated", + ExecutionId::Ftx => "ftx", + } + } +} + +/// Utilities for generating common data structures required for testing. +pub mod test_util { + use crate::{ + model::{ + trade::{SymbolFees, Trade, TradeId}, + ClientOrderId, + }, + simulated::exchange::account::order::Orders, + Open, Order, OrderId, + }; + use barter_data::subscription::trade::PublicTrade; + use barter_integration::model::{ + instrument::{kind::InstrumentKind, Instrument}, + Exchange, Side, + }; + + pub fn client_orders( + trade_number: u64, + bids: Vec>, + asks: Vec>, + ) -> Orders { + Orders { + trade_counter: trade_number, + bids, + asks, + } + } + + pub fn order_open( + cid: ClientOrderId, + side: Side, + price: f64, + quantity: f64, + filled: f64, + ) -> Order { + Order { + exchange: Exchange::from("exchange"), + instrument: Instrument::from(("base", "quote", InstrumentKind::Perpetual)), + cid, + side, + state: Open { + id: OrderId::from("order_id"), + price, + quantity, + filled_quantity: filled, + }, + } + } + + pub fn public_trade(side: Side, price: f64, amount: f64) -> PublicTrade { + PublicTrade { + id: "trade_id".to_string(), + price, + amount, + side, + } + } + + pub fn trade(id: TradeId, side: Side, price: f64, quantity: f64, fees: SymbolFees) -> Trade { + Trade { + id, + order_id: OrderId::from("order_id"), + instrument: Instrument::from(("base", "quote", InstrumentKind::Perpetual)), + side, + price, + quantity, + fees, + } + } +} diff --git a/barter-execution/src/model/balance.rs b/barter-execution/src/model/balance.rs new file mode 100644 index 00000000..fcf6c8a3 --- /dev/null +++ b/barter-execution/src/model/balance.rs @@ -0,0 +1,134 @@ +use barter_integration::model::instrument::symbol::Symbol; +use serde::{Deserialize, Serialize}; + +/// [`Balance`] associated with a [`Symbol`]. +#[derive(Clone, PartialEq, PartialOrd, Debug, Deserialize, Serialize)] +pub struct SymbolBalance { + pub symbol: Symbol, + pub balance: Balance, +} + +impl SymbolBalance { + /// Construct a new [`SymbolBalance`] from a [`Symbol`] and it's associated [`Balance`]. + pub fn new(symbol: S, balance: Balance) -> Self + where + S: Into, + { + Self { + symbol: symbol.into(), + balance, + } + } +} + +/// Total and available balance values. +#[derive(Copy, Clone, PartialEq, PartialOrd, Debug, Deserialize, Serialize)] +pub struct Balance { + pub total: f64, + pub available: f64, +} + +impl Balance { + /// Construct a new [`Balance`]. + pub fn new(total: f64, available: f64) -> Self { + Self { total, available } + } + + /// Calculate the used (`total` - `available`) balance. + pub fn used(&self) -> f64 { + self.total - self.available + } + + /// Apply a [`BalanceDelta`] to this [`Balance`]. + pub fn apply(&mut self, delta: BalanceDelta) { + self.total += delta.total; + self.available += delta.available; + } +} + +/// Communicates a change to be applied to a [`Balance`]; +#[derive(Copy, Clone, PartialEq, PartialOrd, Debug, Deserialize, Serialize)] +pub struct BalanceDelta { + pub total: f64, + pub available: f64, +} + +impl BalanceDelta { + /// Construct a new [`BalanceDelta`]. + pub fn new(total: f64, available: f64) -> Self { + Self { total, available } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_balance_used() { + // No Balance is used + let balance = Balance::new(10.0, 10.0); + assert_eq!(balance.used(), 0.0); + + // All Balance is used + let balance = Balance::new(10.0, 0.0); + assert_eq!(balance.used(), balance.total); + + // Half Balance is used + let balance = Balance::new(10.0, 5.0); + assert_eq!(balance.used(), balance.available); + } + + #[test] + fn test_balance_apply_balance_delta() { + struct TestCase { + balance: Balance, + input_delta: BalanceDelta, + expected: Balance, + } + + let tests = vec![ + TestCase { + // TC0: Delta applies a negative total delta only + balance: Balance::new(10.0, 0.0), + input_delta: BalanceDelta::new(-10.0, 0.0), + expected: Balance::new(0.0, 0.0), + }, + TestCase { + // TC1: Delta applies a negative available delta only + balance: Balance::new(10.0, 10.0), + input_delta: BalanceDelta::new(0.0, -10.0), + expected: Balance::new(10.0, 0.0), + }, + TestCase { + // TC2: Delta applies a positive available delta only + balance: Balance::new(10.0, 10.0), + input_delta: BalanceDelta::new(0.0, 10.0), + expected: Balance::new(10.0, 20.0), + }, + TestCase { + // TC3: Delta applies a positive available delta only + balance: Balance::new(10.0, 10.0), + input_delta: BalanceDelta::new(0.0, 10.0), + expected: Balance::new(10.0, 20.0), + }, + TestCase { + // TC4: Delta applies a positive total & available delta + balance: Balance::new(10.0, 10.0), + input_delta: BalanceDelta::new(10.0, 10.0), + expected: Balance::new(20.0, 20.0), + }, + TestCase { + // TC5: Delta applies a negative total & available delta + balance: Balance::new(10.0, 10.0), + input_delta: BalanceDelta::new(-10.0, -10.0), + expected: Balance::new(0.0, 0.0), + }, + ]; + + for (index, mut test) in tests.into_iter().enumerate() { + test.balance.apply(test.input_delta); + assert_eq!(test.balance, test.expected, "TC{} failed", index); + } + } +} diff --git a/barter-execution/src/model/mod.rs b/barter-execution/src/model/mod.rs new file mode 100644 index 00000000..f79b513a --- /dev/null +++ b/barter-execution/src/model/mod.rs @@ -0,0 +1,55 @@ +use self::{ + balance::SymbolBalance, + order::{Cancelled, Open, Order}, + trade::Trade, +}; +use barter_integration::model::Exchange; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use std::fmt::Formatter; +use uuid::Uuid; + +pub mod balance; +pub mod order; +pub mod trade; + +/// Normalised Barter [`AccountEvent`] containing metadata about the included +/// [`AccountEventKind`] variant. Produced by [`ExecutionClients`](crate::ExecutionClient). +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct AccountEvent { + pub received_time: DateTime, + pub exchange: Exchange, + pub kind: AccountEventKind, +} + +/// Defines the type of Barter [`AccountEvent`]. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub enum AccountEventKind { + // HTTP Only + OrdersOpen(Vec>), + OrdersNew(Vec>), + OrdersCancelled(Vec>), + + // WebSocket Only + Balance(SymbolBalance), + Trade(Trade), + + // HTTP & WebSocket + Balances(Vec), +} + +#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Deserialize, Serialize)] +pub struct ClientOrderId(pub Uuid); + +impl std::fmt::Display for ClientOrderId { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +#[derive(Clone, Copy, Debug)] +pub enum ClientStatus { + Connected, + CancelOnly, + Disconnected, +} diff --git a/barter-execution/src/model/order.rs b/barter-execution/src/model/order.rs new file mode 100644 index 00000000..d03cba22 --- /dev/null +++ b/barter-execution/src/model/order.rs @@ -0,0 +1,472 @@ +use super::ClientOrderId; +use barter_integration::model::{ + instrument::{symbol::Symbol, Instrument}, + Exchange, Side, +}; +use serde::{Deserialize, Serialize}; +use std::{ + cmp::Ordering, + fmt::{Display, Formatter}, +}; + +/// Type of [`Order`]. +#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Deserialize, Serialize)] +pub enum OrderKind { + Market, + Limit, + PostOnly, + ImmediateOrCancel, +} + +impl Display for OrderKind { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{}", + match self { + OrderKind::Market => "market", + OrderKind::Limit => "limit", + OrderKind::PostOnly => "post_only", + OrderKind::ImmediateOrCancel => "immediate_or_cancel", + } + ) + } +} + +/// Todo: +#[derive(Clone, Eq, PartialEq, PartialOrd, Debug, Deserialize, Serialize)] +pub struct Order { + pub exchange: Exchange, + pub instrument: Instrument, + pub cid: ClientOrderId, + pub side: Side, + pub state: State, +} + +/// The initial state of an [`Order`]. Sent to the [`ExecutionClient`](crate::ExecutionClient) for +/// actioning. +#[derive(Copy, Clone, PartialEq, PartialOrd, Debug, Deserialize, Serialize)] +pub struct RequestOpen { + pub kind: OrderKind, + pub price: f64, + pub quantity: f64, +} + +impl Order { + pub fn required_available_balance(&self) -> (&Symbol, f64) { + match self.side { + Side::Buy => ( + &self.instrument.quote, + self.state.price * self.state.quantity, + ), + Side::Sell => (&self.instrument.base, self.state.quantity), + } + } +} + +/// State of an [`Order`] after a [`RequestOpen`] has been sent to the +/// [`ExecutionClient`](crate::ExecutionClient), but a confirmation response has not been received. +#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Deserialize, Serialize)] +pub struct InFlight; + +/// State of an [`Order`] after a request has been made for it to be [`Cancelled`]. +#[derive(Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Deserialize, Serialize)] +pub struct RequestCancel { + pub id: OrderId, +} + +impl From for RequestCancel +where + Id: Into, +{ + fn from(id: Id) -> Self { + Self { id: id.into() } + } +} + +/// Todo: +#[derive(Clone, PartialEq, Debug, Deserialize, Serialize)] +pub struct Open { + pub id: OrderId, + pub price: f64, + pub quantity: f64, + pub filled_quantity: f64, +} + +impl Open { + pub fn remaining_quantity(&self) -> f64 { + self.quantity - self.filled_quantity + } +} + +#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Debug, Deserialize, Serialize)] +pub enum OrderFill { + Full, + Partial, +} + +impl Ord for Order { + fn cmp(&self, other: &Self) -> Ordering { + self.partial_cmp(other) + .unwrap_or_else(|| panic!("{:?}.partial_cmp({:?}) impossible", self, other)) + } +} + +impl PartialOrd for Order { + fn partial_cmp(&self, other: &Self) -> Option { + match (self.side, other.side) { + (Side::Buy, Side::Buy) => match self.state.price.partial_cmp(&other.state.price)? { + Ordering::Equal => self + .state + .remaining_quantity() + .partial_cmp(&other.state.remaining_quantity()), + non_equal => Some(non_equal), + }, + (Side::Sell, Side::Sell) => match other.state.price.partial_cmp(&self.state.price)? { + Ordering::Equal => other + .state + .remaining_quantity() + .partial_cmp(&self.state.remaining_quantity()), + non_equal => Some(non_equal), + }, + _ => None, + } + } +} + +impl Eq for Order {} + +/// State of an [`Order`] after being [`Cancelled`]. +#[derive(Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Deserialize, Serialize)] +pub struct Cancelled { + pub id: OrderId, +} + +impl From for Cancelled +where + Id: Into, +{ + fn from(id: Id) -> Self { + Self { id: id.into() } + } +} + +/// [`Order`] identifier generated by an exchange. Cannot assume this is unique across each +/// [`Exchange`](barter_integration::model::Exchange), +/// [`Market`](barter_integration::model::Market), or +/// [`Instrument`](barter_integration::model::Instrument). +#[derive(Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Deserialize, Serialize)] +pub struct OrderId(pub String); + +impl From for OrderId +where + S: Display, +{ + fn from(id: S) -> Self { + Self(id.to_string()) + } +} + +impl From<&Order> for Order { + fn from(request: &Order) -> Self { + Self { + exchange: request.exchange.clone(), + instrument: request.instrument.clone(), + cid: request.cid, + side: request.side, + state: InFlight, + } + } +} + +impl From<(OrderId, Order)> for Order { + fn from((id, request): (OrderId, Order)) -> Self { + Self { + exchange: request.exchange.clone(), + instrument: request.instrument.clone(), + cid: request.cid, + side: request.side, + state: Open { + id, + price: request.state.price, + quantity: request.state.quantity, + filled_quantity: 0.0, + }, + } + } +} + +impl From> for Order { + fn from(order: Order) -> Self { + Self { + exchange: order.exchange.clone(), + instrument: order.instrument.clone(), + cid: order.cid, + side: order.side, + state: Cancelled { id: order.state.id }, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_util::order_open; + use uuid::Uuid; + + #[test] + fn test_open_order_remaining_quantity() { + let order = order_open(ClientOrderId(Uuid::new_v4()), Side::Buy, 10.0, 10.0, 5.0); + assert_eq!(order.state.remaining_quantity(), 5.0) + } + + #[test] + fn test_partial_ord_order_open() { + struct TestCase { + input_one: Order, + input_two: Order, + expected: Option, + } + + let cid = ClientOrderId(Uuid::new_v4()); + + let tests = vec![ + // -- Side::Buy Order -- + TestCase { + // TC0: Input One has higher price and higher quantity -> Greater + input_one: order_open(cid, Side::Buy, 1100.0, 2.0, 0.0), + input_two: order_open(cid, Side::Buy, 1000.0, 1.0, 0.0), + expected: Some(Ordering::Greater), + }, + TestCase { + // TC1: Input One has higher price but same quantity -> Greater + input_one: order_open(cid, Side::Buy, 1100.0, 1.0, 0.0), + input_two: order_open(cid, Side::Buy, 1000.0, 1.0, 0.0), + expected: Some(Ordering::Greater), + }, + TestCase { + // TC2: Input One has higher price but lower quantity -> Greater + input_one: order_open(cid, Side::Buy, 1100.0, 1.0, 0.0), + input_two: order_open(cid, Side::Buy, 1000.0, 2.0, 0.0), + expected: Some(Ordering::Greater), + }, + TestCase { + // TC3: Input One has same price and higher quantity -> Greater + input_one: order_open(cid, Side::Buy, 1000.0, 2.0, 0.0), + input_two: order_open(cid, Side::Buy, 1000.0, 1.0, 0.0), + expected: Some(Ordering::Greater), + }, + TestCase { + // TC4: Input One has same price and same quantity -> Equal + input_one: order_open(cid, Side::Buy, 1000.0, 1.0, 0.0), + input_two: order_open(cid, Side::Buy, 1000.0, 1.0, 0.0), + expected: Some(Ordering::Equal), + }, + TestCase { + // TC5: Input One has same price but lower quantity -> Less + input_one: order_open(cid, Side::Buy, 1000.0, 1.0, 0.0), + input_two: order_open(cid, Side::Buy, 1000.0, 2.0, 0.0), + expected: Some(Ordering::Less), + }, + TestCase { + // TC6: Input One has lower price but higher quantity -> Less + input_one: order_open(cid, Side::Buy, 1000.0, 2.0, 0.0), + input_two: order_open(cid, Side::Buy, 1100.0, 1.0, 0.0), + expected: Some(Ordering::Less), + }, + TestCase { + // TC7: Input One has lower price and same quantity -> Less + input_one: order_open(cid, Side::Buy, 1000.0, 1.0, 0.0), + input_two: order_open(cid, Side::Buy, 1100.0, 1.0, 0.0), + expected: Some(Ordering::Less), + }, + TestCase { + // TC8: Input One has lower price but lower quantity -> Less + input_one: order_open(cid, Side::Buy, 1000.0, 1.0, 0.0), + input_two: order_open(cid, Side::Buy, 1100.0, 2.0, 0.0), + expected: Some(Ordering::Less), + }, + // -- Side::Sell Order -- + TestCase { + // TC9: Input One has higher price and higher quantity -> Lesser + input_one: order_open(cid, Side::Sell, 1100.0, 2.0, 0.0), + input_two: order_open(cid, Side::Sell, 1000.0, 1.0, 0.0), + expected: Some(Ordering::Less), + }, + TestCase { + // TC10: Input One has higher price but same quantity -> Lesser + input_one: order_open(cid, Side::Sell, 1100.0, 1.0, 0.0), + input_two: order_open(cid, Side::Sell, 1000.0, 1.0, 0.0), + expected: Some(Ordering::Less), + }, + TestCase { + // T11: Input One has higher price but lower quantity -> Lesser + input_one: order_open(cid, Side::Sell, 1100.0, 1.0, 0.0), + input_two: order_open(cid, Side::Sell, 1000.0, 2.0, 0.0), + expected: Some(Ordering::Less), + }, + TestCase { + // TC12: Input One has same price and higher quantity -> Lesser + input_one: order_open(cid, Side::Sell, 1000.0, 2.0, 0.0), + input_two: order_open(cid, Side::Sell, 1000.0, 1.0, 0.0), + expected: Some(Ordering::Less), + }, + TestCase { + // TC13: Input One has same price and same quantity -> Equal + input_one: order_open(cid, Side::Sell, 1000.0, 1.0, 0.0), + input_two: order_open(cid, Side::Sell, 1000.0, 1.0, 0.0), + expected: Some(Ordering::Equal), + }, + TestCase { + // TC14: Input One has same price but lower quantity -> Greater + input_one: order_open(cid, Side::Sell, 1000.0, 1.0, 0.0), + input_two: order_open(cid, Side::Sell, 1000.0, 2.0, 0.0), + expected: Some(Ordering::Greater), + }, + TestCase { + // TC15: Input One has lower price but higher quantity -> Greater + input_one: order_open(cid, Side::Sell, 1000.0, 2.0, 0.0), + input_two: order_open(cid, Side::Sell, 1100.0, 1.0, 0.0), + expected: Some(Ordering::Greater), + }, + TestCase { + // TC16: Input One has lower price and same quantity -> Greater + input_one: order_open(cid, Side::Sell, 1000.0, 1.0, 0.0), + input_two: order_open(cid, Side::Sell, 1100.0, 1.0, 0.0), + expected: Some(Ordering::Greater), + }, + TestCase { + // TC17: Input One has lower price but lower quantity -> Greater + input_one: order_open(cid, Side::Sell, 1000.0, 1.0, 0.0), + input_two: order_open(cid, Side::Sell, 1100.0, 2.0, 0.0), + expected: Some(Ordering::Greater), + }, + // -- Inputs Are Not Comparable Due To Different Sides + TestCase { + // TC18: Input One has lower price but lower quantity -> Greater + input_one: order_open(cid, Side::Buy, 1000.0, 1.0, 0.0), + input_two: order_open(cid, Side::Sell, 1100.0, 2.0, 0.0), + expected: None, + }, + ]; + + for (index, test) in tests.into_iter().enumerate() { + let actual = test.input_one.partial_cmp(&test.input_two); + match (actual, test.expected) { + (None, None) => { + // Test passed + } + (Some(actual), Some(expected)) => { + assert_eq!(actual, expected, "TC{} failed", index) + } + (actual, expected) => { + // Test failed + panic!("TC{index} failed because actual != expected. \nActual: {actual:?}\nExpected: {expected:?}\n"); + } + } + } + } + + #[test] + fn test_sort_vector_order_open() { + struct TestCase { + input: Vec>, + expected: Vec>, + } + + let cid = ClientOrderId(Uuid::new_v4()); + + let tests = vec![ + TestCase { + // TC0: Vector Empty + input: vec![], + expected: vec![], + }, + // -- Vector: Side::Buy Order -- + TestCase { + // TC1: Vector of Side::Buy Order already sorted + input: vec![ + order_open(cid, Side::Buy, 100.0, 1.0, 0.0), + order_open(cid, Side::Buy, 200.0, 1.0, 0.0), + order_open(cid, Side::Buy, 300.0, 1.0, 0.0), + ], + expected: vec![ + order_open(cid, Side::Buy, 100.0, 1.0, 0.0), + order_open(cid, Side::Buy, 200.0, 1.0, 0.0), + order_open(cid, Side::Buy, 300.0, 1.0, 0.0), + ], + }, + TestCase { + // TC2: Vector of Side::Buy Order reverse sorted + input: vec![ + order_open(cid, Side::Buy, 300.0, 1.0, 0.0), + order_open(cid, Side::Buy, 200.0, 1.0, 0.0), + order_open(cid, Side::Buy, 100.0, 1.0, 0.0), + ], + expected: vec![ + order_open(cid, Side::Buy, 100.0, 1.0, 0.0), + order_open(cid, Side::Buy, 200.0, 1.0, 0.0), + order_open(cid, Side::Buy, 300.0, 1.0, 0.0), + ], + }, + TestCase { + // TC3: Vector of Side::Buy Order unsorted sorted + input: vec![ + order_open(cid, Side::Buy, 200.0, 1.0, 0.0), + order_open(cid, Side::Buy, 100.0, 1.0, 0.0), + order_open(cid, Side::Buy, 300.0, 1.0, 0.0), + ], + expected: vec![ + order_open(cid, Side::Buy, 100.0, 1.0, 0.0), + order_open(cid, Side::Buy, 200.0, 1.0, 0.0), + order_open(cid, Side::Buy, 300.0, 1.0, 0.0), + ], + }, + // -- Vector: Side::Sell Order -- + TestCase { + // TC1: Vector of Side::Sell Order already sorted + input: vec![ + order_open(cid, Side::Sell, 300.0, 1.0, 0.0), + order_open(cid, Side::Sell, 200.0, 1.0, 0.0), + order_open(cid, Side::Sell, 100.0, 1.0, 0.0), + ], + expected: vec![ + order_open(cid, Side::Sell, 300.0, 1.0, 0.0), + order_open(cid, Side::Sell, 200.0, 1.0, 0.0), + order_open(cid, Side::Sell, 100.0, 1.0, 0.0), + ], + }, + TestCase { + // TC2: Vector of Side::Sell Order reverse sorted + input: vec![ + order_open(cid, Side::Sell, 100.0, 1.0, 0.0), + order_open(cid, Side::Sell, 200.0, 1.0, 0.0), + order_open(cid, Side::Sell, 300.0, 1.0, 0.0), + ], + expected: vec![ + order_open(cid, Side::Sell, 300.0, 1.0, 0.0), + order_open(cid, Side::Sell, 200.0, 1.0, 0.0), + order_open(cid, Side::Sell, 100.0, 1.0, 0.0), + ], + }, + TestCase { + // TC3: Vector of Side::Sell Order unsorted sorted + input: vec![ + order_open(cid, Side::Sell, 200.0, 1.0, 0.0), + order_open(cid, Side::Sell, 100.0, 1.0, 0.0), + order_open(cid, Side::Sell, 300.0, 1.0, 0.0), + ], + expected: vec![ + order_open(cid, Side::Sell, 300.0, 1.0, 0.0), + order_open(cid, Side::Sell, 200.0, 1.0, 0.0), + order_open(cid, Side::Sell, 100.0, 1.0, 0.0), + ], + }, + ]; + + for (index, mut test) in tests.into_iter().enumerate() { + test.input.sort(); + assert_eq!(test.input, test.expected, "TC{} failed", index); + } + } +} diff --git a/barter-execution/src/model/trade.rs b/barter-execution/src/model/trade.rs new file mode 100644 index 00000000..d034a939 --- /dev/null +++ b/barter-execution/src/model/trade.rs @@ -0,0 +1,54 @@ +use super::order::OrderId; +use barter_integration::model::{ + instrument::{symbol::Symbol, Instrument}, + Side, +}; +use serde::{Deserialize, Serialize}; + +/// Normalised Barter private [`Trade`] model. +#[derive(Clone, PartialEq, PartialOrd, Debug, Deserialize, Serialize)] +pub struct Trade { + pub id: TradeId, + pub order_id: OrderId, + pub instrument: Instrument, + pub side: Side, + pub price: f64, + pub quantity: f64, + pub fees: SymbolFees, +} + +/// Private [`Trade`] identifier generated by an exchange. Cannot assume this is unique across each +/// [`Exchange`](barter_integration::model::Exchange), +/// [`Market`](barter_integration::model::Market), or +/// [`Instrument`](barter_integration::model::Instrument). +#[derive(Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Deserialize, Serialize)] +pub struct TradeId(pub String); + +impl From for TradeId +where + S: Into, +{ + fn from(id: S) -> Self { + Self(id.into()) + } +} + +/// [`Trade`] fees denominated in a [`Symbol`]. +#[derive(Clone, PartialEq, PartialOrd, Debug, Deserialize, Serialize)] +pub struct SymbolFees { + pub symbol: Symbol, + pub fees: f64, +} + +impl SymbolFees { + /// Construct a new [`SymbolFees`]. + pub fn new(symbol: S, fees: f64) -> Self + where + S: Into, + { + Self { + symbol: symbol.into(), + fees, + } + } +} diff --git a/barter-execution/src/simulated/exchange/account/balance.rs b/barter-execution/src/simulated/exchange/account/balance.rs new file mode 100644 index 00000000..671641e9 --- /dev/null +++ b/barter-execution/src/simulated/exchange/account/balance.rs @@ -0,0 +1,204 @@ +use crate::{ + model::{ + balance::{Balance, BalanceDelta, SymbolBalance}, + trade::Trade, + AccountEvent, AccountEventKind, + }, + ExecutionError, ExecutionId, Open, Order, +}; +use barter_integration::model::{ + instrument::{symbol::Symbol, Instrument}, + Exchange, Side, +}; +use chrono::Utc; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +/// [`ClientAccount`](super::ClientAccount) [`Balance`] for each [`Symbol`]. +#[derive(Clone, PartialEq, Debug, Deserialize, Serialize)] +pub struct ClientBalances(pub HashMap); + +impl ClientBalances { + /// Return a reference to the [`Balance`] of the specified [`Symbol`]. + pub fn balance(&self, symbol: &Symbol) -> Result<&Balance, ExecutionError> { + self.get(symbol).ok_or_else(|| { + ExecutionError::Simulated(format!( + "SimulatedExchange is not configured for Symbol: {symbol}" + )) + }) + } + + /// Return a mutable reference to the [`Balance`] of the specified [`Symbol`]. + pub fn balance_mut(&mut self, symbol: &Symbol) -> Result<&mut Balance, ExecutionError> { + self.get_mut(symbol).ok_or_else(|| { + ExecutionError::Simulated(format!( + "SimulatedExchange is not configured for Symbol: {symbol}" + )) + }) + } + + /// Fetch the client [`Balance`] for every [`Symbol``]. + pub fn fetch_all(&self) -> Vec { + self.0 + .clone() + .into_iter() + .map(|(symbol, balance)| SymbolBalance::new(symbol, balance)) + .collect() + } + + /// Determine if the client has sufficient available [`Balance`] to execute an + /// [`Order`]. + pub fn has_sufficient_available_balance( + &self, + symbol: &Symbol, + required_balance: f64, + ) -> Result<(), ExecutionError> { + let available = self.balance(symbol)?.available; + match available >= required_balance { + true => Ok(()), + false => Err(ExecutionError::InsufficientBalance(symbol.clone())), + } + } + + /// Updates the associated [`Symbol`] [`Balance`] when a client creates an [`Order`]. The + /// nature of the [`Balance`] change will depend on if the [`Order`] is a + /// [`Side::Buy`] or [`Side::Sell`]. + pub fn update_from_open(&mut self, open: &Order, required_balance: f64) -> AccountEvent { + let updated_balance = match open.side { + Side::Buy => { + let balance = self + .balance_mut(&open.instrument.quote) + .expect("Balance existence checked in has_sufficient_available_balance"); + + balance.available -= required_balance; + SymbolBalance::new(open.instrument.quote.clone(), *balance) + } + Side::Sell => { + let balance = self + .balance_mut(&open.instrument.base) + .expect("Balance existence checked in has_sufficient_available_balance"); + + balance.available -= required_balance; + SymbolBalance::new(open.instrument.base.clone(), *balance) + } + }; + + AccountEvent { + received_time: Utc::now(), + exchange: Exchange::from(ExecutionId::Simulated), + kind: AccountEventKind::Balance(updated_balance), + } + } + + /// Updates the associated [`Symbol`] [`Balance`] when a client cancels an [`Order`]. The + /// nature of the [`Balance`] change will depend on if the [`Order`] was a + /// [`Side::Buy`] or [`Side::Sell`]. + pub fn update_from_cancel(&mut self, cancelled: &Order) -> SymbolBalance { + match cancelled.side { + Side::Buy => { + let balance = self + .balance_mut(&cancelled.instrument.quote) + .expect("Balance existence checked when opening Order"); + + balance.available += cancelled.state.price * cancelled.state.remaining_quantity(); + SymbolBalance::new(cancelled.instrument.quote.clone(), *balance) + } + Side::Sell => { + let balance = self + .balance_mut(&cancelled.instrument.base) + .expect("Balance existence checked when opening Order"); + + balance.available += cancelled.state.remaining_quantity(); + SymbolBalance::new(cancelled.instrument.base.clone(), *balance) + } + } + } + + /// When a client [`Trade`] occurs, it causes a change in the [`Balance`] of the base & quote + /// [`Symbol`]. The nature of each [`Balance`] change will depend on if the matched + /// [`Order`] was a [`Side::Buy`] or [`Side::Sell`]. + /// + /// A [`Side::Buy`] match causes the [`Symbol`] [`Balance`] of the base to increase by the + /// `trade_quantity`, and the quote to decrease by the `trade_quantity * price`. + /// + /// A [`Side::Sell`] match causes the [`Symbol`] [`Balance`] of the base to decrease by the + /// `trade_quantity`, and the quote to increase by the `trade_quantity * price`. + pub fn update_from_trade(&mut self, trade: &Trade) -> AccountEvent { + let Instrument { base, quote, .. } = &trade.instrument; + + // Calculate the base & quote Balance deltas + let (base_delta, quote_delta) = match trade.side { + Side::Buy => { + // Base total & available increase by trade.quantity minus base trade.fees + let base_increase = trade.quantity - trade.fees.fees; + let base_delta = BalanceDelta { + total: base_increase, + available: base_increase, + }; + + // Quote total decreases by (trade.quantity * price) + // Note: available was already decreased by the opening of the Side::Buy order + let quote_delta = BalanceDelta { + total: -trade.quantity * trade.price, + available: 0.0, + }; + + (base_delta, quote_delta) + } + Side::Sell => { + // Base total decreases by trade.quantity + // Note: available was already decreased by the opening of the Side::Sell order + let base_delta = BalanceDelta { + total: -trade.quantity, + available: 0.0, + }; + + // Quote total & available increase by (trade.quantity * price) minus quote fees + let quote_increase = (trade.quantity * trade.price) - trade.fees.fees; + let quote_delta = BalanceDelta { + total: quote_increase, + available: quote_increase, + }; + + (base_delta, quote_delta) + } + }; + + // Apply BalanceDelta & return updated Balance + let base_balance = self.update(base, base_delta); + let quote_balance = self.update(quote, quote_delta); + + AccountEvent { + received_time: Utc::now(), + exchange: Exchange::from(ExecutionId::Simulated), + kind: AccountEventKind::Balances(vec![ + SymbolBalance::new(base.clone(), base_balance), + SymbolBalance::new(quote.clone(), quote_balance), + ]), + } + } + + /// Apply the [`BalanceDelta`] to the [`Balance`] of the specified [`Symbol`], returning a + /// `Copy` of the updated [`Balance`]. + pub fn update(&mut self, symbol: &Symbol, delta: BalanceDelta) -> Balance { + let base_balance = self.balance_mut(symbol).unwrap(); + + base_balance.apply(delta); + + *base_balance + } +} + +impl std::ops::Deref for ClientBalances { + type Target = HashMap; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl std::ops::DerefMut for ClientBalances { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} diff --git a/barter-execution/src/simulated/exchange/account/mod.rs b/barter-execution/src/simulated/exchange/account/mod.rs new file mode 100644 index 00000000..92f272ee --- /dev/null +++ b/barter-execution/src/simulated/exchange/account/mod.rs @@ -0,0 +1,432 @@ +use self::{balance::ClientBalances, order::ClientOrders}; +use crate::{ + model::{ + balance::{Balance, SymbolBalance}, + order::OrderKind, + AccountEvent, AccountEventKind, + }, + Cancelled, ExecutionError, ExecutionId, Open, Order, RequestCancel, RequestOpen, +}; +use barter_data::subscription::trade::PublicTrade; +use barter_integration::model::{instrument::Instrument, Exchange, Side}; +use chrono::Utc; +use std::{fmt::Debug, time::Duration}; +use tokio::sync::{mpsc, oneshot}; +use tracing::warn; + +/// [`ClientAccount`] [`Balance`] for each [`Symbol`](barter_integration::model::Symbol) and +/// associated balance management logic. +pub mod balance; + +/// [`ClientAccount`] [`ClientOrders`] management & matching logic. +pub mod order; + +/// Simulated account state containing [`ClientBalances`] and [`ClientOrders`]. Details the +/// simulated account fees and latency. +#[derive(Clone, Debug)] +pub struct ClientAccount { + pub latency: Duration, + pub fees_percent: f64, + pub event_account_tx: mpsc::UnboundedSender, + pub balances: ClientBalances, + pub orders: ClientOrders, +} + +impl ClientAccount { + /// Construct a [`ClientAccountBuilder`] for configuring a new [`ClientAccount`]. + pub fn builder() -> ClientAccountBuilder { + ClientAccountBuilder::new() + } + + /// Send every [`Order`] for every [`Instrument`] to the client. + pub fn fetch_orders_open( + &self, + response_tx: oneshot::Sender>, ExecutionError>>, + ) { + respond_with_latency(self.latency, response_tx, Ok(self.orders.fetch_all())); + } + + /// Send the [`Balance`] for every [`Symbol`](barter_integration::model::Symbol) to the client. + pub fn fetch_balances( + &self, + response_tx: oneshot::Sender, ExecutionError>>, + ) { + respond_with_latency(self.latency, response_tx, Ok(self.balances.fetch_all())); + } + + /// Execute open order requests and send the response via the provided [`oneshot::Sender`]. + pub fn open_orders( + &mut self, + open_requests: Vec>, + response_tx: oneshot::Sender, ExecutionError>>>, + ) { + let open_results = open_requests + .into_iter() + .map(|request| self.try_open_order_atomic(request)) + .collect(); + + respond_with_latency(self.latency, response_tx, open_results); + } + + /// Execute an open order request, adding it to [`ClientOrders`] and updating the associated + /// [`Balance`]. Sends an [`AccountEvent`] for both the new order and balance update. + pub fn try_open_order_atomic( + &mut self, + request: Order, + ) -> Result, ExecutionError> { + Self::check_order_kind_support(request.state.kind)?; + + // Calculate required available balance to open order + let (symbol, required_balance) = request.required_available_balance(); + + // Check available balance is sufficient + self.balances + .has_sufficient_available_balance(symbol, required_balance)?; + + // Build Open + let open = self.orders.build_order_open(request); + + // Retrieve client Instrument Orders + let orders = self.orders.orders_mut(&open.instrument)?; + + // Now that fallible operations have succeeded, mutate ClientBalances & ClientOrders + orders.add_order_open(open.clone()); + let balance_event = self.balances.update_from_open(&open, required_balance); + + // Send AccountEvents to client + self.event_account_tx + .send(balance_event) + .expect("Client is offline - failed to send AccountEvent::Balance"); + + self.event_account_tx + .send(AccountEvent { + received_time: Utc::now(), + exchange: Exchange::from(ExecutionId::Simulated), + kind: AccountEventKind::OrdersNew(vec![open.clone()]), + }) + .expect("Client is offline - failed to send AccountEvent::Trade"); + + Ok(open) + } + + /// Check if the [`Order`] [`OrderKind`] is supported. + pub fn check_order_kind_support(kind: OrderKind) -> Result<(), ExecutionError> { + match kind { + OrderKind::Limit | OrderKind::PostOnly => Ok(()), + unsupported => Err(ExecutionError::UnsupportedOrderKind(unsupported)), + } + } + + /// Execute cancel order requests and send the response via the provided [`oneshot::Sender`]. + pub fn cancel_orders( + &mut self, + cancel_requests: Vec>, + response_tx: oneshot::Sender, ExecutionError>>>, + ) { + let cancel_results = cancel_requests + .into_iter() + .map(|request| self.try_cancel_order_atomic(request)) + .collect(); + + respond_with_latency(self.latency, response_tx, cancel_results); + } + + /// Execute a cancel order request, removing it from the [`ClientOrders`] and updating the + /// associated [`Balance`]. Sends an [`AccountEvent`] for both the order cancel and balance + /// update. + pub fn try_cancel_order_atomic( + &mut self, + request: Order, + ) -> Result, ExecutionError> { + // Retrieve client Instrument Orders + let orders = self.orders.orders_mut(&request.instrument)?; + + // Find & remove Order associated with the Order + let removed = match request.side { + Side::Buy => { + // Search for Order using OrderId + let index = orders + .bids + .iter() + .position(|bid| bid.state.id == request.state.id) + .ok_or(ExecutionError::OrderNotFound(request.cid))?; + orders.bids.remove(index) + } + Side::Sell => { + // Search for Order using OrderId + let index = orders + .asks + .iter() + .position(|ask| ask.state.id == request.state.id) + .ok_or(ExecutionError::OrderNotFound(request.cid))?; + + orders.asks.remove(index) + } + }; + + // Now that fallible operations have succeeded, mutate ClientBalances + let balance_event = self.balances.update_from_cancel(&removed); + + // Map Order to Order + let cancelled = Order::from(removed); + + // Send AccountEvents to client + self.event_account_tx + .send(AccountEvent { + received_time: Utc::now(), + exchange: Exchange::from(ExecutionId::Simulated), + kind: AccountEventKind::OrdersCancelled(vec![cancelled.clone()]), + }) + .expect("Client is offline - failed to send AccountEvent::Trade"); + + self.event_account_tx + .send(AccountEvent { + received_time: Utc::now(), + exchange: Exchange::from(ExecutionId::Simulated), + kind: AccountEventKind::Balance(balance_event), + }) + .expect("Client is offline - failed to send AccountEvent::Balance"); + + Ok(cancelled) + } + + /// Execute a cancel all orders request and send the response via the provided + /// [`oneshot::Sender`]. + pub fn cancel_orders_all( + &mut self, + response_tx: oneshot::Sender>, ExecutionError>>, + ) { + let removed_orders = self + .orders + .all + .values_mut() + .flat_map(|orders| { + let bids = orders.bids.drain(..); + let asks = orders.asks.drain(..); + + bids.chain(asks) + }) + .collect::>>(); + + let balance_updates = removed_orders + .iter() + .map(|cancelled| self.balances.update_from_cancel(cancelled)) + .collect(); + + let cancelled_orders = removed_orders + .into_iter() + .map(Order::from) + .collect::>>(); + + // Send AccountEvents to client + self.event_account_tx + .send(AccountEvent { + received_time: Utc::now(), + exchange: Exchange::from(ExecutionId::Simulated), + kind: AccountEventKind::OrdersCancelled(cancelled_orders.clone()), + }) + .expect("Client is offline - failed to send AccountEvent::OrdersCancelled"); + + self.event_account_tx + .send(AccountEvent { + received_time: Utc::now(), + exchange: Exchange::from(ExecutionId::Simulated), + kind: AccountEventKind::Balances(balance_updates), + }) + .expect("Client is offline - failed to send AccountEvent::Balances"); + + respond_with_latency(self.latency, response_tx, Ok(cancelled_orders)) + } + + /// Determine if the incoming [`PublicTrade`] liquidity matches any [`ClientOrders`] relating + /// to the [`Instrument`]. If there are matches, trades are simulated by client orders being + /// taken. + pub fn match_orders(&mut self, instrument: Instrument, trade: PublicTrade) { + // Client fees + let fees_percent = self.fees_percent; + + // Access the ClientOrders relating to the Instrument of the PublicTrade + let orders = match self.orders.orders_mut(&instrument) { + Ok(orders) => orders, + Err(error) => { + warn!( + ?error, %instrument, ?trade, "cannot match orders with unrecognised Instrument" + ); + return; + } + }; + + // Match client Orders to incoming PublicTrade if the liquidity intersects + let trades = match orders.has_matching_order(&trade) { + Some(Side::Buy) => orders.match_bids(&trade, fees_percent), + Some(Side::Sell) => orders.match_asks(&trade, fees_percent), + None => return, + }; + + // Apply Balance updates for each client Trade and send AccountEvents to client + for trade in trades { + // Update Balances + let balances_event = self.balances.update_from_trade(&trade); + + self.event_account_tx + .send(balances_event) + .expect("Client is offline - failed to send AccountEvent::Balances"); + + self.event_account_tx + .send(AccountEvent { + received_time: Utc::now(), + exchange: Exchange::from(ExecutionId::Simulated), + kind: AccountEventKind::Trade(trade), + }) + .expect("Client is offline - failed to send AccountEvent::Trade"); + } + } +} + +/// Sends the provided `Response` via the [`oneshot::Sender`] after waiting for the latency +/// [`Duration`]. Used to simulate network latency between the exchange and client. +pub fn respond_with_latency( + latency: Duration, + response_tx: oneshot::Sender, + response: Response, +) where + Response: Debug + Send + 'static, +{ + tokio::spawn(async move { + tokio::time::sleep(latency).await; + response_tx + .send(response) + .expect("SimulatedExchange failed to send oneshot response to execution request") + }); +} + +#[derive(Debug, Default)] +pub struct ClientAccountBuilder { + latency: Option, + fees_percent: Option, + event_account_tx: Option>, + instruments: Option>, + balances: Option, +} + +impl ClientAccountBuilder { + fn new() -> Self { + Self { + ..Default::default() + } + } + + pub fn latency(self, value: Duration) -> Self { + Self { + latency: Some(value), + ..self + } + } + + pub fn fees_percent(self, value: f64) -> Self { + Self { + fees_percent: Some(value), + ..self + } + } + + pub fn event_account_tx(self, value: mpsc::UnboundedSender) -> Self { + Self { + event_account_tx: Some(value), + ..self + } + } + + pub fn instruments(self, value: Vec) -> Self { + Self { + instruments: Some(value), + ..self + } + } + + pub fn balances(self, value: ClientBalances) -> Self { + Self { + balances: Some(value), + ..self + } + } + + pub fn build(self) -> Result { + // Construct ClientAccount + let client_account = ClientAccount { + latency: self + .latency + .ok_or_else(|| ExecutionError::BuilderIncomplete("latency".to_string()))?, + fees_percent: self + .fees_percent + .ok_or_else(|| ExecutionError::BuilderIncomplete("fees_percent".to_string()))?, + event_account_tx: self + .event_account_tx + .ok_or_else(|| ExecutionError::BuilderIncomplete("event_account_tx".to_string()))?, + balances: self + .balances + .ok_or_else(|| ExecutionError::BuilderIncomplete("balances".to_string()))?, + orders: self + .instruments + .map(ClientOrders::new) + .ok_or_else(|| ExecutionError::BuilderIncomplete("instruments".to_string()))?, + }; + + // Validate each Instrument base & quote Symbol has an associated Balance + client_account + .orders + .all + .keys() + .flat_map(|instrument| [&instrument.base, &instrument.quote]) + .map(|symbol| client_account.balances.balance(symbol)) + .collect::, ExecutionError>>()?; + + Ok(client_account) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_check_order_kind_support() { + struct TestCase { + kind: OrderKind, + expected: Result<(), ExecutionError>, + } + + let tests = vec![ + TestCase { + // TC0: Market + kind: OrderKind::Market, + expected: Err(ExecutionError::UnsupportedOrderKind(OrderKind::Market)), + }, + TestCase { + // TC1: Limit + kind: OrderKind::Limit, + expected: Ok(()), + }, + TestCase { + // TC2: PostOnly + kind: OrderKind::PostOnly, + expected: Ok(()), + }, + TestCase { + // TC3: Immediate Or Cancel + kind: OrderKind::ImmediateOrCancel, + expected: Err(ExecutionError::UnsupportedOrderKind( + OrderKind::ImmediateOrCancel, + )), + }, + ]; + + for (index, test) in tests.into_iter().enumerate() { + let actual = ClientAccount::check_order_kind_support(test.kind); + match test.expected { + Ok(()) => assert!(actual.is_ok(), "TC{} failed", index), + Err(_) => assert!(actual.is_err(), "TC{} failed", index), + } + } + } +} diff --git a/barter-execution/src/simulated/exchange/account/order.rs b/barter-execution/src/simulated/exchange/account/order.rs new file mode 100644 index 00000000..8770f41b --- /dev/null +++ b/barter-execution/src/simulated/exchange/account/order.rs @@ -0,0 +1,832 @@ +use crate::{ + model::trade::{SymbolFees, Trade, TradeId}, + ExecutionError, Open, Order, OrderId, RequestOpen, +}; +use barter_data::subscription::trade::PublicTrade; +use barter_integration::model::{instrument::Instrument, Side}; +use serde::{Deserialize, Serialize}; +use std::{cmp::Ordering, collections::HashMap}; + +/// [`ClientAccount`](super::ClientAccount) [`Orders`] for each [`Instrument`]. +#[derive(Clone, Eq, PartialEq, Debug, Default, Deserialize, Serialize)] +pub struct ClientOrders { + pub request_counter: u64, + pub all: HashMap, +} + +impl ClientOrders { + /// Construct a new [`ClientOrders`] from the provided selection of [`Instrument`]s. + pub fn new(instruments: Vec) -> Self { + Self { + request_counter: 0, + all: instruments + .into_iter() + .map(|instrument| (instrument, Orders::default())) + .collect(), + } + } + + /// Return a mutable reference to the client [`Orders`] of the specified [`Instrument`]. + pub fn orders_mut(&mut self, instrument: &Instrument) -> Result<&mut Orders, ExecutionError> { + self.all.get_mut(instrument).ok_or_else(|| { + ExecutionError::Simulated(format!( + "SimulatedExchange is not configured for Instrument: {instrument}" + )) + }) + } + + /// Fetch the bid and ask [`Order`]s for every [`Instrument`]. + pub fn fetch_all(&self) -> Vec> { + self.all + .values() + .flat_map(|market| [&market.bids, &market.asks]) + .flatten() + .cloned() + .collect() + } + + /// Build an [`Order`] from the provided [`Order`]. The request counter + /// is incremented and the new total is used as a unique [`OrderId`]. + pub fn build_order_open(&mut self, request: Order) -> Order { + self.increment_request_counter(); + Order::from((self.order_id(), request)) + } + + /// Increment the [`Order`] counter by one to ensure the next generated + /// [`OrderId`] is unique. + pub fn increment_request_counter(&mut self) { + self.request_counter += 1; + } + + /// Generate a unique [`OrderId`]. + pub fn order_id(&self) -> OrderId { + OrderId(self.request_counter.to_string()) + } +} + +/// Client [`Orders`] for an [`Instrument`]. Simulates client orders in an real +/// multi-participant OrderBook. +#[derive(Clone, Eq, PartialEq, Debug, Default, Deserialize, Serialize)] +pub struct Orders { + pub trade_counter: u64, + pub bids: Vec>, + pub asks: Vec>, +} + +impl Orders { + /// Add an [`Order`] to the bids or asks depending on it's [`Side`]. + pub fn add_order_open(&mut self, open: Order) { + match open.side { + Side::Buy => { + // Add Order to open bids + self.bids.push(open); + self.bids.sort(); + } + Side::Sell => { + // Add Order to open asks + self.asks.push(open); + self.asks.sort(); + } + } + } + + /// Check if an input [`PublicTrade`] matches an bid or ask client [`Open`]. + /// + /// Note: + /// - In the event that the client has opened both a bid and ask [`Order`] at the same + /// price, preferentially select the Order with the larger remaining quantity to + /// match on. + pub fn has_matching_order(&self, trade: &PublicTrade) -> Option { + match (self.bids.last(), self.asks.last()) { + // Check the best bid & ask Order for a match + (Some(best_bid), Some(best_ask)) => { + // Note: + // In the unlikely case that: best_bid.price == best_ask.price == trade.price + // Preferentially select the larger remaining quantity Order to match on + if best_bid.state.price == trade.price && best_ask.state.price == trade.price { + let best_bid_quantity = best_bid.state.remaining_quantity(); + let best_ask_quantity = best_ask.state.remaining_quantity(); + match best_bid_quantity.partial_cmp(&best_ask_quantity) { + Some(Ordering::Greater) => Some(Side::Buy), + _ => Some(Side::Sell), + } + } + // Best bid matches + else if best_bid.state.price >= trade.price { + Some(Side::Buy) + } + // Best ask matches + else if best_ask.state.price <= trade.price { + Some(Side::Sell) + } + // No matches + else { + None + } + } + + // Best bid Order matches the input PublicTrade + (Some(best_bid), None) if best_bid.state.price >= trade.price => Some(Side::Buy), + + // Best ask Order matches the input PublicTrade + (None, Some(best_ask)) if best_ask.state.price <= trade.price => Some(Side::Sell), + + // Either no bid or ask Order, or no matches + _ => None, + } + } + + /// Simulates [`Side::Buy`] trades by using the [`PublicTrade`] liquidity to match on open + /// client bid [`Order`]s. + pub fn match_bids(&mut self, trade: &PublicTrade, fees_percent: f64) -> Vec { + // Keep track of how much trade liquidity is remaining to match with + let mut remaining_liquidity = trade.amount; + + // Collection of execution Trades generated from Order matches + let mut trades = vec![]; + + let remaining_best_bid = loop { + // Pop the best bid Order + let mut best_bid = match self.bids.pop() { + Some(best_bid) => best_bid, + None => break None, + }; + + // Break with remaining best bid if it's not a match, or trade liquidity is exhausted + if best_bid.state.price < trade.price || remaining_liquidity <= 0.0 { + break Some(best_bid); + } + + // Remaining liquidity is either a full-fill or a partial-fill + self.trade_counter += 1; + match OrderFill::kind(&best_bid, remaining_liquidity) { + // Full Order fill + OrderFill::Full => { + // Remove trade quantity from remaining liquidity + let trade_quantity = best_bid.state.remaining_quantity(); + remaining_liquidity -= trade_quantity; + + // Generate execution Trade from full Order fill + trades.push(self.generate_trade(best_bid, trade_quantity, fees_percent)); + + // If exact full fill with zero remaining liquidity (highly unlikely), break + if remaining_liquidity == 0.0 { + break None; + } + } + + // Partial Order fill with zero remaining trade liquidity + OrderFill::Partial => { + // Partial-fill means trade quantity is all the remaining trade liquidity + let trade_quantity = remaining_liquidity; + + // Generate execution Trade from partial Order fill + best_bid.state.filled_quantity += trade_quantity; + trades.push(self.generate_trade( + best_bid.clone(), + trade_quantity, + fees_percent, + )); + + break Some(best_bid); + } + } + }; + + // If remaining best bid had a partial-fill, or is not a match, put it back as the best bid + if let Some(remaining_best_bid) = remaining_best_bid { + self.bids.push(remaining_best_bid); + } + + trades + } + + /// Generate a client [`Trade`] with a unique [`TradeId`] for this [`Instrument`] market. + pub fn generate_trade( + &self, + order: Order, + trade_quantity: f64, + fees_percent: f64, + ) -> Trade { + // Calculate the trade fees (denominated in base or quote depending on Order Side) + let fees = calculate_fees(&order, trade_quantity, fees_percent); + + // Generate execution Trade from the Order match + Trade { + id: self.trade_id(), + order_id: order.state.id, + instrument: order.instrument, + side: order.side, + price: order.state.price, + quantity: trade_quantity, + fees, + } + } + + /// Use the `trade_counter` value to generate a unique [`TradeId`] for this [`Instrument`] + /// market. + pub fn trade_id(&self) -> TradeId { + TradeId(self.trade_counter.to_string()) + } + + /// Simulates [`Side::Sell`] trades by using the [`PublicTrade`] liquidity to match on open + /// client bid [`Order`]s. + pub fn match_asks(&mut self, trade: &PublicTrade, fees_percent: f64) -> Vec { + // Keep track of how much trade liquidity is remaining to match with + let mut remaining_liquidity = trade.amount; + + // Collection of execution Trades generated from Order matches + let mut trades = vec![]; + + let remaining_best_ask = loop { + // Pop the best Order + let mut best_ask = match self.asks.pop() { + Some(best_ask) => best_ask, + None => break None, + }; + + // Break with remaining best ask if it's not a match, or trade liquidity is exhausted + if best_ask.state.price > trade.price || remaining_liquidity <= 0.0 { + break Some(best_ask); + } + + // Remaining liquidity is either a full-fill or a partial-fill + self.trade_counter += 1; + match OrderFill::kind(&best_ask, remaining_liquidity) { + // Full Order fill + OrderFill::Full => { + // Remove trade quantity from remaining liquidity + let trade_quantity = best_ask.state.remaining_quantity(); + remaining_liquidity -= trade_quantity; + + // Generate execution Trade from full Order fill + trades.push(self.generate_trade(best_ask, trade_quantity, fees_percent)); + + // If exact full fill with zero remaining liquidity (highly unlikely), break + if remaining_liquidity == 0.0 { + break None; + } + } + + // Partial Order fill with zero remaining trade liquidity + OrderFill::Partial => { + // Partial-fill means trade quantity is all the remaining trade liquidity + let trade_quantity = remaining_liquidity; + + // Generate execution Trade from partial Order fill + best_ask.state.filled_quantity += trade_quantity; + trades.push(self.generate_trade( + best_ask.clone(), + trade_quantity, + fees_percent, + )); + + break Some(best_ask); + } + } + }; + + // If remaining best ask had a partial-fill, or is not a match, put it back as the best ask + if let Some(remaining_best_bid) = remaining_best_ask { + self.asks.push(remaining_best_bid); + } + + trades + } + + /// Calculates the total number of open bids and asks. + pub fn num_orders(&self) -> usize { + self.bids.len() + self.asks.len() + } +} + +/// Communicates if an [`Order`] liquidity match is a full or partial fill. Partial fills +/// leave the order still open with some proportion of the initial quantity still active. +#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Debug, Deserialize, Serialize)] +pub enum OrderFill { + Full, + Partial, +} + +impl OrderFill { + /// Determine the [`OrderFill`] kind given the [`Order`] and the available liquidity. + pub fn kind(order: &Order, liquidity: f64) -> Self { + match order.state.remaining_quantity() <= liquidity { + true => Self::Full, + false => Self::Partial, + } + } +} + +/// Calculate the [`SymbolFees`] of a [`Order`] match (trade). +pub fn calculate_fees(order: &Order, trade_quantity: f64, fees_percent: f64) -> SymbolFees { + match order.side { + Side::Buy => SymbolFees::new(order.instrument.base.clone(), fees_percent * trade_quantity), + Side::Sell => SymbolFees::new( + order.instrument.quote.clone(), + fees_percent * order.state.price * trade_quantity, + ), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + model::ClientOrderId, + simulated::exchange::account::order::Orders, + test_util::{client_orders, order_open, public_trade, trade}, + }; + use barter_integration::model::Side; + use uuid::Uuid; + + #[test] + fn test_client_orders_has_matching_order() { + struct TestCase { + orders: Orders, + input_trade: PublicTrade, + expected: Option, + } + + let cid = ClientOrderId(Uuid::new_v4()); + + let tests = vec![ + TestCase { + // TC0: No matching bids or asks since no open orders + orders: client_orders(0, vec![], vec![]), + input_trade: public_trade(Side::Buy, 100.0, 1.0), + expected: None, + }, + TestCase { + // TC1: No matching bid for trade with no asks open + orders: client_orders(0, vec![order_open(cid, Side::Buy, 100.0, 1.0, 0.0)], vec![]), + input_trade: public_trade(Side::Buy, 150.0, 1.0), + expected: None, + }, + TestCase { + // TC2: No matching ask for trade with no bids open + orders: client_orders( + 0, + vec![], + vec![order_open(cid, Side::Sell, 100.0, 1.0, 0.0)], + ), + input_trade: public_trade(Side::Sell, 50.0, 1.0), + expected: None, + }, + TestCase { + // TC3: Exact matching bid for trade with no asks open + orders: client_orders(0, vec![order_open(cid, Side::Buy, 100.0, 1.0, 0.0)], vec![]), + input_trade: public_trade(Side::Buy, 100.0, 1.0), + expected: Some(Side::Buy), + }, + TestCase { + // TC4: Exact matching ask for trade with no bids open + orders: client_orders( + 0, + vec![], + vec![order_open(cid, Side::Sell, 100.0, 1.0, 0.0)], + ), + input_trade: public_trade(Side::Sell, 100.0, 1.0), + expected: Some(Side::Sell), + }, + TestCase { + // TC5: No matches for trade with open bids and asks + orders: client_orders( + 0, + vec![order_open(cid, Side::Buy, 50.0, 1.0, 0.0)], + vec![order_open(cid, Side::Sell, 150.0, 1.0, 0.0)], + ), + input_trade: public_trade(Side::Buy, 100.0, 1.0), + expected: None, + }, + TestCase { + // TC6: Trade matches bid & ask (same price), so take larger quantity bid + orders: client_orders( + 0, + vec![order_open(cid, Side::Buy, 100.0, 100.0, 0.0)], + vec![order_open(cid, Side::Sell, 100.0, 1.0, 0.0)], + ), + input_trade: public_trade(Side::Buy, 100.0, 1.0), + expected: Some(Side::Buy), + }, + TestCase { + // TC6: Trade matches bid & ask (same price), so take larger quantity ask + orders: client_orders( + 0, + vec![order_open(cid, Side::Buy, 100.0, 1.0, 0.0)], + vec![order_open(cid, Side::Sell, 100.0, 100.0, 0.0)], + ), + input_trade: public_trade(Side::Buy, 100.0, 1.0), + expected: Some(Side::Sell), + }, + ]; + + for (index, test) in tests.into_iter().enumerate() { + let actual = test.orders.has_matching_order(&test.input_trade); + assert_eq!(actual, test.expected, "TC{} failed", index); + } + } + + #[test] + fn test_client_orders_match_bids() { + struct TestCase { + orders: Orders, + input_trade: PublicTrade, + input_fees_percent: f64, + expected_orders: Orders, + expected_trades: Vec, + } + + let cid = ClientOrderId(Uuid::new_v4()); + + let tests = vec![ + TestCase { + // TC0: Best bid matches the PublicTrade w/ a full-fill + orders: client_orders( + 0, + vec![ + order_open(cid, Side::Buy, 100.0, 1.0, 0.0), + order_open(cid, Side::Buy, 200.0, 1.0, 0.0), + ], + vec![], + ), + input_trade: public_trade(Side::Buy, 200.0, 1.0), + input_fees_percent: 0.1, + expected_orders: client_orders( + 1, + vec![order_open(cid, Side::Buy, 100.0, 1.0, 0.0)], + vec![], + ), + expected_trades: vec![trade( + TradeId(1.to_string()), + Side::Buy, + 200.0, + 1.0, + SymbolFees::new("base", 0.1 * 1.0), + )], + }, + TestCase { + // TC1: Two bids match the PublicTrade w/ two full-fills + orders: client_orders( + 0, + vec![ + order_open(cid, Side::Buy, 100.0, 1.0, 0.0), + order_open(cid, Side::Buy, 200.0, 1.0, 0.0), + ], + vec![], + ), + input_trade: public_trade(Side::Buy, 100.0, 2.0), + input_fees_percent: 0.1, + expected_orders: client_orders(2, vec![], vec![]), + expected_trades: vec![ + trade( + TradeId(1.to_string()), + Side::Buy, + 200.0, + 1.0, + SymbolFees::new("base", 0.1 * 1.0), + ), + trade( + TradeId(2.to_string()), + Side::Buy, + 100.0, + 1.0, + SymbolFees::new("base", 0.1 * 1.0), + ), + ], + }, + TestCase { + // TC2: Two bids match the PublicTrade w/ one full-fill & one partial-fill + orders: client_orders( + 0, + vec![ + order_open(cid, Side::Buy, 100.0, 1.0, 0.0), + order_open(cid, Side::Buy, 200.0, 1.0, 0.0), + ], + vec![], + ), + input_trade: public_trade(Side::Sell, 100.0, 1.5), + input_fees_percent: 0.1, + expected_orders: client_orders( + 2, + vec![order_open(cid, Side::Buy, 100.0, 1.0, 0.5)], + vec![], + ), + expected_trades: vec![ + trade( + TradeId(1.to_string()), + Side::Buy, + 200.0, + 1.0, + SymbolFees::new("base", 0.1 * 1.0), + ), + trade( + TradeId(2.to_string()), + Side::Buy, + 100.0, + 0.5, + SymbolFees::new("base", 0.1 * 0.5), + ), + ], + }, + TestCase { + // TC3: No bids match the PublicTrade + orders: client_orders( + 0, + vec![ + order_open(cid, Side::Buy, 100.0, 1.0, 0.0), + order_open(cid, Side::Buy, 200.0, 1.0, 0.0), + ], + vec![], + ), + input_trade: public_trade(Side::Sell, 1_000_000_000.0, 1.0), + input_fees_percent: 0.1, + expected_orders: client_orders( + 0, + vec![ + order_open(cid, Side::Buy, 100.0, 1.0, 0.0), + order_open(cid, Side::Buy, 200.0, 1.0, 0.0), + ], + vec![], + ), + expected_trades: vec![], + }, + ]; + + for (index, mut test) in tests.into_iter().enumerate() { + let actual_trades = test + .orders + .match_bids(&test.input_trade, test.input_fees_percent); + assert_eq!(actual_trades, test.expected_trades, "TC{}", index); + + let actual_orders = test.orders; + assert_eq!(actual_orders, test.expected_orders, "TC{}", index); + } + } + + #[test] + fn test_client_orders_match_asks() { + struct TestCase { + orders: Orders, + input_trade: PublicTrade, + input_fees_percent: f64, + expected_orders: Orders, + expected_trades: Vec, + } + + let cid = ClientOrderId(Uuid::new_v4()); + + let tests = vec![ + TestCase { + // TC0: Best ask matches the PublicTrade w/ a full-fill + orders: client_orders( + 0, + vec![], + vec![ + order_open(cid, Side::Sell, 200.0, 1.0, 0.0), + order_open(cid, Side::Sell, 100.0, 1.0, 0.0), + ], + ), + input_trade: public_trade(Side::Buy, 100.0, 1.0), + input_fees_percent: 0.1, + expected_orders: client_orders( + 1, + vec![], + vec![order_open(cid, Side::Sell, 200.0, 1.0, 0.0)], + ), + expected_trades: vec![trade( + TradeId(1.to_string()), + Side::Sell, + 100.0, + 1.0, + SymbolFees::new("quote", 0.1 * 100.0 * 1.0), + )], + }, + TestCase { + // TC1: Two asks match the PublicTrade w/ two full-fills + orders: client_orders( + 0, + vec![], + vec![ + order_open(cid, Side::Sell, 200.0, 1.0, 0.0), + order_open(cid, Side::Sell, 100.0, 1.0, 0.0), + ], + ), + input_trade: public_trade(Side::Buy, 200.0, 2.0), + input_fees_percent: 0.1, + expected_orders: client_orders(2, vec![], vec![]), + expected_trades: vec![ + trade( + TradeId(1.to_string()), + Side::Sell, + 100.0, + 1.0, + SymbolFees::new("quote", 0.1 * 100.0 * 1.0), + ), + trade( + TradeId(2.to_string()), + Side::Sell, + 200.0, + 1.0, + SymbolFees::new("quote", 0.1 * 200.0 * 1.0), + ), + ], + }, + TestCase { + // TC2: Two asks match the PublicTrade w/ one full-fill & one partial-fill + orders: client_orders( + 0, + vec![], + vec![ + order_open(cid, Side::Sell, 200.0, 1.0, 0.0), + order_open(cid, Side::Sell, 100.0, 1.0, 0.0), + ], + ), + input_trade: public_trade(Side::Sell, 200.0, 1.5), + input_fees_percent: 0.1, + expected_orders: client_orders( + 2, + vec![], + vec![order_open(cid, Side::Sell, 200.0, 1.0, 0.5)], + ), + expected_trades: vec![ + trade( + TradeId(1.to_string()), + Side::Sell, + 100.0, + 1.0, + SymbolFees::new("quote", 0.1 * 100.0 * 1.0), + ), + trade( + TradeId(2.to_string()), + Side::Sell, + 200.0, + 0.5, + SymbolFees::new("quote", 0.1 * 200.0 * 0.5), + ), + ], + }, + TestCase { + // TC3: No asks match the PublicTrade + orders: client_orders( + 0, + vec![], + vec![ + order_open(cid, Side::Sell, 200.0, 1.0, 0.0), + order_open(cid, Side::Sell, 100.0, 1.0, 0.0), + ], + ), + input_trade: public_trade(Side::Sell, 1.0, 1.0), + input_fees_percent: 0.1, + expected_orders: client_orders( + 0, + vec![], + vec![ + order_open(cid, Side::Sell, 200.0, 1.0, 0.0), + order_open(cid, Side::Sell, 100.0, 1.0, 0.0), + ], + ), + expected_trades: vec![], + }, + ]; + + for (index, mut test) in tests.into_iter().enumerate() { + let actual_trades = test + .orders + .match_asks(&test.input_trade, test.input_fees_percent); + assert_eq!(actual_trades, test.expected_trades, "TC{}", index); + + let actual_orders = test.orders; + assert_eq!(actual_orders, test.expected_orders, "TC{}", index); + } + } + + #[test] + fn test_client_orders_num_orders() { + struct TestCase { + orders: Orders, + expected_num: usize, + } + + let cid = ClientOrderId(Uuid::new_v4()); + + let tests = vec![ + TestCase { + // TC0: Empty orders + orders: client_orders(0, vec![], vec![]), + expected_num: 0, + }, + TestCase { + // TC1: one bid, empty ask + orders: client_orders(0, vec![order_open(cid, Side::Buy, 150.0, 1.0, 0.0)], vec![]), + expected_num: 1, + }, + TestCase { + // TC2: empty bids, one ask + orders: client_orders( + 0, + vec![], + vec![order_open(cid, Side::Sell, 150.0, 1.0, 0.0)], + ), + expected_num: 1, + }, + TestCase { + // TC2: many of each + orders: client_orders( + 0, + vec![ + order_open(cid, Side::Sell, 150.0, 1.0, 0.0), + order_open(cid, Side::Sell, 150.0, 1.0, 0.0), + ], + vec![ + order_open(cid, Side::Sell, 150.0, 1.0, 0.0), + order_open(cid, Side::Sell, 150.0, 1.0, 0.0), + ], + ), + expected_num: 4, + }, + ]; + + for (index, test) in tests.into_iter().enumerate() { + let actual = test.orders.num_orders(); + assert_eq!(actual, test.expected_num, "TC{} failed", index); + } + } + + #[test] + fn test_order_fill_kind() { + struct TestCase { + input_order: Order, + input_liquidity: f64, + expected: OrderFill, + } + + let cid = ClientOrderId(Uuid::new_v4()); + + let tests = vec![ + TestCase { + // TC0: Zero filled bid is fully filled by remaining liquidity + input_order: order_open(cid, Side::Buy, 10.0, 10.0, 0.0), + input_liquidity: 10.0, + expected: OrderFill::Full, + }, + TestCase { + // TC1: Partially filled bid is fully filled by remaining liquidity + input_order: order_open(cid, Side::Buy, 10.0, 10.0, 5.0), + input_liquidity: 10.0, + expected: OrderFill::Full, + }, + TestCase { + // TC2: Zero filled bid is partially filled by remaining liquidity + input_order: order_open(cid, Side::Buy, 10.0, 10.0, 0.0), + input_liquidity: 5.0, + expected: OrderFill::Partial, + }, + TestCase { + // TC3: Partially filled bid is partially filled by remaining liquidity + input_order: order_open(cid, Side::Buy, 10.0, 10.0, 1.0), + input_liquidity: 5.0, + expected: OrderFill::Partial, + }, + ]; + + for (index, test) in tests.into_iter().enumerate() { + let actual = OrderFill::kind(&test.input_order, test.input_liquidity); + assert_eq!(actual, test.expected, "TC{} failed", index); + } + } + + #[test] + fn test_calculate_fees() { + struct TestCase { + order: Order, + trade_quantity: f64, + fees_percent: f64, + expected: SymbolFees, + } + + let cid = ClientOrderId(Uuid::new_v4()); + + let tests = vec![ + TestCase { + // TC0: 10% trade fees from matched Side::Buy order + order: order_open(cid, Side::Buy, 100.0, 10.0, 0.0), + trade_quantity: 10.0, + fees_percent: 0.1, + expected: SymbolFees::new("base", 0.1 * 10.0), + }, + TestCase { + // TC1: 50% trade fees from matched Side::Sell order + order: order_open(cid, Side::Sell, 100.0, 10.0, 0.0), + trade_quantity: 10.0, + fees_percent: 0.5, + expected: SymbolFees::new("quote", 0.5 * 100.0 * 10.0), + }, + ]; + + for (index, test) in tests.into_iter().enumerate() { + let actual = calculate_fees(&test.order, test.trade_quantity, test.fees_percent); + assert_eq!(actual, test.expected, "TC{} failed", index); + } + } +} diff --git a/barter-execution/src/simulated/exchange/mod.rs b/barter-execution/src/simulated/exchange/mod.rs new file mode 100644 index 00000000..1d8c78de --- /dev/null +++ b/barter-execution/src/simulated/exchange/mod.rs @@ -0,0 +1,85 @@ +use super::{exchange::account::ClientAccount, SimulatedEvent}; +use crate::ExecutionError; +use tokio::sync::mpsc; + +/// [`SimulatedExchange`] account balances, open orders, fees, and latency. +pub mod account; + +/// [`SimulatedExchange`] that responds to [`SimulatedEvent`]s. +#[derive(Debug)] +pub struct SimulatedExchange { + pub event_simulated_rx: mpsc::UnboundedReceiver, + pub account: ClientAccount, +} + +impl SimulatedExchange { + /// Construct a [`ExchangeBuilder`] for configuring a new [`SimulatedExchange`]. + pub fn builder() -> ExchangeBuilder { + ExchangeBuilder::new() + } + + /// Run the [`SimulatedExchange`] by responding to [`SimulatedEvent`]s. + pub async fn run(mut self) { + while let Some(event) = self.event_simulated_rx.recv().await { + match event { + SimulatedEvent::FetchOrdersOpen(response_tx) => { + self.account.fetch_orders_open(response_tx) + } + SimulatedEvent::FetchBalances(response_tx) => { + self.account.fetch_balances(response_tx) + } + SimulatedEvent::OpenOrders((open_requests, response_tx)) => { + self.account.open_orders(open_requests, response_tx) + } + SimulatedEvent::CancelOrders((cancel_requests, response_tx)) => { + self.account.cancel_orders(cancel_requests, response_tx) + } + SimulatedEvent::CancelOrdersAll(response_tx) => { + self.account.cancel_orders_all(response_tx) + } + SimulatedEvent::MarketTrade((instrument, trade)) => { + self.account.match_orders(instrument, trade) + } + } + } + } +} + +#[derive(Debug, Default)] +pub struct ExchangeBuilder { + event_simulated_rx: Option>, + account: Option, +} + +impl ExchangeBuilder { + fn new() -> Self { + Self { + ..Default::default() + } + } + + pub fn event_simulated_rx(self, value: mpsc::UnboundedReceiver) -> Self { + Self { + event_simulated_rx: Some(value), + ..self + } + } + + pub fn account(self, value: ClientAccount) -> Self { + Self { + account: Some(value), + ..self + } + } + + pub fn build(self) -> Result { + Ok(SimulatedExchange { + event_simulated_rx: self.event_simulated_rx.ok_or_else(|| { + ExecutionError::BuilderIncomplete("event_simulated_rx".to_string()) + })?, + account: self + .account + .ok_or_else(|| ExecutionError::BuilderIncomplete("account".to_string()))?, + }) + } +} diff --git a/barter-execution/src/simulated/execution/mod.rs b/barter-execution/src/simulated/execution/mod.rs new file mode 100644 index 00000000..76612789 --- /dev/null +++ b/barter-execution/src/simulated/execution/mod.rs @@ -0,0 +1,106 @@ +use crate::{ + model::order::{Cancelled, Open, Order}, + simulated::SimulatedEvent, + AccountEvent, ExecutionClient, ExecutionError, ExecutionId, RequestCancel, RequestOpen, + SymbolBalance, +}; +use async_trait::async_trait; +use tokio::sync::{mpsc, oneshot}; + +/// Simulated [`ExecutionClient`] implementation that integrates with the Barter +/// [`SimulatedExchange`](super::exchange::SimulatedExchange). +#[derive(Clone, Debug)] +pub struct SimulatedExecution { + pub request_tx: mpsc::UnboundedSender, +} + +#[async_trait] +impl ExecutionClient for SimulatedExecution { + const CLIENT: ExecutionId = ExecutionId::Simulated; + type Config = mpsc::UnboundedSender; + + async fn init(request_tx: Self::Config, _: mpsc::UnboundedSender) -> Self { + Self { request_tx } + } + + async fn fetch_orders_open(&self) -> Result>, ExecutionError> { + // Oneshot channel to communicate with the SimulatedExchange + let (response_tx, response_rx) = oneshot::channel(); + + // Send FetchOrdersOpen request to the SimulatedExchange + self.request_tx + .send(SimulatedEvent::FetchOrdersOpen(response_tx)) + .expect("SimulatedExchange is offline - failed to send FetchOrdersOpen request"); + + // Receive FetchOrdersOpen response from the SimulatedExchange + response_rx + .await + .expect("SimulatedExchange is offline - failed to receive FetchOrdersOpen response") + } + + async fn fetch_balances(&self) -> Result, ExecutionError> { + // Oneshot channel to communicate with the SimulatedExchange + let (response_tx, response_rx) = oneshot::channel(); + + // Send FetchBalances request to the SimulatedExchange + self.request_tx + .send(SimulatedEvent::FetchBalances(response_tx)) + .expect("SimulatedExchange is offline - failed to send FetchBalances request"); + + // Receive FetchBalances response from the SimulatedExchange + response_rx + .await + .expect("SimulatedExchange is offline - failed to receive FetchBalances response") + } + + async fn open_orders( + &self, + open_requests: Vec>, + ) -> Vec, ExecutionError>> { + // Oneshot channel to communicate with the SimulatedExchange + let (response_tx, response_rx) = oneshot::channel(); + + // Send OpenOrders request to the SimulatedExchange + self.request_tx + .send(SimulatedEvent::OpenOrders((open_requests, response_tx))) + .expect("SimulatedExchange is offline - failed to send OpenOrders request"); + + // Receive OpenOrders response from the SimulatedExchange + response_rx + .await + .expect("SimulatedExchange is offline - failed to receive OpenOrders response") + } + + async fn cancel_orders( + &self, + cancel_requests: Vec>, + ) -> Vec, ExecutionError>> { + // Oneshot channel to communicate with the SimulatedExchange + let (response_tx, response_rx) = oneshot::channel(); + + // Send CancelOrders request to the SimulatedExchange + self.request_tx + .send(SimulatedEvent::CancelOrders((cancel_requests, response_tx))) + .expect("SimulatedExchange is offline - failed to send CancelOrders request"); + + // Receive CancelOrders response from the SimulatedExchange + response_rx + .await + .expect("SimulatedExchange is offline - failed to receive CancelOrders response") + } + + async fn cancel_orders_all(&self) -> Result>, ExecutionError> { + // Oneshot channel to communicate with the SimulatedExchange + let (response_tx, response_rx) = oneshot::channel(); + + // Send CancelOrdersAll request to the SimulatedExchange + self.request_tx + .send(SimulatedEvent::CancelOrdersAll(response_tx)) + .expect("SimulatedExchange is offline - failed to send CancelOrdersAll request"); + + // Receive CancelOrdersAll response from the SimulatedExchange + response_rx + .await + .expect("SimulatedExchange is offline - failed to receive CancelOrdersAll response") + } +} diff --git a/barter-execution/src/simulated/mod.rs b/barter-execution/src/simulated/mod.rs new file mode 100644 index 00000000..e4a1b3f5 --- /dev/null +++ b/barter-execution/src/simulated/mod.rs @@ -0,0 +1,38 @@ +use crate::{Cancelled, ExecutionError, Open, Order, RequestCancel, RequestOpen, SymbolBalance}; +use barter_data::subscription::trade::PublicTrade; +use barter_integration::model::instrument::Instrument; +use tokio::sync::oneshot; + +/// Simulated Exchange using public trade `Streams` to model available market liquidity. Liquidity +/// is then used to match to open client orders. +pub mod exchange; + +/// Simulated [`ExecutionClient`](crate::ExecutionClient) implementation that integrates with the +/// Barter [`SimulatedExchange`](exchange::SimulatedExchange). +pub mod execution; + +/// Events used to communicate with the Barter [`SimulatedExchange`](exchange::SimulatedExchange). +/// +/// Two main types of [`SimulatedEvent`]: +/// 1. Request sent from the [`SimulatedExecution`](execution::SimulatedExecution) +/// [`ExecutionClient`](crate::ExecutionClient). +/// 2. Market events used to model available liquidity and trigger matches with open client orders. +#[derive(Debug)] +pub enum SimulatedEvent { + FetchOrdersOpen(oneshot::Sender>, ExecutionError>>), + FetchBalances(oneshot::Sender, ExecutionError>>), + OpenOrders( + ( + Vec>, + oneshot::Sender, ExecutionError>>>, + ), + ), + CancelOrders( + ( + Vec>, + oneshot::Sender, ExecutionError>>>, + ), + ), + CancelOrdersAll(oneshot::Sender>, ExecutionError>>), + MarketTrade((Instrument, PublicTrade)), +} diff --git a/barter-execution/tests/simulated_exchange.rs b/barter-execution/tests/simulated_exchange.rs new file mode 100644 index 00000000..769a26f8 --- /dev/null +++ b/barter-execution/tests/simulated_exchange.rs @@ -0,0 +1,1008 @@ +use crate::util::{ + fees_50_percent, initial_balances, latency_50ms, open_order, order_cancel_request, + order_cancelled, order_request_limit, run_default_exchange, +}; +use barter_data::subscription::trade::PublicTrade; +use barter_execution::{ + error::ExecutionError, + model::{ + balance::{Balance, SymbolBalance}, + order::OrderId, + trade::{SymbolFees, Trade, TradeId}, + AccountEvent, AccountEventKind, ClientOrderId, + }, + simulated::{execution::SimulatedExecution, SimulatedEvent}, + ExecutionClient, +}; +use barter_integration::model::{ + instrument::{kind::InstrumentKind, symbol::Symbol, Instrument}, + Side, +}; +use tokio::sync::mpsc; +use uuid::Uuid; + +mod util; + +#[derive(Clone)] +struct Ids { + cid: ClientOrderId, + id: OrderId, +} + +impl Ids { + fn new>(cid: Uuid, id: Id) -> Self { + Self { + cid: ClientOrderId(cid), + id: id.into(), + } + } +} + +#[tokio::test] +async fn main() { + // Create channels: + // - event_account_tx sends AccountEvents to the Barter Engine + // - event_simulated_tx sends MarketEvents and execution requests to the SimulatedExchange + let (event_account_tx, mut event_account_rx) = mpsc::unbounded_channel(); + let (mut event_simulated_tx, event_simulated_rx) = mpsc::unbounded_channel(); + + // Build SimulatedExchange & run on it's own Tokio task + tokio::spawn(run_default_exchange(event_account_tx, event_simulated_rx)); + + // Initialise SimulatedExecution execution to interact with the exchange via the simulated channel + let client = SimulatedExecution { + request_tx: event_simulated_tx.clone(), + }; + + // 1. Fetch initial OpenOrders when we have no open Orders + test_1_fetch_initial_orders_and_check_empty(&client).await; + + // 2. Fetch initial Balances when there have been no balance changing events + test_2_fetch_balances_and_check_same_as_initial(&client).await; + + // 3. Open LIMIT Buy Order and check AccountEvent Balance is sent for the quote currency (usdt) + let test_3_ids = Ids::new(Uuid::new_v4(), 1); + test_3_open_limit_buy_order(&client, test_3_ids.clone(), &mut event_account_rx).await; + + // 4. Send MarketEvent that does not match any open Order and check no AccountEvents are sent + test_4_send_market_event_that_does_not_match_any_open_order( + &mut event_simulated_tx, + &mut event_account_rx, + ); + + // 5. Cancel the open buy order and check AccountEvents for cancelled order and balance are sent + test_5_cancel_buy_order(&client, test_3_ids, &mut event_account_rx).await; + + // 6. Open 2x LIMIT Buy Orders & assert on received AccountEvents + let test_6_ids_1 = Ids::new(Uuid::new_v4(), 2); + let test_6_ids_2 = Ids::new(Uuid::new_v4(), 3); + test_6_open_2x_limit_buy_orders( + &client, + test_6_ids_1.clone(), + test_6_ids_2, + &mut event_account_rx, + ) + .await; + + // 7. Send MarketEvent that exactly full matches 1x open Order (trade) and check AccountEvents + // for balances and trades + test_7_send_market_event_that_exact_full_matches_order( + &mut event_simulated_tx, + &mut event_account_rx, + ) + .await; + + // 8. Fetch open orders & check only one limit buy order remaining from test_6_order_cid_1 + test_8_fetch_open_orders_and_check_test_6_order_cid_1_only(&client, test_6_ids_1.clone()).await; + + // 9. Open 2x LIMIT Sell Order & assert on received AccountEvents + let test_9_ids_1 = Ids::new(Uuid::new_v4(), 4); + let test_9_ids_2 = Ids::new(Uuid::new_v4(), 5); + test_9_open_2x_limit_sell_orders( + &client, + test_9_ids_1, + test_9_ids_2.clone(), + &mut event_account_rx, + ) + .await; + + // 10. Send MarketEvent that fully matches 1x sell Order (trade), and partially matches the other + // sell Order (trade). Check AccountEvents for balances and trades of both matches are sent. + test_10_send_market_event_that_full_and_partial_matches_orders( + &mut event_simulated_tx, + &mut event_account_rx, + ) + .await; + + // 11. Cancel all open orders. Includes a partially filled sell order, and non-filled buy order. + // Check AccountEvents for orders cancelled and balances are sent. + test_11_cancel_all_orders(&client, test_6_ids_1, test_9_ids_2, &mut event_account_rx).await; + + // 12. Fetch open orders (now that we've called cancel_all) and check it is empty + test_12_fetch_open_orders_and_check_empty(&client).await; + + // 13. Fail to open limit buy order with insufficient funds + let test_13_ids_1 = Ids::new(Uuid::new_v4(), 6); + let test_13_ids_2 = Ids::new(Uuid::new_v4(), 6); // 6 because first should fail + test_13_fail_to_open_one_of_two_limits_with_insufficient_funds( + &client, + test_13_ids_1, + test_13_ids_2, + &mut event_account_rx, + ) + .await; + + // 14. Fail to cancel limit order with OrderNotFound using incorrect OrderId + test_14_fail_to_cancel_limit_with_order_not_found(&client).await; +} + +// 1. Fetch initial OpenOrders when we have no open Orders. +async fn test_1_fetch_initial_orders_and_check_empty(client: &SimulatedExecution) { + let initial_orders = client.fetch_orders_open().await.unwrap(); + assert!(initial_orders.is_empty()); +} + +// 2. Fetch initial Balances when there have been no balance changing events. +async fn test_2_fetch_balances_and_check_same_as_initial(client: &SimulatedExecution) { + let actual_balances = client.fetch_balances().await.unwrap(); + let initial_balances = initial_balances(); + + assert_eq!(actual_balances.len(), initial_balances.len()); + + for actual in actual_balances { + let expected = initial_balances.get(&actual.symbol).unwrap(); + assert_eq!(actual.balance, *expected); + } +} + +// 3. Open LIMIT Buy Order and check AccountEvent Balance is sent for the quote currency (usdt). +async fn test_3_open_limit_buy_order( + client: &SimulatedExecution, + test_3_ids: Ids, + event_account_rx: &mut mpsc::UnboundedReceiver, +) { + let new_orders = client + .open_orders(vec![order_request_limit( + Instrument::from(("btc", "usdt", InstrumentKind::Perpetual)), + test_3_ids.cid, + Side::Buy, + 100.0, + 1.0, + )]) + .await; + + let expected_new_order = open_order( + Instrument::from(("btc", "usdt", InstrumentKind::Perpetual)), + test_3_ids.cid, + test_3_ids.id, + Side::Buy, + 100.0, + 1.0, + 0.0, + ); + + assert_eq!(new_orders.len(), 1); + assert_eq!(new_orders[0].clone().unwrap(), expected_new_order); + + // Check AccountEvent Balance for quote currency (usdt) has available balance decrease + match event_account_rx.try_recv() { + Ok(AccountEvent { + kind: AccountEventKind::Balance(usdt_balance), + .. + }) => { + // Expected usdt Balance.available = 10_000 - (100.0 * 1.0) + let expected = SymbolBalance::new("usdt", Balance::new(10_000.0, 9_900.0)); + assert_eq!(usdt_balance, expected); + } + other => { + panic!("try_recv() consumed unexpected: {:?}", other); + } + } + + // Check AccountEvent OrderNew generated + match event_account_rx.try_recv() { + Ok(AccountEvent { + kind: AccountEventKind::OrdersNew(new_orders), + .. + }) => { + assert_eq!(new_orders.len(), 1); + assert_eq!(new_orders[0].clone(), expected_new_order); + } + other => { + panic!("try_recv() consumed unexpected: {:?}", other); + } + } + + // Check no more AccountEvents generated + match event_account_rx.try_recv() { + Err(mpsc::error::TryRecvError::Empty) => {} + other => { + panic!("try_recv() consumed unexpected: {:?}", other); + } + } +} + +// 4. Send MarketEvent that does not match any open Order and check no AccountEvents are sent. +fn test_4_send_market_event_that_does_not_match_any_open_order( + event_simulated_tx: &mut mpsc::UnboundedSender, + event_account_rx: &mut mpsc::UnboundedReceiver, +) { + event_simulated_tx + .send(SimulatedEvent::MarketTrade(( + Instrument::from(("btc", "usdt", InstrumentKind::Perpetual)), + PublicTrade { + id: "test_4".to_string(), + side: Side::Sell, + price: 1000.0, + amount: 1.0, + }, + ))) + .unwrap(); + + // Check no more AccountEvents generated + match event_account_rx.try_recv() { + Err(mpsc::error::TryRecvError::Empty) => {} + other => { + panic!("try_recv() consumed unexpected: {:?}", other); + } + } +} + +// 5. Cancel the open buy order and check AccountEvents for cancelled order and balance are sent. +async fn test_5_cancel_buy_order( + client: &SimulatedExecution, + test_3_ids: Ids, + event_account_rx: &mut mpsc::UnboundedReceiver, +) { + let cancelled = client + .cancel_orders(vec![order_cancel_request( + Instrument::from(("btc", "usdt", InstrumentKind::Perpetual)), + test_3_ids.cid, + Side::Buy, + test_3_ids.id.clone(), + )]) + .await; + + let expected_cancelled = order_cancelled( + Instrument::from(("btc", "usdt", InstrumentKind::Perpetual)), + test_3_ids.cid, + Side::Buy, + test_3_ids.id, + ); + + assert_eq!(cancelled.len(), 1); + assert_eq!(cancelled[0].clone().unwrap(), expected_cancelled); + + // Check AccountEvent Order cancelled + match event_account_rx.try_recv() { + Ok(AccountEvent { + kind: AccountEventKind::OrdersCancelled(cancelled), + .. + }) => { + assert_eq!(cancelled.len(), 1); + assert_eq!(cancelled[0].clone(), expected_cancelled); + } + other => { + panic!("try_recv() consumed unexpected: {:?}", other); + } + } + + // Check AccountEvent Balance for quote currency (usdt) has available balance increase + match event_account_rx.try_recv() { + Ok(AccountEvent { + kind: AccountEventKind::Balance(usdt_balance), + .. + }) => { + // Expected usdt Balance.available = 9_900 + (100.0 * 1.0) + let expected = SymbolBalance::new("usdt", Balance::new(10_000.0, 10_000.0)); + assert_eq!(usdt_balance, expected); + } + other => { + panic!("try_recv() consumed unexpected: {:?}", other); + } + } + + // Check no more AccountEvents generated + match event_account_rx.try_recv() { + Err(mpsc::error::TryRecvError::Empty) => {} + other => { + panic!("try_recv() consumed unexpected: {:?}", other); + } + } +} + +// 6. Open 2x limit buy orders and check AccountEvents for balance & order new are sent +async fn test_6_open_2x_limit_buy_orders( + client: &SimulatedExecution, + test_6_ids_1: Ids, + test_6_ids_2: Ids, + event_account_rx: &mut mpsc::UnboundedReceiver, +) { + let opened_orders = client + .open_orders(vec![ + order_request_limit( + Instrument::from(("btc", "usdt", InstrumentKind::Perpetual)), + test_6_ids_1.cid, + Side::Buy, + 100.0, + 1.0, + ), + order_request_limit( + Instrument::from(("btc", "usdt", InstrumentKind::Perpetual)), + test_6_ids_2.cid, + Side::Buy, + 200.0, + 1.0, + ), + ]) + .await; + + let expected_order_new_1 = open_order( + Instrument::from(("btc", "usdt", InstrumentKind::Perpetual)), + test_6_ids_1.cid, + test_6_ids_1.id.clone(), + Side::Buy, + 100.0, + 1.0, + 0.0, + ); + + let expected_order_new_2 = open_order( + Instrument::from(("btc", "usdt", InstrumentKind::Perpetual)), + test_6_ids_2.cid, + test_6_ids_2.id, + Side::Buy, + 200.0, + 1.0, + 0.0, + ); + + assert_eq!(opened_orders.len(), 2); + assert_eq!(opened_orders[0].clone().unwrap(), expected_order_new_1); + assert_eq!(opened_orders[1].clone().unwrap(), expected_order_new_2); + + // Check AccountEvent Balance for first order - quote currency has available balance decrease + match event_account_rx.try_recv() { + Ok(AccountEvent { + kind: AccountEventKind::Balance(usdt_balance), + .. + }) => { + // Expected usdt Balance.available = 10_000 - (100.0 * 1.0) + let expected = SymbolBalance::new("usdt", Balance::new(10_000.0, 9_900.0)); + assert_eq!(usdt_balance, expected); + } + other => { + panic!("try_recv() consumed unexpected: {:?}", other); + } + } + + // Check AccountEvent OrdersNew for first order + match event_account_rx.try_recv() { + Ok(AccountEvent { + kind: AccountEventKind::OrdersNew(new_orders), + .. + }) => { + assert_eq!(new_orders.len(), 1); + assert_eq!(new_orders[0].clone(), expected_order_new_1); + } + other => { + panic!("try_recv() consumed unexpected: {:?}", other); + } + } + + // Check AccountEvent Balance for second order - quote currency has available balance decrease + match event_account_rx.try_recv() { + Ok(AccountEvent { + kind: AccountEventKind::Balance(usdt_balance), + .. + }) => { + // Expected usdt Balance.available = 9_900 - (200.0 * 1.0) + let expected = SymbolBalance::new("usdt", Balance::new(10_000.0, 9_700.0)); + assert_eq!(usdt_balance, expected); + } + other => { + panic!("try_recv() consumed unexpected: {:?}", other); + } + } + + // Check AccountEvent OrdersNew for second order + match event_account_rx.try_recv() { + Ok(AccountEvent { + kind: AccountEventKind::OrdersNew(new_orders), + .. + }) => { + assert_eq!(new_orders.len(), 1); + assert_eq!(new_orders[0].clone(), expected_order_new_2); + } + other => { + panic!("try_recv() consumed unexpected: {:?}", other); + } + } + + // Check no more AccountEvents generated + match event_account_rx.try_recv() { + Err(mpsc::error::TryRecvError::Empty) => {} + other => { + panic!("try_recv() consumed unexpected: {:?}", other); + } + } +} + +// 7. Send MarketEvent that exactly full matches 1x open Order (trade) and check AccountEvents for +// balances and trades are sent. +async fn test_7_send_market_event_that_exact_full_matches_order( + event_simulated_tx: &mut mpsc::UnboundedSender, + event_account_rx: &mut mpsc::UnboundedReceiver, +) { + // Send matching MarketEvent + event_simulated_tx + .send(SimulatedEvent::MarketTrade(( + Instrument::from(("btc", "usdt", InstrumentKind::Perpetual)), + PublicTrade { + id: "test_7".to_string(), + side: Side::Sell, + price: 200.0, + amount: 1.0, + }, + ))) + .unwrap(); + + tokio::time::sleep(latency_50ms()).await; + + // Check AccountEvent Balances for base & quote currencies related to the trade + match event_account_rx.try_recv() { + Ok(AccountEvent { + kind: AccountEventKind::Balances(balances), + .. + }) => { + // Base & Quote SymbolBalances should be updated + assert_eq!(balances.len(), 2); + + // Base Balance first: expected btc { total: 10.0 + 1.0 - fees, available: 10.0 + 1.0 - fees } + let btc_fees = 1.0 * fees_50_percent(); + let expected_btc = SymbolBalance::new( + "btc", + Balance::new(10.0 + 1.0 - btc_fees, 10.0 + 1.0 - btc_fees), + ); + assert_eq!(balances[0], expected_btc); + + // Quote Balance second: expected usdt Balance { total: 10_000 - 200, available: 9_700 } + let expected_usdt = SymbolBalance::new("usdt", Balance::new(9_800.0, 9_700.0)); + assert_eq!(balances[1], expected_usdt); + } + other => { + panic!("try_recv() consumed unexpected: {:?}", other); + } + } + + // Check AccountEvent Trade for order matching MarketEvent + match event_account_rx.try_recv() { + Ok(AccountEvent { + kind: AccountEventKind::Trade(trade), + .. + }) => { + let expected = Trade { + id: TradeId(1.to_string()), + order_id: OrderId(3.to_string()), + instrument: Instrument::from(("btc", "usdt", InstrumentKind::Perpetual)), + side: Side::Buy, + price: 200.0, + quantity: 1.0, + fees: SymbolFees::new("btc", 1.0 * fees_50_percent()), + }; + assert_eq!(trade, expected); + } + other => { + panic!("try_recv() consumed unexpected: {:?}", other); + } + } + + // Check no more AccountEvents generated + match event_account_rx.try_recv() { + Err(mpsc::error::TryRecvError::Empty) => {} + other => { + panic!("try_recv() consumed unexpected: {:?}", other); + } + } +} + +// 8. Fetch open orders & check there is only one limit buy order remaining from test_6_order_cid_1. +async fn test_8_fetch_open_orders_and_check_test_6_order_cid_1_only( + client: &SimulatedExecution, + test_6_ids_1: Ids, +) { + let open_orders = client.fetch_orders_open().await.unwrap(); + assert_eq!(open_orders.len(), 1); + assert_eq!( + open_orders[0].clone(), + open_order( + Instrument::from(("btc", "usdt", InstrumentKind::Perpetual)), + test_6_ids_1.cid, + test_6_ids_1.id, + Side::Buy, + 100.0, + 1.0, + 0.0 + ) + ); +} + +// 9. Open 2x LIMIT Sell Order & check AccountEvents for balances and order news are sent. +async fn test_9_open_2x_limit_sell_orders( + client: &SimulatedExecution, + test_9_ids_1: Ids, + test_9_ids_2: Ids, + event_account_rx: &mut mpsc::UnboundedReceiver, +) { + let opened_orders = client + .open_orders(vec![ + order_request_limit( + Instrument::from(("btc", "usdt", InstrumentKind::Perpetual)), + test_9_ids_1.cid, + Side::Sell, + 500.0, + 1.0, + ), + order_request_limit( + Instrument::from(("btc", "usdt", InstrumentKind::Perpetual)), + test_9_ids_2.cid, + Side::Sell, + 1000.0, + 1.0, + ), + ]) + .await; + + let expected_order_new_1 = open_order( + Instrument::from(("btc", "usdt", InstrumentKind::Perpetual)), + test_9_ids_1.cid, + test_9_ids_1.id, + Side::Sell, + 500.0, + 1.0, + 0.0, + ); + + let expected_order_new_2 = open_order( + Instrument::from(("btc", "usdt", InstrumentKind::Perpetual)), + test_9_ids_2.cid, + test_9_ids_2.id, + Side::Sell, + 1000.0, + 1.0, + 0.0, + ); + + assert_eq!(opened_orders.len(), 2); + assert_eq!(opened_orders[0].clone().unwrap(), expected_order_new_1); + assert_eq!(opened_orders[1].clone().unwrap(), expected_order_new_2); + + // Check AccountEvent Balance for first order - quote currency has available balance decrease + match event_account_rx.try_recv() { + Ok(AccountEvent { + kind: AccountEventKind::Balance(btc_balance), + .. + }) => { + // Expected btc Balance.available = 10.5 - 1.0 + let expected = SymbolBalance::new("btc", Balance::new(10.5, 10.5 - 1.0)); + assert_eq!(btc_balance, expected); + } + other => { + panic!( + "try_recv() consumed unexpected Result: {:?}", + other + ); + } + } + + // Check AccountEvent OrdersNew for first order + match event_account_rx.try_recv() { + Ok(AccountEvent { + kind: AccountEventKind::OrdersNew(new_orders), + .. + }) => { + assert_eq!(new_orders.len(), 1); + assert_eq!(new_orders[0].clone(), expected_order_new_1); + } + other => { + panic!( + "try_recv() consumed unexpected Result: {:?}", + other + ); + } + } + + // Check AccountEvent Balance for second order - quote currency has available balance decrease + match event_account_rx.try_recv() { + Ok(AccountEvent { + kind: AccountEventKind::Balance(btc_balance), + .. + }) => { + // Expected btc Balance.available = 9.5 - 1.0 + let expected = SymbolBalance::new("btc", Balance::new(10.5, 9.5 - 1.0)); + assert_eq!(btc_balance, expected); + } + other => { + panic!( + "try_recv() consumed unexpected Result: {:?}", + other + ); + } + } + + // Check AccountEvent OrdersNew for second order + match event_account_rx.try_recv() { + Ok(AccountEvent { + kind: AccountEventKind::OrdersNew(new_orders), + .. + }) => { + assert_eq!(new_orders.len(), 1); + assert_eq!(new_orders[0].clone(), expected_order_new_2); + } + other => { + panic!( + "try_recv() consumed unexpected Result: {:?}", + other + ); + } + } + + // Check no more AccountEvents generated + match event_account_rx.try_recv() { + Err(mpsc::error::TryRecvError::Empty) => {} + other => { + panic!( + "try_recv() consumed unexpected Result: {:?}", + other + ); + } + } +} + +// 10. Send MarketEvent that fully matches 1x sell Order (trade), and partially matches the another +// (trade). Check AccountEvents for balances and trades of both matches are sent. +async fn test_10_send_market_event_that_full_and_partial_matches_orders( + event_simulated_tx: &mut mpsc::UnboundedSender, + event_account_rx: &mut mpsc::UnboundedReceiver, +) { + // Send MarketEvent that fully matches one order and partially matches another + event_simulated_tx + .send(SimulatedEvent::MarketTrade(( + Instrument::from(("btc", "usdt", InstrumentKind::Perpetual)), + PublicTrade { + id: "test_10".to_string(), + side: Side::Buy, + price: 1000.0, + amount: 1.5, + }, + ))) + .unwrap(); + + tokio::time::sleep(latency_50ms()).await; + + // a) First full match fill: + let first_full_fill_fees = (500.0 * 1.0) * fees_50_percent(); + // Check AccountEvent Balances for base & quote currencies related to the trade + match event_account_rx.try_recv() { + Ok(AccountEvent { + kind: AccountEventKind::Balances(balances), + .. + }) => { + // Base & Quote SymbolBalances should be updated + assert_eq!(balances.len(), 2); + + // Base Balance first: expected btc Balance { total: 10.5 - 1.0, available: 8.5 } + let expected_btc = SymbolBalance::new("btc", Balance::new(10.5 - 1.0, 8.5)); + assert_eq!(balances[0], expected_btc); + + // Quote Balance second: + // Expected usdt increase = (500 * 1.0) - (500 * 1.0 * 0.5) = 500 - 250 = 250 + // expected usdt Balance { total: 9_800 + 250, available: 9_700 + 250 } + let expected_usdt = SymbolBalance::new("usdt", Balance::new(10_050.0, 9_950.0)); + assert_eq!(balances[1], expected_usdt); + } + other => { + panic!( + "try_recv() consumed unexpected Result: {:?}", + other + ); + } + } + + match event_account_rx.try_recv() { + Ok(AccountEvent { + kind: AccountEventKind::Trade(trade), + .. + }) => { + let expected = Trade { + id: TradeId(2.to_string()), + order_id: OrderId(4.to_string()), + instrument: Instrument::from(("btc", "usdt", InstrumentKind::Perpetual)), + side: Side::Sell, + price: 500.0, + quantity: 1.0, + fees: SymbolFees::new("usdt", first_full_fill_fees), + }; + assert_eq!(trade, expected); + } + other => { + panic!( + "try_recv() consumed unexpected Result: {:?}", + other + ); + } + } + + // b) Second partial match fill + let second_partial_fill_fees = (1000.0 * 0.5) * fees_50_percent(); + + // Check AccountEvent Balances for base & quote currencies related to the trade + match event_account_rx.try_recv() { + Ok(AccountEvent { + kind: AccountEventKind::Balances(balances), + .. + }) => { + // Base & Quote SymbolBalances should be updated + assert_eq!(balances.len(), 2); + + // btc { total: 9.0, available: 8.5 } 0.5 left in partially filled trade + + // Base Balance first: expected btc Balance { total: 9.5 - 0.5, available: 8.5 } + let expected_btc = SymbolBalance::new("btc", Balance::new(9.5 - 0.5, 8.5)); + assert_eq!(balances[0], expected_btc); + + // Quote Balance second: + // Expected usdt increase = (1000 * 0.5) - (1000 * 0.5 * 0.5) = 500 - 250 = 250 + // expected usdt Balance { total: 10_050 + 250, available: 9_950 + 250 } + let expected_usdt = + SymbolBalance::new("usdt", Balance::new(10_050.0 + 250.0, 9_950.0 + 250.0)); + assert_eq!(balances[1], expected_usdt); + } + other => { + panic!( + "try_recv() consumed unexpected Result: {:?}", + other + ); + } + } + + match event_account_rx.try_recv() { + Ok(AccountEvent { + kind: AccountEventKind::Trade(trade), + .. + }) => { + let expected = Trade { + id: TradeId(3.to_string()), + order_id: OrderId(5.to_string()), + instrument: Instrument::from(("btc", "usdt", InstrumentKind::Perpetual)), + side: Side::Sell, + price: 1000.0, + quantity: 0.5, + fees: SymbolFees::new("usdt", second_partial_fill_fees), + }; + assert_eq!(trade, expected); + } + other => { + panic!( + "try_recv() consumed unexpected Result: {:?}", + other + ); + } + } + + // Check no more AccountEvents generated + match event_account_rx.try_recv() { + Err(mpsc::error::TryRecvError::Empty) => {} + other => { + panic!( + "try_recv() consumed unexpected Result: {:?}", + other + ); + } + } +} + +// 11. Cancel all open orders. Includes a partially filled sell order, and non-filled buy order. +// Check AccountEvents for orders cancelled and balances are sent. +async fn test_11_cancel_all_orders( + client: &SimulatedExecution, + test_6_ids_1: Ids, + test_9_ids_2: Ids, + event_account_rx: &mut mpsc::UnboundedReceiver, +) { + let cancelled = client.cancel_orders_all().await.unwrap(); + + let expected_cancelled = vec![ + order_cancelled( + // Bids are cancelled first + Instrument::from(("btc", "usdt", InstrumentKind::Perpetual)), + test_6_ids_1.cid, + Side::Buy, + test_6_ids_1.id, + ), + order_cancelled( + // Asks are cancelled second + Instrument::from(("btc", "usdt", InstrumentKind::Perpetual)), + test_9_ids_2.cid, + Side::Sell, + test_9_ids_2.id, + ), + ]; + + assert_eq!(cancelled.len(), 2); + assert_eq!(cancelled[0].clone(), expected_cancelled[0]); + assert_eq!(cancelled[1].clone(), expected_cancelled[1]); + + // Check AccountEvent Order cancelled for both the bid & ask + match event_account_rx.try_recv() { + Ok(AccountEvent { + kind: AccountEventKind::OrdersCancelled(cancelled), + .. + }) => { + assert_eq!(cancelled.len(), 2); + assert_eq!(cancelled[0].clone(), expected_cancelled[0]); + assert_eq!(cancelled[1].clone(), expected_cancelled[1]); + } + other => { + panic!( + "try_recv() consumed unexpected Result: {:?}", + other + ); + } + } + + // Check AccountEvent Balances for cancelled bid and ask orders + match event_account_rx.try_recv() { + Ok(AccountEvent { + kind: AccountEventKind::Balances(balances), + .. + }) => { + // SymbolBalances for Bid order quote, & ask order base should be updated + assert_eq!(balances.len(), 2); + + // Bids are cancelled first, so balance is updated first + // test_6_order_cid_1, Side::Buy, price=100.0, quantity=1.0 + // Therefore, usdt Balance { total: 10_300, available: 10_200 + (100 * 1) + let expected_usdt = + SymbolBalance::new("usdt", Balance::new(10_300.0, 10_200.0 + 100.0)); + assert_eq!(balances[0], expected_usdt); + + // Asks are cancelled second, so balance is updated first + // test_9_order_cid_2, Side::Sell, price=1000.0, quantity=1.0, filled=0.5 + // Therefore, btc Balance { total: 9.0, available: 8.5 + 0.5 } + let expected_btc = SymbolBalance::new("btc", Balance::new(9.0, 8.5 + 0.5)); + assert_eq!(balances[1], expected_btc); + } + other => { + panic!( + "try_recv() consumed unexpected Result: {:?}", + other + ); + } + } + + // Check no more AccountEvents generated + match event_account_rx.try_recv() { + Err(mpsc::error::TryRecvError::Empty) => {} + other => { + panic!( + "try_recv() consumed unexpected Result: {:?}", + other + ); + } + } +} + +// 12. Fetch open orders (now that we've called cancel_all) and check it is empty +async fn test_12_fetch_open_orders_and_check_empty(client: &SimulatedExecution) { + let open_orders = client.fetch_orders_open().await.unwrap(); + assert!(open_orders.is_empty()); +} + +// 13. Fail to open limit buy order with insufficient funds +async fn test_13_fail_to_open_one_of_two_limits_with_insufficient_funds( + client: &SimulatedExecution, + test_13_ids_1: Ids, + test_13_ids_2: Ids, + event_account_rx: &mut mpsc::UnboundedReceiver, +) { + let opened_orders = client + .open_orders(vec![ + order_request_limit( + Instrument::from(("btc", "usdt", InstrumentKind::Perpetual)), + test_13_ids_1.cid, + Side::Buy, + 1_000_000_000.0, + 1.0, + ), + order_request_limit( + Instrument::from(("btc", "usdt", InstrumentKind::Perpetual)), + test_13_ids_2.cid, + Side::Sell, + 1000.0, + 1.0, + ), + ]) + .await; + + let expected_order_new_1 = Err(ExecutionError::InsufficientBalance(Symbol::from("usdt"))); + let expected_order_new_2 = open_order( + Instrument::from(("btc", "usdt", InstrumentKind::Perpetual)), + test_13_ids_2.cid, + test_13_ids_2.id, + Side::Sell, + 1000.0, + 1.0, + 0.0, + ); + + assert_eq!(opened_orders.len(), 2); + assert_eq!(opened_orders[0].clone(), expected_order_new_1); + assert_eq!(opened_orders[1].clone().unwrap(), expected_order_new_2); + + // Note: First order failed to due usdt InsufficientBalance, so don't expect any AccountEvents + + // Check AccountEvent Balance for second order - quote currency has available balance decrease + match event_account_rx.try_recv() { + Ok(AccountEvent { + kind: AccountEventKind::Balance(btc_balance), + .. + }) => { + // Expected btc Balance.available = 9.0 - 1.0 + let expected = SymbolBalance::new("btc", Balance::new(9.0, 9.0 - 1.0)); + assert_eq!(btc_balance, expected); + } + other => { + panic!( + "try_recv() consumed unexpected Result: {:?}", + other + ); + } + } + + // Check AccountEvent OrdersNew for first order + match event_account_rx.try_recv() { + Ok(AccountEvent { + kind: AccountEventKind::OrdersNew(new_orders), + .. + }) => { + assert_eq!(new_orders.len(), 1); + assert_eq!(new_orders[0].clone(), expected_order_new_2); + } + other => { + panic!( + "try_recv() consumed unexpected Result: {:?}", + other + ); + } + } + + // Check no more AccountEvents generated + match event_account_rx.try_recv() { + Err(mpsc::error::TryRecvError::Empty) => {} + other => { + panic!( + "try_recv() consumed unexpected Result: {:?}", + other + ); + } + } +} + +// 14. Fail to cancel limit order with OrderNotFound using incorrect OrderId +async fn test_14_fail_to_cancel_limit_with_order_not_found(client: &SimulatedExecution) { + let cid = ClientOrderId(Uuid::new_v4()); + let cancelled = client + .cancel_orders(vec![order_cancel_request( + Instrument::from(("btc", "usdt", InstrumentKind::Perpetual)), + cid, + Side::Buy, + OrderId::from("order will not be found"), + )]) + .await; + + let expected = Err(ExecutionError::OrderNotFound(cid)); + + assert_eq!(cancelled.len(), 1); + assert_eq!(cancelled[0], expected); +} diff --git a/barter-execution/tests/util/mod.rs b/barter-execution/tests/util/mod.rs new file mode 100644 index 00000000..adaac218 --- /dev/null +++ b/barter-execution/tests/util/mod.rs @@ -0,0 +1,162 @@ +use barter_execution::{ + model::{ + balance::Balance, + order::{Cancelled, Open, Order, OrderId, OrderKind, RequestCancel, RequestOpen}, + AccountEvent, ClientOrderId, + }, + simulated::{ + exchange::{ + account::{balance::ClientBalances, ClientAccount}, + SimulatedExchange, + }, + SimulatedEvent, + }, + ExecutionId, +}; +use barter_integration::model::{ + instrument::{kind::InstrumentKind, symbol::Symbol, Instrument}, + Exchange, Side, +}; +use std::{collections::HashMap, time::Duration}; +use tokio::sync::mpsc; + +pub(super) async fn run_default_exchange( + event_account_tx: mpsc::UnboundedSender, + event_simulated_rx: mpsc::UnboundedReceiver, +) { + // Define SimulatedExchange available Instruments + let instruments = instruments(); + + // Create initial ClientAccount balances (Symbols must all be included in the Instruments) + let balances = initial_balances(); + + // Build SimulatedExchange & run on it's own Tokio task + SimulatedExchange::builder() + .event_simulated_rx(event_simulated_rx) + .account( + ClientAccount::builder() + .latency(latency_50ms()) + .fees_percent(fees_50_percent()) + .event_account_tx(event_account_tx) + .instruments(instruments) + .balances(balances) + .build() + .expect("failed to build ClientAccount"), + ) + .build() + .expect("failed to build SimulatedExchange") + .run() + .await +} + +pub(super) fn latency_50ms() -> Duration { + Duration::from_millis(50) +} + +pub(super) fn fees_50_percent() -> f64 { + 0.5 +} + +// Instruments that the SimulatedExchange supports +pub(super) fn instruments() -> Vec { + vec![Instrument::from(("btc", "usdt", InstrumentKind::Perpetual))] +} + +// Initial SimulatedExchange ClientAccount balances for each Symbol +pub(super) fn initial_balances() -> ClientBalances { + ClientBalances(HashMap::from([ + (Symbol::from("btc"), Balance::new(10.0, 10.0)), + (Symbol::from("usdt"), Balance::new(10_000.0, 10_000.0)), + ])) +} + +// Utility for creating an Open Order request +pub(super) fn order_request_limit( + instrument: I, + cid: ClientOrderId, + side: Side, + price: f64, + quantity: f64, +) -> Order +where + I: Into, +{ + Order { + exchange: Exchange::from(ExecutionId::Simulated), + instrument: instrument.into(), + cid, + side, + state: RequestOpen { + kind: OrderKind::Limit, + price, + quantity, + }, + } +} + +// Utility for creating an Open Order +pub(super) fn open_order( + instrument: I, + cid: ClientOrderId, + id: OrderId, + side: Side, + price: f64, + quantity: f64, + filled: f64, +) -> Order +where + I: Into, +{ + Order { + exchange: Exchange::from(ExecutionId::Simulated), + instrument: instrument.into(), + cid, + side, + state: Open { + id, + price, + quantity, + filled_quantity: filled, + }, + } +} + +// Utility for creating an Order RequestCancel +pub(super) fn order_cancel_request( + instrument: I, + cid: ClientOrderId, + side: Side, + id: Id, +) -> Order +where + I: Into, + Id: Into, +{ + Order { + exchange: Exchange::from(ExecutionId::Simulated), + instrument: instrument.into(), + cid, + side, + state: RequestCancel::from(id), + } +} + +// Utility for creating an Order +pub(super) fn order_cancelled( + instrument: I, + cid: ClientOrderId, + side: Side, + id: Id, +) -> Order +where + I: Into, + Id: Into, +{ + Order { + exchange: Exchange::from(ExecutionId::Simulated), + instrument: instrument.into(), + cid, + side, + state: Cancelled::from(id), + } +} diff --git a/barter-integration/Cargo.toml b/barter-integration/Cargo.toml new file mode 100644 index 00000000..435344ed --- /dev/null +++ b/barter-integration/Cargo.toml @@ -0,0 +1,50 @@ +[package] +name = "barter-integration" +version = "0.7.3" +authors = ["JustAStream"] +edition = "2021" +license = "MIT" +documentation = "https://docs.rs/barter-integration/" +repository = "https://github.com/barter-rs/barter-rs" +readme = "README.md" +description = "Low-level framework for composing flexible web integrations, especially with financial exchanges" +keywords = ["trading", "backtesting", "crypto", "stocks", "investment"] +categories = ["accessibility", "simulation"] + +[dev-dependencies] +rust_decimal_macros = { workspace = true } + +[dependencies] +# Logging +tracing = { workspace = true } + +# SerDe +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +serde_qs = { workspace = true } +serde_urlencoded = { workspace = true } + +# Error +thiserror = { workspace = true } + +# Async +tokio = { workspace = true, features = ["net", "sync", "macros", "rt-multi-thread"] } +futures = { workspace = true } +async-trait = { workspace = true } +pin-project = { workspace = true } + +# Protocol +tokio-tungstenite = { workspace = true, features = ["rustls-tls-webpki-roots"] } +reqwest = { workspace = true, features = ["json"] } +url = { workspace = true } + +# Cryptographic Signatures +hmac = { workspace = true } +sha2 = { workspace = true } +hex = { workspace = true } +base64 = { workspace = true } + +# Misc +chrono = { workspace = true, features = ["serde"] } +bytes = { workspace = true } +rust_decimal = { workspace = true } \ No newline at end of file diff --git a/barter-integration/LICENSE b/barter-integration/LICENSE new file mode 100644 index 00000000..71caa787 --- /dev/null +++ b/barter-integration/LICENSE @@ -0,0 +1,25 @@ +Copyright (c) 2022 Barter-Integration Contributors + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/barter-integration/README.md b/barter-integration/README.md new file mode 100644 index 00000000..f06c3e41 --- /dev/null +++ b/barter-integration/README.md @@ -0,0 +1,367 @@ +# Barter-Integration + +High-performance, low-level framework for composing flexible web integrations. + +Utilised by other [`Barter`] trading ecosystem crates to build robust financial exchange integrations, +primarily for public data collection & trade execution. It is: +* **Low-Level**: Translates raw data streams communicated over the web into any desired data model using arbitrary data transformations. +* **Flexible**: Compatible with any protocol (WebSocket, FIX, Http, etc.), any input/output model, and any user defined transformations. + +Core abstractions include: +- **RestClient** providing configurable signed Http communication between client & server. +- **ExchangeStream** providing configurable communication over any asynchronous stream protocols (WebSocket, FIX, etc.). + +Both core abstractions provide the robust glue you need to conveniently translate between server & client data models. + + +**See: [`Barter`], [`Barter-Data`] & [`Barter-Execution`]** + +[![Crates.io][crates-badge]][crates-url] +[![MIT licensed][mit-badge]][mit-url] +[![Build Status][actions-badge]][actions-url] +[![Discord chat][discord-badge]][discord-url] + +[crates-badge]: https://img.shields.io/crates/v/barter-integration.svg +[crates-url]: https://crates.io/crates/barter-integration + +[mit-badge]: https://img.shields.io/badge/license-MIT-blue.svg +[mit-url]: https://gitlab.com/open-source-keir/financial-modelling/trading/barter-integration-rs/-/blob/main/LICENCE + +[actions-badge]: https://gitlab.com/open-source-keir/financial-modelling/trading/barter-integration-rs/badges/-/blob/main/pipeline.svg +[actions-url]: https://gitlab.com/open-source-keir/financial-modelling/trading/barter-integration-rs/-/commits/main + +[discord-badge]: https://img.shields.io/discord/910237311332151317.svg?logo=discord&style=flat-square +[discord-url]: https://discord.gg/wE7RqhnQMV + +[API Documentation] | [Chat] + +[`Barter`]: https://crates.io/crates/barter +[`Barter-Data`]: https://crates.io/crates/barter-data +[`Barter-Execution`]: https://crates.io/crates/barter-execution +[API Documentation]: https://docs.rs/barter-data/latest/barter_integration +[Chat]: https://discord.gg/wE7RqhnQMV + +## Overview + +Barter-Integration is a high-performance, low-level, configurable framework for composing flexible web +integrations. + +### RestClient +**(sync private & public Http communication)** + +At a high level, a `RestClient` is has a few major components that allow it to execute `RestRequests`: +* `RequestSigner` with configurable signing logic on the target API. +* `HttpParser` that translates API specific responses into the desired output types. + +### ExchangeStream +**(async communication using streaming protocols such as WebSocket and FIX)** + +At a high level, an `ExchangeStream` is made up of a few major components: +* Inner Stream/Sink socket (eg/ WebSocket, FIX, etc). +* StreamParser that is capable of parsing input protocol messages (eg/ WebSocket, FIX, etc.) as exchange + specific messages. +* Transformer that transforms from exchange specific message into an iterator of the desired outputs type. + +## Examples + +#### Fetch Ftx Account Balances Using Signed GET request: +```rust,no_run +use std::borrow::Cow; + +use barter_integration::{ + error::SocketError, + metric::Tag, + model::Symbol, + protocol::http::{ + private::{encoder::HexEncoder, RequestSigner, Signer}, + rest::{client::RestClient, RestRequest}, + HttpParser, + }, +}; +use bytes::Bytes; +use chrono::{DateTime, Utc}; +use hmac::{Hmac, Mac}; +use reqwest::{RequestBuilder, StatusCode}; +use serde::Deserialize; +use thiserror::Error; +use tokio::sync::mpsc; + +struct FtxSigner { + api_key: String, +} + +// Configuration required to sign every Ftx `RestRequest` +struct FtxSignConfig<'a> { + api_key: &'a str, + time: DateTime, + method: reqwest::Method, + path: Cow<'static, str>, +} + +impl Signer for FtxSigner { + type Config<'a> = FtxSignConfig<'a> where Self: 'a; + + fn config<'a, Request>( + &'a self, + request: Request, + _: &RequestBuilder, + ) -> Result, SocketError> + where + Request: RestRequest, + { + Ok(FtxSignConfig { + api_key: self.api_key.as_str(), + time: Utc::now(), + method: Request::method(), + path: request.path(), + }) + } + + fn add_bytes_to_sign(mac: &mut M, config: &Self::Config<'a>) -> Bytes + where + M: Mac + { + mac.update(config.time.to_string().as_bytes()); + mac.update(config.method.as_str().as_bytes()); + mac.update(config.path.as_bytes()); + } + + fn build_signed_request<'a>( + config: Self::Config<'a>, + builder: RequestBuilder, + signature: String, + ) -> Result { + // Add Ftx required Headers & build reqwest::Request + builder + .header("FTX-KEY", config.api_key) + .header("FTX-TS", &config.time.timestamp_millis().to_string()) + .header("FTX-SIGN", &signature) + .build() + .map_err(SocketError::from) + } +} + +struct FtxParser; + +impl HttpParser for FtxParser { + type ApiError = serde_json::Value; + type OutputError = ExecutionError; + + fn parse_api_error(&self, status: StatusCode, api_error: Self::ApiError) -> Self::OutputError { + // For simplicity, use serde_json::Value as Error and extract raw String for parsing + let error = api_error.to_string(); + + // Parse Ftx error message to determine custom ExecutionError variant + match error.as_str() { + message if message.contains("Invalid login credentials") => { + ExecutionError::Unauthorised(error) + } + _ => ExecutionError::Socket(SocketError::HttpResponse(status, error)), + } + } +} + +#[derive(Debug, Error)] +enum ExecutionError { + #[error("request authorisation invalid: {0}")] + Unauthorised(String), + + #[error("SocketError: {0}")] + Socket(#[from] SocketError), +} + +struct FetchBalancesRequest; + +impl RestRequest for FetchBalancesRequest { + type Response = FetchBalancesResponse; // Define Response type + type QueryParams = (); // FetchBalances does not require any QueryParams + type Body = (); // FetchBalances does not require any Body + + fn path(&self) -> Cow<'static, str> { + Cow::Borrowed("/api/wallet/balances") + } + + fn method() -> reqwest::Method { + reqwest::Method::GET + } +} + +#[derive(Deserialize)] +#[allow(dead_code)] +struct FetchBalancesResponse { + success: bool, + result: Vec, +} + +#[derive(Deserialize)] +#[allow(dead_code)] +struct FtxBalance { + #[serde(rename = "coin")] + symbol: Symbol, + total: f64, +} + +/// See Barter-Execution for a comprehensive real-life example, as well as code you can use out of the +/// box to execute trades on many exchanges. +#[tokio::main] +async fn main() { + // HMAC-SHA256 encoded account API secret used for signing private http requests + let mac: Hmac = Hmac::new_from_slice("api_secret".as_bytes()).unwrap(); + + // Build Ftx configured RequestSigner for signing http requests with hex encoding + let request_signer = RequestSigner::new( + FtxSigner { + api_key: "api_key".to_string(), + }, + mac, + HexEncoder, + ); + + // Build RestClient with Ftx configuration + let rest_client = RestClient::new("https://ftx.com", request_signer, FtxParser); + + // Fetch Result + let _response = rest_client.execute(FetchBalancesRequest).await; +} +``` + +#### Consume Binance Futures tick-by-tick Trades and calculate a rolling sum of volume: + +```rust,no_run +use barter_integration::{ + error::SocketError, + protocol::websocket::{WebSocket, WebSocketParser, WsMessage}, + ExchangeStream, Transformer, +}; +use futures::{SinkExt, StreamExt}; +use serde::{de, Deserialize}; +use serde_json::json; +use std::str::FromStr; +use tokio_tungstenite::connect_async; +use tracing::debug; + +// Convenient type alias for an `ExchangeStream` utilising a tungstenite `WebSocket` +type ExchangeWsStream = ExchangeStream; + +// Communicative type alias for what the VolumeSum the Transformer is generating +type VolumeSum = f64; + +#[derive(Deserialize)] +#[serde(untagged, rename_all = "camelCase")] +enum BinanceMessage { + SubResponse { + result: Option>, + id: u32, + }, + Trade { + #[serde(rename = "q", deserialize_with = "de_str")] + quantity: f64, + }, +} + +struct StatefulTransformer { + sum_of_volume: VolumeSum, +} + +impl Transformer for StatefulTransformer { + type Input = BinanceMessage; + type OutputIter = Vec>; + + fn transform(&mut self, input: Self::Input) -> Self::OutputIter { + // Add new input Trade quantity to sum + match input { + BinanceMessage::SubResponse { result, id } => { + debug!("Received SubResponse for {}: {:?}", id, result); + // Don't care about this for the example + } + BinanceMessage::Trade { quantity, .. } => { + // Add new Trade volume to internal state VolumeSum + self.sum_of_volume += quantity; + } + }; + + // Return IntoIterator of length 1 containing the running sum of volume + vec![Ok(self.sum_of_volume)] + } +} + +/// See Barter-Data for a comprehensive real-life example, as well as code you can use out of the +/// box to collect real-time public market data from many exchanges. +#[tokio::main] +async fn main() { + // Establish Sink/Stream communication with desired WebSocket server + let mut binance_conn = connect_async("wss://fstream.binance.com/ws/") + .await + .map(|(ws_conn, _)| ws_conn) + .expect("failed to connect"); + + // Send something over the socket (eg/ Binance trades subscription) + binance_conn + .send(WsMessage::Text( + json!({"method": "SUBSCRIBE","params": ["btcusdt@aggTrade"],"id": 1}).to_string(), + )) + .await + .expect("failed to send WsMessage over socket"); + + // Instantiate some arbitrary Transformer to apply to data parsed from the WebSocket protocol + let transformer = StatefulTransformer { sum_of_volume: 0.0 }; + + // ExchangeWsStream includes pre-defined WebSocket Sink/Stream & WebSocket StreamParser + let mut ws_stream = ExchangeWsStream::new(binance_conn, transformer); + + // Receive a stream of your desired Output data model from the ExchangeStream + while let Some(volume_result) = ws_stream.next().await { + match volume_result { + Ok(cumulative_volume) => { + // Do something with your data + println!("{cumulative_volume:?}"); + } + Err(error) => { + // React to any errors produced by the internal transformation + eprintln!("{error}") + } + } + } +} + +/// Deserialize a `String` as the desired type. +fn de_str<'de, D, T>(deserializer: D) -> Result +where + D: de::Deserializer<'de>, + T: FromStr, + T::Err: std::fmt::Display, +{ + let data: String = Deserialize::deserialize(deserializer)?; + data.parse::().map_err(de::Error::custom) +} +``` +**For a larger, "real world" example, see the [`Barter-Data`] repository.** + +## Getting Help +Firstly, see if the answer to your question can be found in the [API Documentation]. If the answer is not there, I'd be +happy to help to [Chat] and try answer your question via Discord. + +## Contributing +Thanks for your help in improving the Barter ecosystem! Please do get in touch on the discord to discuss +development, new features, and the future roadmap. + +## Related Projects +In addition to the Barter-Integration crate, the Barter project also maintains: +* [`Barter`]: High-performance, extensible & modular trading components with batteries-included. Contains a + pre-built trading Engine that can serve as a live-trading or backtesting system. +* [`Barter-Data`]: A high-performance WebSocket integration library for streaming public data from leading + cryptocurrency exchanges. +* [`Barter-Execution`]: Financial exchange integrations for trade execution - yet to be released! + +## Roadmap +* Add new default StreamParser implementations to enable integration with other popular systems such as Kafka. + +## Licence +This project is licensed under the [MIT license]. + +[MIT license]: https://gitlab.com/open-source-keir/financial-modelling/trading/barter-data-rs/-/blob/main/LICENSE + +### Contribution +Unless you explicitly state otherwise, any contribution intentionally submitted +for inclusion in Barter-Integration by you, shall be licensed as MIT, without any additional +terms or conditions. \ No newline at end of file diff --git a/barter-integration/examples/signed_get_request.rs b/barter-integration/examples/signed_get_request.rs new file mode 100644 index 00000000..e59a68b8 --- /dev/null +++ b/barter-integration/examples/signed_get_request.rs @@ -0,0 +1,154 @@ +use std::borrow::Cow; + +use barter_integration::{ + error::SocketError, + model::instrument::symbol::Symbol, + protocol::http::{ + private::{encoder::HexEncoder, RequestSigner, Signer}, + rest::{client::RestClient, RestRequest}, + HttpParser, + }, +}; +use chrono::{DateTime, Utc}; +use hmac::{Hmac, Mac}; +use reqwest::{RequestBuilder, StatusCode}; +use serde::Deserialize; +use thiserror::Error; + +struct FtxSigner { + api_key: String, +} + +// Configuration required to sign every Ftx `RestRequest` +struct FtxSignConfig<'a> { + api_key: &'a str, + time: DateTime, + method: reqwest::Method, + path: Cow<'static, str>, +} + +impl Signer for FtxSigner { + type Config<'a> = FtxSignConfig<'a> where Self: 'a; + + fn config<'a, Request>( + &'a self, + request: Request, + _: &RequestBuilder, + ) -> Result, SocketError> + where + Request: RestRequest, + { + Ok(FtxSignConfig { + api_key: self.api_key.as_str(), + time: Utc::now(), + method: Request::method(), + path: request.path(), + }) + } + + fn add_bytes_to_sign(mac: &mut M, config: &Self::Config<'_>) + where + M: Mac, + { + mac.update(config.time.to_string().as_bytes()); + mac.update(config.method.as_str().as_bytes()); + mac.update(config.path.as_bytes()); + } + + fn build_signed_request( + config: Self::Config<'_>, + builder: RequestBuilder, + signature: String, + ) -> Result { + // Add Ftx required Headers & build reqwest::Request + builder + .header("FTX-KEY", config.api_key) + .header("FTX-TS", &config.time.timestamp_millis().to_string()) + .header("FTX-SIGN", &signature) + .build() + .map_err(SocketError::from) + } +} + +struct FtxParser; + +impl HttpParser for FtxParser { + type ApiError = serde_json::Value; + type OutputError = ExecutionError; + + fn parse_api_error(&self, status: StatusCode, api_error: Self::ApiError) -> Self::OutputError { + // For simplicity, use serde_json::Value as Error and extract raw String for parsing + let error = api_error.to_string(); + + // Parse Ftx error message to determine custom ExecutionError variant + match error.as_str() { + message if message.contains("Invalid login credentials") => { + ExecutionError::Unauthorised(error) + } + _ => ExecutionError::Socket(SocketError::HttpResponse(status, error)), + } + } +} + +#[derive(Debug, Error)] +enum ExecutionError { + #[error("request authorisation invalid: {0}")] + Unauthorised(String), + + #[error("SocketError: {0}")] + Socket(#[from] SocketError), +} + +struct FetchBalancesRequest; + +impl RestRequest for FetchBalancesRequest { + type Response = FetchBalancesResponse; // Define Response type + type QueryParams = (); // FetchBalances does not require any QueryParams + type Body = (); // FetchBalances does not require any Body + + fn path(&self) -> Cow<'static, str> { + Cow::Borrowed("/api/wallet/balances") + } + + fn method() -> reqwest::Method { + reqwest::Method::GET + } +} + +#[derive(Deserialize)] +#[allow(dead_code)] +struct FetchBalancesResponse { + success: bool, + result: Vec, +} + +#[derive(Deserialize)] +#[allow(dead_code)] +struct FtxBalance { + #[serde(rename = "coin")] + symbol: Symbol, + total: f64, +} + +/// See Barter-Execution for a comprehensive real-life example, as well as code you can use out of the +/// box to execute trades on many exchanges. +#[tokio::main] +async fn main() { + // HMAC-SHA256 encoded account API secret used for signing private http requests + let mac: Hmac = Hmac::new_from_slice("api_secret".as_bytes()).unwrap(); + + // Build Ftx configured RequestSigner for signing http requests with hex encoding + let request_signer = RequestSigner::new( + FtxSigner { + api_key: "api_key".to_string(), + }, + mac, + HexEncoder, + ); + + // Build RestClient with Ftx configuration + let rest_client = RestClient::new("https://ftx.com", request_signer, FtxParser); + + // Fetch Result + let _response = rest_client.execute(FetchBalancesRequest).await; +} diff --git a/barter-integration/examples/simple_websocket_integration.rs b/barter-integration/examples/simple_websocket_integration.rs new file mode 100644 index 00000000..cdb9cb79 --- /dev/null +++ b/barter-integration/examples/simple_websocket_integration.rs @@ -0,0 +1,108 @@ +use barter_integration::{ + error::SocketError, + protocol::websocket::{WebSocket, WebSocketParser, WsMessage}, + ExchangeStream, Transformer, +}; +use futures::{SinkExt, StreamExt}; +use serde::{de, Deserialize}; +use serde_json::json; +use std::str::FromStr; +use tokio_tungstenite::connect_async; +use tracing::debug; + +// Convenient type alias for an `ExchangeStream` utilising a tungstenite `WebSocket` +type ExchangeWsStream = ExchangeStream; + +// Communicative type alias for what the VolumeSum the Transformer is generating +type VolumeSum = f64; + +#[derive(Deserialize)] +#[serde(untagged, rename_all = "camelCase")] +enum BinanceMessage { + SubResponse { + result: Option>, + id: u32, + }, + Trade { + #[serde(rename = "q", deserialize_with = "de_str")] + quantity: f64, + }, +} + +struct StatefulTransformer { + sum_of_volume: VolumeSum, +} + +impl Transformer for StatefulTransformer { + type Error = SocketError; + type Input = BinanceMessage; + type Output = VolumeSum; + type OutputIter = Vec>; + + fn transform(&mut self, input: Self::Input) -> Self::OutputIter { + // Add new input Trade quantity to sum + match input { + BinanceMessage::SubResponse { result, id } => { + debug!("Received SubResponse for {}: {:?}", id, result); + // Don't care about this for the example + } + BinanceMessage::Trade { quantity, .. } => { + // Add new Trade volume to internal state VolumeSum + self.sum_of_volume += quantity; + } + }; + + // Return IntoIterator of length 1 containing the running sum of volume + vec![Ok(self.sum_of_volume)] + } +} + +/// See Barter-Data for a comprehensive real-life example, as well as code you can use out of the +/// box to collect real-time public market data from many exchanges. +#[tokio::main] +async fn main() { + // Establish Sink/Stream communication with desired WebSocket server + let mut binance_conn = connect_async("wss://fstream.binance.com/ws/") + .await + .map(|(ws_conn, _)| ws_conn) + .expect("failed to connect"); + + // Send something over the socket (eg/ Binance trades subscription) + binance_conn + .send(WsMessage::Text( + json!({"method": "SUBSCRIBE","params": ["btcusdt@aggTrade"],"id": 1}).to_string(), + )) + .await + .expect("failed to send WsMessage over socket"); + + // Instantiate some arbitrary Transformer to apply to data parsed from the WebSocket protocol + let transformer = StatefulTransformer { sum_of_volume: 0.0 }; + + // ExchangeWsStream includes pre-defined WebSocket Sink/Stream & WebSocket StreamParser + let mut ws_stream = ExchangeWsStream::new(binance_conn, transformer); + + // Receive a stream of your desired Output data model from the ExchangeStream + while let Some(volume_result) = ws_stream.next().await { + match volume_result { + Ok(cumulative_volume) => { + // Do something with your data + println!("{cumulative_volume:?}"); + } + Err(error) => { + // React to any errors produced by the internal transformation + eprintln!("{error}") + } + } + } +} + +/// Deserialize a `String` as the desired type. +fn de_str<'de, D, T>(deserializer: D) -> Result +where + D: de::Deserializer<'de>, + T: FromStr, + T::Err: std::fmt::Display, +{ + let data: String = Deserialize::deserialize(deserializer)?; + data.parse::().map_err(de::Error::custom) +} diff --git a/barter-integration/src/de.rs b/barter-integration/src/de.rs new file mode 100644 index 00000000..59fa0347 --- /dev/null +++ b/barter-integration/src/de.rs @@ -0,0 +1,98 @@ +/// Determine the `DateTime` from the provided `Duration` since the epoch. +pub fn datetime_utc_from_epoch_duration( + duration: std::time::Duration, +) -> chrono::DateTime { + chrono::DateTime::::from(std::time::UNIX_EPOCH + duration) +} + +/// Deserialize a `String` as the desired type. +pub fn de_str<'de, D, T>(deserializer: D) -> Result +where + D: serde::de::Deserializer<'de>, + T: std::str::FromStr, + T::Err: std::fmt::Display, +{ + let data: &str = serde::de::Deserialize::deserialize(deserializer)?; + data.parse::().map_err(serde::de::Error::custom) +} + +/// Deserialize a `u64` milliseconds value as `DateTime`. +pub fn de_u64_epoch_ms_as_datetime_utc<'de, D>( + deserializer: D, +) -> Result, D::Error> +where + D: serde::de::Deserializer<'de>, +{ + serde::de::Deserialize::deserialize(deserializer).map(|epoch_ms| { + datetime_utc_from_epoch_duration(std::time::Duration::from_millis(epoch_ms)) + }) +} + +/// Deserialize a &str "u64" milliseconds value as `DateTime`. +pub fn de_str_u64_epoch_ms_as_datetime_utc<'de, D>( + deserializer: D, +) -> Result, D::Error> +where + D: serde::de::Deserializer<'de>, +{ + de_str(deserializer).map(|epoch_ms| { + datetime_utc_from_epoch_duration(std::time::Duration::from_millis(epoch_ms)) + }) +} + +/// Deserialize a &str "f64" milliseconds value as `DateTime`. +pub fn de_str_f64_epoch_ms_as_datetime_utc<'de, D>( + deserializer: D, +) -> Result, D::Error> +where + D: serde::de::Deserializer<'de>, +{ + de_str(deserializer).map(|epoch_ms: f64| { + datetime_utc_from_epoch_duration(std::time::Duration::from_millis(epoch_ms as u64)) + }) +} + +/// Deserialize a &str "f64" seconds value as `DateTime`. +pub fn de_str_f64_epoch_s_as_datetime_utc<'de, D>( + deserializer: D, +) -> Result, D::Error> +where + D: serde::de::Deserializer<'de>, +{ + de_str(deserializer).map(|epoch_s: f64| { + datetime_utc_from_epoch_duration(std::time::Duration::from_secs_f64(epoch_s)) + }) +} + +/// Assists deserialisation of sequences by attempting to extract & parse the next element in the +/// provided sequence. +/// +/// A [`serde::de::Error`] is returned if the element does not exist, or it cannot +/// be deserialized into the `Target` type inferred. +/// +/// Example sequence: ["20180.30000","0.00010000","1661978265.280067","s","l",""] +pub fn extract_next<'de, SeqAccessor, Target>( + sequence: &mut SeqAccessor, + name: &'static str, +) -> Result +where + SeqAccessor: serde::de::SeqAccess<'de>, + Target: serde::de::DeserializeOwned, +{ + sequence + .next_element::()? + .ok_or_else(|| serde::de::Error::missing_field(name)) +} + +/// Serialize a generic element T as a `Vec`. +pub fn se_element_to_vector(element: T, serializer: S) -> Result +where + S: serde::Serializer, + T: serde::Serialize, +{ + use serde::ser::SerializeSeq; + + let mut sequence = serializer.serialize_seq(Some(1))?; + sequence.serialize_element(&element)?; + sequence.end() +} diff --git a/barter-integration/src/error.rs b/barter-integration/src/error.rs new file mode 100644 index 00000000..dc74b7f2 --- /dev/null +++ b/barter-integration/src/error.rs @@ -0,0 +1,71 @@ +use crate::model::SubscriptionId; +use reqwest::Error; +use thiserror::Error; + +/// All socket IO related errors generated in `barter-integration`. +#[derive(Debug, Error)] +pub enum SocketError { + #[error("Sink error")] + Sink, + + #[error("Deserialising JSON error: {error} for payload: {payload}")] + Deserialise { + error: serde_json::Error, + payload: String, + }, + + #[error("Deserialising JSON error: {error} for binary payload: {payload:?}")] + DeserialiseBinary { + error: serde_json::Error, + payload: Vec, + }, + + #[error("Serialising JSON error: {0}")] + Serialise(serde_json::Error), + + #[error("SerDe Query String serialisation error: {0}")] + QueryParams(#[from] serde_qs::Error), + + #[error("SerDe url encoding serialisation error: {0}")] + UrlEncoded(#[from] serde_urlencoded::ser::Error), + + #[error("error parsing Url: {0}")] + UrlParse(#[from] url::ParseError), + + #[error("error subscribing to resources over the socket: {0}")] + Subscribe(String), + + #[error("ExchangeStream terminated with closing frame: {0}")] + Terminated(String), + + #[error("{entity} does not support: {item}")] + Unsupported { entity: &'static str, item: String }, + + #[error("WebSocket error: {0}")] + WebSocket(#[from] tokio_tungstenite::tungstenite::Error), + + #[error("HTTP error: {0}")] + Http(reqwest::Error), + + #[error("HTTP request timed out")] + HttpTimeout(reqwest::Error), + + /// REST http response error + #[error("HTTP response (status={0}) error: {1}")] + HttpResponse(reqwest::StatusCode, String), + + #[error("consumed unidentifiable message: {0}")] + Unidentifiable(SubscriptionId), + + #[error("consumed error message from exchange: {0}")] + Exchange(String), +} + +impl From for SocketError { + fn from(error: Error) -> Self { + match error { + error if error.is_timeout() => SocketError::HttpTimeout(error), + error => SocketError::Http(error), + } + } +} diff --git a/barter-integration/src/lib.rs b/barter-integration/src/lib.rs new file mode 100644 index 00000000..e5b48ba2 --- /dev/null +++ b/barter-integration/src/lib.rs @@ -0,0 +1,153 @@ +//! # Barter-Integration +//! High-performance, low-level framework for composing flexible web integrations. +//! +//! Utilised by other Barter trading ecosystem crates to build robust financial exchange integrations, +//! primarily for public data collection & trade execution. It is: +//! * **Low-Level**: Translates raw data streams communicated over the web into any desired data model using arbitrary data transformations. +//! * **Flexible**: Compatible with any protocol (WebSocket, FIX, Http, etc.), any input/output model, and any user defined transformations. +//! +//! ## Core abstractions: +//! - **RestClient** providing configurable signed Http communication between client & server. +//! - **ExchangeStream** providing configurable communication over any asynchronous stream protocols (WebSocket, FIX, etc.). +//! +//! Both core abstractions provide the robust glue you need to conveniently translate between server & client data models. + +#![warn( + missing_debug_implementations, + missing_copy_implementations, + rust_2018_idioms +)] + +use crate::{error::SocketError, protocol::StreamParser}; +use futures::Stream; +use pin_project::pin_project; +use serde::Deserialize; +use std::{ + collections::VecDeque, + fmt::Debug, + marker::PhantomData, + pin::Pin, + task::{Context, Poll}, +}; + +/// Foundational data structures that define the building blocks used by the rest of the `Barter` +/// ecosystem. +/// +/// eg/ `Market`, `Exchange`, `Instrument`, `Symbol`, etc. +pub mod model; + +/// All [`Error`](std::error::Error)s generated in Barter-Integration. +pub mod error; + +/// Contains `StreamParser` implementations for transforming communication protocol specific +/// messages into a generic output data structure. +pub mod protocol; + +/// Contains the flexible `Metric` type used for representing real-time metrics generically. +pub mod metric; + +/// Utilities to assist deserialisation. +pub mod de; + +/// [`Validator`]s are capable of determining if their internal state is satisfactory to fulfill +/// some use case defined by the implementor. +pub trait Validator { + /// Check if `Self` is valid for some use case. + fn validate(self) -> Result + where + Self: Sized; +} + +/// [`Transformer`]s are capable of transforming any `Input` into an iterator of +/// `Result`s. +pub trait Transformer { + type Error; + type Input: for<'de> Deserialize<'de>; + type Output; + type OutputIter: IntoIterator>; + fn transform(&mut self, input: Self::Input) -> Self::OutputIter; +} + +/// An [`ExchangeStream`] is a communication protocol agnostic [`Stream`]. It polls protocol +/// messages from the inner [`Stream`], and transforms them into the desired output data structure. +#[derive(Debug)] +#[pin_project] +pub struct ExchangeStream +where + Protocol: StreamParser, + InnerStream: Stream, + StreamTransformer: Transformer, +{ + #[pin] + pub stream: InnerStream, + pub transformer: StreamTransformer, + pub buffer: VecDeque>, + pub protocol_marker: PhantomData, +} + +impl Stream + for ExchangeStream +where + Protocol: StreamParser, + InnerStream: Stream> + Unpin, + StreamTransformer: Transformer, + StreamTransformer::Error: From, +{ + type Item = Result; + + fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + loop { + // Flush Self::Item buffer if it is not currently empty + if let Some(output) = self.buffer.pop_front() { + return Poll::Ready(Some(output)); + } + + // Poll inner `Stream` for next the next input protocol message + let input = match self.as_mut().project().stream.poll_next(cx) { + Poll::Ready(Some(input)) => input, + Poll::Ready(None) => return Poll::Ready(None), + Poll::Pending => return Poll::Pending, + }; + + // Parse input protocol message into `ExchangeMessage` + let exchange_message = match Protocol::parse::(input) { + // `StreamParser` successfully deserialised `ExchangeMessage` + Some(Ok(exchange_message)) => exchange_message, + + // If `StreamParser` returns an Err pass it downstream + Some(Err(err)) => return Poll::Ready(Some(Err(err.into()))), + + // If `StreamParser` returns None it's a safe-to-skip message + None => continue, + }; + + // Transform `ExchangeMessage` into `Transformer::OutputIter` + // ie/ IntoIterator> + self.transformer + .transform(exchange_message) + .into_iter() + .for_each( + |output_result: Result| { + self.buffer.push_back(output_result) + }, + ); + } + } +} + +impl + ExchangeStream +where + Protocol: StreamParser, + InnerStream: Stream, + StreamTransformer: Transformer, +{ + pub fn new(stream: InnerStream, transformer: StreamTransformer) -> Self { + Self { + stream, + transformer, + buffer: VecDeque::with_capacity(6), + protocol_marker: PhantomData, + } + } +} diff --git a/barter-integration/src/metric.rs b/barter-integration/src/metric.rs new file mode 100644 index 00000000..aa980624 --- /dev/null +++ b/barter-integration/src/metric.rs @@ -0,0 +1,109 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialOrd, PartialEq, Serialize)] +pub struct Metric { + /// Metric name. + pub name: &'static str, + + /// Milliseconds since the Unix epoch. + pub time: u64, + + /// Key-Value pairs to categorise the Metric. + pub tags: Vec, + + /// Observed measurements. + pub fields: Vec, +} + +#[derive(Debug, Clone, Serialize, Ord, PartialOrd, Eq, PartialEq)] +pub struct Tag { + pub key: &'static str, + pub value: String, +} + +#[derive(Debug, Clone, PartialOrd, PartialEq, Serialize)] +pub struct Field { + pub key: &'static str, + pub value: Value, +} + +#[derive(Debug, Clone, PartialOrd, PartialEq, Deserialize, Serialize)] +pub enum Value { + Float(f64), + Int(i64), + UInt(u64), + Bool(bool), + String(String), +} + +impl From<(&'static str, S)> for Tag +where + S: Into, +{ + fn from((key, value): (&'static str, S)) -> Self { + Self::new(key, value) + } +} + +impl Tag { + pub fn new(key: &'static str, value: S) -> Self + where + S: Into, + { + Self { + key, + value: value.into(), + } + } +} + +impl From<(&'static str, S)> for Field +where + S: Into, +{ + fn from((key, value): (&'static str, S)) -> Self { + Self::new(key, value) + } +} + +impl Field { + pub fn new(key: &'static str, value: S) -> Self + where + S: Into, + { + Self { + key, + value: value.into(), + } + } +} + +impl From for Value { + fn from(value: f64) -> Self { + Self::Float(value) + } +} + +impl From for Value { + fn from(value: i64) -> Self { + Self::Int(value) + } +} + +impl From for Value { + fn from(value: u64) -> Self { + Self::UInt(value) + } +} + +impl From for Value { + fn from(value: bool) -> Self { + Self::Bool(value) + } +} + +impl From for Value { + fn from(value: String) -> Self { + Self::String(value) + } +} diff --git a/barter-integration/src/model/instrument/kind.rs b/barter-integration/src/model/instrument/kind.rs new file mode 100644 index 00000000..61697db9 --- /dev/null +++ b/barter-integration/src/model/instrument/kind.rs @@ -0,0 +1,109 @@ +use chrono::{DateTime, Utc}; +use rust_decimal::Decimal; +use serde::{Deserialize, Serialize}; +use std::fmt::{Display, Formatter}; + +/// Defines the type of [`Instrument`](super::Instrument) which is being traded on a +/// given `base_quote` market. +#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum InstrumentKind { + Spot, + Future(FutureContract), + Perpetual, + Option(OptionContract), +} + +impl Default for InstrumentKind { + fn default() -> Self { + Self::Spot + } +} + +impl Display for InstrumentKind { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{}", + match self { + InstrumentKind::Spot => "spot".to_string(), + InstrumentKind::Future(future) => + format!("future_{}-UTC", future.expiry.date_naive()), + InstrumentKind::Perpetual => "perpetual".to_string(), + InstrumentKind::Option(option) => format!( + "option_{}_{}_{}-UTC_{}", + option.kind, + option.exercise, + option.expiry.date_naive(), + option.strike, + ), + } + ) + } +} + +/// Configuration of an [`InstrumentKind::Future`] contract. +#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Deserialize, Serialize)] +pub struct FutureContract { + #[serde(with = "chrono::serde::ts_milliseconds")] + pub expiry: DateTime, +} + +/// Configuration of an [`InstrumentKind::Option`] contract. +#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Deserialize, Serialize)] +pub struct OptionContract { + pub kind: OptionKind, + pub exercise: OptionExercise, + #[serde(with = "chrono::serde::ts_milliseconds")] + pub expiry: DateTime, + pub strike: Decimal, +} + +/// [`OptionContract`] kind - Put or Call. +#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Deserialize, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum OptionKind { + #[serde(alias = "CALL", alias = "Call")] + Call, + #[serde(alias = "PUT", alias = "Put")] + Put, +} + +impl Display for OptionKind { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{}", + match self { + OptionKind::Call => "call", + OptionKind::Put => "put", + } + ) + } +} + +/// [`OptionContract`] exercise style. +#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Deserialize, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum OptionExercise { + #[serde(alias = "AMERICAN", alias = "American")] + American, + #[serde(alias = "BERMUDAN", alias = "Bermudan")] + Bermudan, + #[serde(alias = "EUROPEAN", alias = "European")] + European, +} + +impl Display for OptionExercise { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{}", + match self { + OptionExercise::American => "american", + OptionExercise::Bermudan => "bermudan", + OptionExercise::European => "european", + } + ) + } +} diff --git a/barter-integration/src/model/instrument/mod.rs b/barter-integration/src/model/instrument/mod.rs new file mode 100644 index 00000000..5f5d6087 --- /dev/null +++ b/barter-integration/src/model/instrument/mod.rs @@ -0,0 +1,165 @@ +use crate::model::instrument::{kind::InstrumentKind, symbol::Symbol}; +use serde::{Deserialize, Serialize}; +use std::fmt::{Display, Formatter}; + +pub mod kind; +pub mod symbol; + +/// Barter representation of an `Instrument`. Used to uniquely identify a `base_quote` pair, and it's +/// associated instrument type. +/// +/// eg/ Instrument { base: "btc", quote: "usdt", kind: Spot } +#[derive(Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub struct Instrument { + pub base: Symbol, + pub quote: Symbol, + #[serde(rename = "instrument_kind")] + pub kind: InstrumentKind, +} + +impl Display for Instrument { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "({}_{}, {})", self.base, self.quote, self.kind) + } +} + +impl From<(S, S, InstrumentKind)> for Instrument +where + S: Into, +{ + fn from((base, quote, kind): (S, S, InstrumentKind)) -> Self { + Self { + base: base.into(), + quote: quote.into(), + kind, + } + } +} + +impl Instrument { + /// Constructs a new [`Instrument`] using the provided configuration. + pub fn new(base: S, quote: S, kind: InstrumentKind) -> Self + where + S: Into, + { + Self { + base: base.into(), + quote: quote.into(), + kind, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::instrument::kind::{ + FutureContract, InstrumentKind, OptionContract, OptionExercise, OptionKind, + }; + use chrono::{TimeZone, Utc}; + use rust_decimal_macros::dec; + + #[test] + fn test_de_instrument() { + struct TestCase { + input: &'static str, + expected: Result, + } + + let cases = vec![ + TestCase { + // TC0: Valid Spot + input: r#"{"base": "btc", "quote": "usd", "instrument_kind": "spot" }"#, + expected: Ok(Instrument::from(("btc", "usd", InstrumentKind::Spot))), + }, + TestCase { + // TC1: Valid Future + input: r#"{ + "base": "btc", + "quote": "usd", + "instrument_kind": {"future": {"expiry": 1703980800000}} + }"#, + expected: Ok(Instrument::new( + "btc", + "usd", + InstrumentKind::Future(FutureContract { + expiry: Utc.timestamp_millis_opt(1703980800000).unwrap(), + }), + )), + }, + TestCase { + // TC2: Valid FuturePerpetual + input: r#"{"base": "btc", "quote": "usd", "instrument_kind": "perpetual" }"#, + expected: Ok(Instrument::from(("btc", "usd", InstrumentKind::Perpetual))), + }, + TestCase { + // TC3: Valid Option Call American + input: r#"{ + "base": "btc", + "quote": "usd", + "instrument_kind": { + "option": { + "kind": "CALL", + "exercise": "American", + "expiry": 1703980800000, + "strike": 50000 + } + } + }"#, + expected: Ok(Instrument::from(( + "btc", + "usd", + InstrumentKind::Option(OptionContract { + kind: OptionKind::Call, + exercise: OptionExercise::American, + expiry: Utc.timestamp_millis_opt(1703980800000).unwrap(), + strike: dec!(50000), + }), + ))), + }, + TestCase { + // TC4: Valid Option Put Bermudan + input: r#"{ + "base": "btc", + "quote": "usd", + "instrument_kind": { + "option": { + "kind": "Put", + "exercise": "BERMUDAN", + "expiry": 1703980800000, + "strike": 50000 + } + } + }"#, + expected: Ok(Instrument::from(( + "btc", + "usd", + InstrumentKind::Option(OptionContract { + kind: OptionKind::Put, + exercise: OptionExercise::Bermudan, + expiry: Utc.timestamp_millis_opt(1703980800000).unwrap(), + strike: dec!(50000.0), + }), + ))), + }, + ]; + + for (index, test) in cases.into_iter().enumerate() { + let actual = serde_json::from_str::(test.input); + + match (actual, test.expected) { + (Ok(actual), Ok(expected)) => { + assert_eq!(actual, expected, "TC{} failed", index) + } + (Err(_), Err(_)) => { + // Test passed + } + (actual, expected) => { + // Test failed + panic!("TC{index} failed because actual != expected. \nActual: {actual:?}\nExpected: {expected:?}\n"); + } + } + } + } +} diff --git a/barter-integration/src/model/instrument/symbol.rs b/barter-integration/src/model/instrument/symbol.rs new file mode 100644 index 00000000..e0c4c970 --- /dev/null +++ b/barter-integration/src/model/instrument/symbol.rs @@ -0,0 +1,54 @@ +use serde::{Deserialize, Deserializer, Serialize}; +use std::fmt::{Debug, Display, Formatter}; + +/// Barter new type representing a currency symbol `String` identifier. +/// +/// eg/ "btc", "eth", "usdt", etc +#[derive(Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize)] +pub struct Symbol(String); + +impl Debug for Symbol { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl Display for Symbol { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl AsRef for Symbol { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl<'de> Deserialize<'de> for Symbol { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + String::deserialize(deserializer).map(Symbol::new) + } +} + +impl From for Symbol +where + S: Into, +{ + fn from(input: S) -> Self { + Symbol::new(input) + } +} + +impl Symbol { + /// Construct a new [`Symbol`] new type using the provided `Into` value. + pub fn new(input: S) -> Self + where + S: Into, + { + Self(input.into().to_lowercase()) + } +} diff --git a/barter-integration/src/model/mod.rs b/barter-integration/src/model/mod.rs new file mode 100644 index 00000000..b0197e8a --- /dev/null +++ b/barter-integration/src/model/mod.rs @@ -0,0 +1,245 @@ +use crate::model::instrument::{kind::InstrumentKind, symbol::Symbol, Instrument}; +use serde::{Deserialize, Deserializer, Serialize}; +use std::{ + borrow::Cow, + fmt::{Debug, Display, Formatter}, +}; + +/// [`Instrument`] related data structures. +/// +/// eg/ `Instrument`, `InstrumentKind`, `OptionContract`, `Symbol`, etc. +pub mod instrument; + +/// Represents a unique combination of an [`Exchange`] & an [`Instrument`]. +#[derive(Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Deserialize, Serialize)] +pub struct Market { + pub exchange: Exchange, + #[serde(flatten)] + pub instrument: InstrumentId, +} + +impl From<(E, I)> for Market +where + E: Into, + I: Into, +{ + fn from((exchange, instrument): (E, I)) -> Self { + Self::new(exchange, instrument) + } +} + +impl From<(E, S, S, InstrumentKind)> for Market +where + E: Into, + S: Into, +{ + fn from((exchange, base, quote, instrument_kind): (E, S, S, InstrumentKind)) -> Self { + Self::new(exchange, (base, quote, instrument_kind)) + } +} + +impl Market { + /// Constructs a new [`Market`] using the provided [`Exchange`] & [`Instrument`]. + pub fn new(exchange: E, instrument: I) -> Self + where + E: Into, + I: Into, + { + Self { + exchange: exchange.into(), + instrument: instrument.into(), + } + } +} + +/// Barter new type representing a unique `String` identifier for a [`Market`], where a [`Market`] +/// represents an [`Instrument`] being traded on an [`Exchange`]. +/// +/// eg/ binance_(btc_spot, future_perpetual) +/// eg/ ftx_btc_usdt_future_perpetual +#[derive(Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize)] +pub struct MarketId(pub String); + +impl Debug for MarketId { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl Display for MarketId { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl<'de> Deserialize<'de> for MarketId { + fn deserialize>(deserializer: D) -> Result { + String::deserialize(deserializer).map(MarketId) + } +} + +impl From<&Market> for MarketId { + fn from(value: &Market) -> Self { + Self(format!("{}_{}", value.exchange, value.instrument).to_lowercase()) + } +} + +impl MarketId { + /// Construct a unique `String` [`MarketId`] identifier for a [`Market`], where a [`Market`] + /// represents an [`Instrument`] being traded on an [`Exchange`]. + pub fn new(exchange: &Exchange, instrument: &Instrument) -> Self { + Self( + format!( + "{}_{}_{}_{}", + exchange, instrument.base, instrument.quote, instrument.kind + ) + .to_lowercase(), + ) + } +} + +/// Barter representation of an [`Exchange`]'s name. +/// +/// eg/ Exchange("binance_spot"), Exchange("bitfinex"), Exchange("gateio_spot"), etc. +#[derive(Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize)] +pub struct Exchange(Cow<'static, str>); + +impl From for Exchange +where + E: Into>, +{ + fn from(exchange: E) -> Self { + Exchange(exchange.into()) + } +} + +impl Debug for Exchange { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl Display for Exchange { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +/// New type representing a unique `String` identifier for a stream that has been subscribed to. +/// This is used to identify data structures received over the socket. +/// +/// For example, `Barter-Data` uses this identifier to associate received data structures from the +/// exchange with the original `Barter-Data` `Subscription` that was actioned over the socket. +/// +/// Note: Each exchange will require the use of different `String` identifiers depending on the +/// data structures they send. +/// +/// eg/ [`SubscriptionId`] of an `FtxTrade` is "{BASE}/{QUOTE}" (ie/ market). +/// eg/ [`SubscriptionId`] of a `BinanceTrade` is "{base}{symbol}@trade" (ie/ channel). +#[derive(Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize)] +pub struct SubscriptionId(pub String); + +impl Debug for SubscriptionId { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl Display for SubscriptionId { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl AsRef for SubscriptionId { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl From for SubscriptionId +where + S: Into, +{ + fn from(input: S) -> Self { + Self(input.into()) + } +} + +/// [`Side`] of a trade or position - Buy or Sell. +#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Deserialize, Serialize)] +pub enum Side { + #[serde(alias = "buy", alias = "BUY", alias = "b")] + Buy, + #[serde(alias = "sell", alias = "SELL", alias = "s")] + Sell, +} + +impl Display for Side { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{}", + match self { + Side::Buy => "buy", + Side::Sell => "sell", + } + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::instrument::{kind::InstrumentKind, Instrument}; + use serde::de::Error; + + #[test] + fn test_de_market() { + struct TestCase { + input: &'static str, + expected: Result, + } + + let cases = vec![ + TestCase { + // TC0: Valid Binance btc_usd Spot Market + input: r##"{ "exchange": "binance", "base": "btc", "quote": "usd", "instrument_kind": "spot" }"##, + expected: Ok(Market { + exchange: Exchange::from("binance"), + instrument: Instrument::from(("btc", "usd", InstrumentKind::Spot)), + }), + }, + TestCase { + // TC1: Valid Ftx btc_usd FuturePerpetual Market + input: r##"{ "exchange": "ftx_old", "base": "btc", "quote": "usd", "instrument_kind": "perpetual" }"##, + expected: Ok(Market { + exchange: Exchange::from("ftx_old"), + instrument: Instrument::from(("btc", "usd", InstrumentKind::Perpetual)), + }), + }, + TestCase { + // TC3: Invalid Market w/ numeric exchange + input: r##"{ "exchange": 100, "base": "btc", "quote": "usd", "instrument_kind": "perpetual" }"##, + expected: Err(serde_json::Error::custom("")), + }, + ]; + + for (index, test) in cases.into_iter().enumerate() { + let actual = serde_json::from_str::(test.input); + + match (actual, test.expected) { + (Ok(actual), Ok(expected)) => { + assert_eq!(actual, expected, "TC{} failed", index) + } + (Err(_), Err(_)) => { + // Test passed + } + (actual, expected) => { + // Test failed + panic!("TC{index} failed because actual != expected. \nActual: {actual:?}\nExpected: {expected:?}\n"); + } + } + } + } +} diff --git a/barter-integration/src/protocol/http/mod.rs b/barter-integration/src/protocol/http/mod.rs new file mode 100644 index 00000000..f4c4f580 --- /dev/null +++ b/barter-integration/src/protocol/http/mod.rs @@ -0,0 +1,87 @@ +use self::rest::RestRequest; +use crate::error::SocketError; +use reqwest::StatusCode; +use serde::de::DeserializeOwned; +use tracing::error; + +/// Defines an abstract [`RestRequest`] that can be executed by a fully +/// configurable [`RestClient`](rest::client::RestClient). +pub mod rest; + +/// Defines a configurable [`RequestSigner`](private::RequestSigner) that signs Http +/// [`RestRequest`] using API specific logic. +pub mod private; + +/// Defines a default [`BuildStrategy`] that builds a non-authenticated Http +/// [`RestRequest`] with no headers. +pub mod public; + +/// [`RestRequest`] build strategy for the API being interacted with. +/// +/// An API that requires authenticated [`RestRequest`]s will likely utilise the configurable +/// [`RequestSigner`](private::RequestSigner) to sign the requests before building. +/// +/// An API that requires no authentication may just add mandatory `reqwest` headers to the +/// [`RestRequest`] before building. +pub trait BuildStrategy { + /// Use a [`RestRequest`] and [`reqwest::RequestBuilder`] to construct a [`reqwest::Request`] + /// that is ready for executing. + /// + /// It is expected that any signing or performed during this method, or the addition of any + /// `reqwest` headers. + fn build( + &self, + request: Request, + builder: reqwest::RequestBuilder, + ) -> Result + where + Request: RestRequest; +} + +/// Utilised by a [`RestClient`](rest::client::RestClient) to deserialise +/// [`RestRequest::Response`], and upon failure parses API errors +/// returned from the server. +pub trait HttpParser { + type ApiError: DeserializeOwned; + type OutputError: From; + + /// Attempt to parse a [`StatusCode`] & bytes payload into a deserialisable `Response`. + fn parse( + &self, + status: StatusCode, + payload: &[u8], + ) -> Result + where + Response: DeserializeOwned, + { + // Attempt to deserialise reqwest::Response bytes into Ok(Response) + let parse_ok_error = match serde_json::from_slice::(payload) { + Ok(response) => return Ok(response), + Err(serde_error) => serde_error, + }; + + // Attempt to deserialise API Error if Ok(Response) deserialisation failed + let parse_api_error_error = match serde_json::from_slice::(payload) { + Ok(api_error) => return Err(self.parse_api_error(status, api_error)), + Err(serde_error) => serde_error, + }; + + // Log errors if failed to deserialise reqwest::Response into Response or API Self::Error + error!( + status_code = ?status, + ?parse_ok_error, + ?parse_api_error_error, + response_body = %String::from_utf8_lossy(payload), + "error deserializing HTTP response" + ); + + Err(Self::OutputError::from(SocketError::DeserialiseBinary { + error: parse_ok_error, + payload: payload.to_vec(), + })) + } + + /// If [`parse`](Self::parse) fails to deserialise the `Ok(Response)`, this function parses + /// to parse the API [`Self::ApiError`] associated with the response. + fn parse_api_error(&self, status: StatusCode, error: Self::ApiError) -> Self::OutputError; +} diff --git a/barter-integration/src/protocol/http/private/encoder.rs b/barter-integration/src/protocol/http/private/encoder.rs new file mode 100644 index 00000000..d6dfb428 --- /dev/null +++ b/barter-integration/src/protocol/http/private/encoder.rs @@ -0,0 +1,35 @@ +use base64::Engine; + +/// Encodes bytes data. +pub trait Encoder { + /// Encodes the bytes data into some `String` format. + fn encode(&self, data: Bytes) -> String + where + Bytes: AsRef<[u8]>; +} + +/// Encodes bytes data as a hex `String` using lowercase characters. +#[derive(Debug, Copy, Clone)] +pub struct HexEncoder; + +impl Encoder for HexEncoder { + fn encode(&self, data: Bytes) -> String + where + Bytes: AsRef<[u8]>, + { + hex::encode(data) + } +} + +/// Encodes bytes data as a base64 `String`. +#[derive(Debug, Copy, Clone)] +pub struct Base64Encoder; + +impl Encoder for Base64Encoder { + fn encode(&self, data: Bytes) -> String + where + Bytes: AsRef<[u8]>, + { + base64::engine::general_purpose::STANDARD.encode(data) + } +} diff --git a/barter-integration/src/protocol/http/private/mod.rs b/barter-integration/src/protocol/http/private/mod.rs new file mode 100644 index 00000000..fa86f1f6 --- /dev/null +++ b/barter-integration/src/protocol/http/private/mod.rs @@ -0,0 +1,131 @@ +use self::encoder::Encoder; +use super::{rest::RestRequest, BuildStrategy}; +use crate::error::SocketError; +use hmac::Mac; + +/// Implementations for encoding signatures generated by a [`RequestSigner`]. +pub mod encoder; + +/// API specific signing logic used by a [`RequestSigner`]. +#[allow(clippy::needless_lifetimes)] +pub trait Signer { + /// Configuration required to sign the [`RestRequest`]s for this API server. + type Config<'a> + where + Self: 'a; + + /// Generates a [`Self::Config`] for this [`RestRequest`] and + /// [`RequestBuilder`](reqwest::RequestBuilder). + /// + /// # Examples + /// + /// ## Private REST Request: FTX + /// ```rust,ignore + /// fn config(&self, _: Request, _: &RequestBuilder) -> Self::Config + /// where + /// Request: RestRequest + /// { + /// FtxSignConfig { + /// api_key: self.api_key.as_str(), + /// time: Utc::now(), + /// method: Request::method(), + /// path: Request::path() + /// } + /// } + /// ``` + fn config<'a, Request>( + &'a self, + request: Request, + builder: &reqwest::RequestBuilder, + ) -> Result, SocketError> + where + Request: RestRequest; + + /// Generate the bytes to sign from the provided [`Self::Config`]. + /// + /// # Examples + /// + /// ## Private REST Request: FTX + /// ```rust,ignore + /// fn add_bytes_to_sign(mac: &mut M, config: &Self::Config) { + /// mac.update(config.time.to_string().as_bytes()); + /// mac.update(config.method.as_str().as_bytes()); + /// mac.update(config.path.as_bytes()); + /// } + /// ``` + fn add_bytes_to_sign(mac: &mut M, config: &Self::Config<'_>) + where + M: Mac; + + /// Build a signed [`reqwest::Request`] from the provided [`Self::Config`], + /// [`RequestBuilder`](reqwest::RequestBuilder), and generated cryptographic signature `String`. + /// + /// # Examples + /// + /// ## Private REST Request: FTX + /// ```rust,ignore + /// fn build_signed_request(config: Self::Config, builder: RequestBuilder, signature: String) -> Result { + /// // Add Ftx required Headers & build reqwest::Request + /// builder + /// .header(HEADER_FTX_KEY, &self.api_key) + /// .header(HEADER_FTX_SIGN, &signature) + /// .header(HEADER_FTX_TS, &time) + /// .build() + /// .map_err(SocketError::from) + /// } + /// ``` + fn build_signed_request<'a>( + config: Self::Config<'a>, + builder: reqwest::RequestBuilder, + signature: String, + ) -> Result; +} + +/// Generically signs Http [`RestRequest`]s utilising API specific [`Signer`] logic, a hashable +/// [`Mac`], and a signature [`Encoder`]. +#[derive(Debug, Copy, Clone)] +pub struct RequestSigner { + signer: Sig, + mac: Hmac, + encoder: SigEncoder, +} + +impl BuildStrategy for RequestSigner +where + Sig: Signer, + Hmac: Mac + Clone, + SigEncoder: Encoder, +{ + fn build( + &self, + request: Request, + builder: reqwest::RequestBuilder, + ) -> Result + where + Request: RestRequest, + { + // Build configuration required for generating signed requests + let config = self.signer.config(request, &builder)?; + + // Update Mac state & finalise bytes + let mut mac = self.mac.clone(); + Sig::add_bytes_to_sign(&mut mac, &config); + let bytes_to_encode = mac.finalize().into_bytes(); + + // Encode signature from Mac bytes + let signature = self.encoder.encode(bytes_to_encode); + + Sig::build_signed_request(config, builder, signature) + } +} + +impl RequestSigner { + /// Construct a new [`Self`] using the provided API specific configuration. + pub fn new(signer: Sig, mac: Hmac, encoder: SigEncoder) -> Self { + Self { + signer, + mac, + encoder, + } + } +} diff --git a/barter-integration/src/protocol/http/public/mod.rs b/barter-integration/src/protocol/http/public/mod.rs new file mode 100644 index 00000000..064dfb62 --- /dev/null +++ b/barter-integration/src/protocol/http/public/mod.rs @@ -0,0 +1,16 @@ +use super::BuildStrategy; +use crate::error::SocketError; + +/// [`RestRequest`](super::RestRequest) [`BuildStrategy`] that builds a non-authenticated Http request with no headers. +#[derive(Debug, Copy, Clone)] +pub struct PublicNoHeaders; + +impl BuildStrategy for PublicNoHeaders { + fn build( + &self, + _: Request, + builder: reqwest::RequestBuilder, + ) -> Result { + builder.build().map_err(SocketError::from) + } +} diff --git a/barter-integration/src/protocol/http/rest/client.rs b/barter-integration/src/protocol/http/rest/client.rs new file mode 100644 index 00000000..70e627c3 --- /dev/null +++ b/barter-integration/src/protocol/http/rest/client.rs @@ -0,0 +1,141 @@ +use crate::{ + error::SocketError, + metric::{Field, Metric, Tag}, + protocol::http::{rest::RestRequest, BuildStrategy, HttpParser}, +}; +use bytes::Bytes; +use chrono::Utc; +use std::borrow::Cow; + +/// Configurable REST client capable of executing signed [`RestRequest`]s. Use this when +/// integrating APIs that require Http in order to interact with resources. Each API will require +/// a specific combination of [`Signer`](super::super::private::Signer), [`Mac`](hmac::Mac), +/// signature [`Encoder`](super::super::private::encoder::Encoder), and +/// [`HttpParser`]. +#[derive(Debug)] +pub struct RestClient<'a, Strategy, Parser> { + /// HTTP [`reqwest::Client`] for executing signed [`reqwest::Request`]s. + pub http_client: reqwest::Client, + + /// Base Url of the API being interacted with. + pub base_url: Cow<'a, str>, + + /// [`RestRequest`] build strategy for the API being interacted with that implements + /// [`BuildStrategy`]. + /// + /// An authenticated [`RestClient`] will utilise API specific + /// [`Signer`](super::super::private::Signer) logic, a hashable [`Mac`](hmac::Mac), and a + /// signature [`Encoder`](super::super::private::encoder::Encoder). Where as a non authorised + /// [`RestRequest`] may add any mandatory `reqwest` headers that are required. + pub strategy: Strategy, + + /// [`HttpParser`] that deserialises [`RestRequest::Response`]s, and upon failure parses + /// API errors returned from the server. + pub parser: Parser, +} + +impl<'a, Strategy, Parser> RestClient<'a, Strategy, Parser> +where + Strategy: BuildStrategy, + Parser: HttpParser, +{ + /// Execute the provided [`RestRequest`]. + pub async fn execute( + &self, + request: Request, + ) -> Result<(Request::Response, Metric), Parser::OutputError> + where + Request: RestRequest, + { + // Use provided Request to construct a signed reqwest::Request + let request = self.build(request)?; + + // Measure request execution + let (status, payload, latency) = self.measured_execution::(request).await?; + + // Attempt to parse API Success or Error response + self.parser + .parse::(status, &payload) + .map(|response| (response, latency)) + } + + /// Use the provided [`RestRequest`] to construct a signed Http [`reqwest::Request`]. + pub fn build(&self, request: Request) -> Result + where + Request: RestRequest, + { + // Construct url + let url = format!("{}{}", self.base_url, request.path()); + + // Construct RequestBuilder with method & url + let mut builder = self + .http_client + .request(Request::method(), url) + .timeout(Request::timeout()); + + // Add optional query parameters + if let Some(query_params) = request.query_params() { + builder = builder.query(query_params); + } + + // Add optional Body + if let Some(body) = request.body() { + builder = builder.json(body); + } + + // Use RequestBuilder (public or private strategy) to build reqwest::Request + self.strategy.build(request, builder) + } + + /// Execute the built [`reqwest::Request`] using the [`reqwest::Client`]. + /// + /// Measures and returns the Http request round trip duration. + pub async fn measured_execution( + &self, + request: reqwest::Request, + ) -> Result<(reqwest::StatusCode, Bytes, Metric), SocketError> + where + Request: RestRequest, + { + // Construct Http request duration Metric + let mut latency = Metric { + name: "http_request_duration", + time: Utc::now().timestamp_millis() as u64, + tags: vec![ + Tag::new("http_method", Request::method().as_str()), + Tag::new("base_url", self.base_url.as_ref()), + Tag::new("path", request.url().path()), + ], + fields: Vec::with_capacity(1), + }; + + // Measure the HTTP request round trip duration + let start = std::time::Instant::now(); + let response = self.http_client.execute(request).await?; + let duration = start.elapsed().as_millis() as u64; + + // Update Metric with response status and request duration + latency + .tags + .push(Tag::new("status_code", response.status().as_str())); + latency.fields.push(Field::new("duration", duration)); + + // Extract Status Code & reqwest::Response Bytes + let status_code = response.status(); + let payload = response.bytes().await?; + + Ok((status_code, payload, latency)) + } +} + +impl<'a, Strategy, Parser> RestClient<'a, Strategy, Parser> { + /// Construct a new [`Self`] using the provided configuration. + pub fn new>>(base_url: Url, strategy: Strategy, parser: Parser) -> Self { + Self { + http_client: reqwest::Client::new(), + base_url: base_url.into(), + strategy, + parser, + } + } +} diff --git a/barter-integration/src/protocol/http/rest/mod.rs b/barter-integration/src/protocol/http/rest/mod.rs new file mode 100644 index 00000000..887b1273 --- /dev/null +++ b/barter-integration/src/protocol/http/rest/mod.rs @@ -0,0 +1,42 @@ +use serde::{de::DeserializeOwned, Serialize}; +use std::time::Duration; + +/// Configurable [`client::RestClient`] capable of executing signed [`RestRequest`]s and parsing +/// responses. +pub mod client; + +/// Default Http [`reqwest::Request`] timeout Duration. +const DEFAULT_HTTP_REQUEST_TIMEOUT: Duration = Duration::from_secs(5); + +/// Http REST request that can be executed by a [`RestClient`](self::client::RestClient). +pub trait RestRequest { + /// Expected response type if this request was successful. + type Response: DeserializeOwned; + + /// Serialisable query parameters type - use unit struct () if not required for this request. + type QueryParams: Serialize; + + /// Serialisable Body type - use unit struct () if not required for this request. + type Body: Serialize; + + /// Additional [`Url`](url::Url) path to the resource. + fn path(&self) -> std::borrow::Cow<'static, str>; + + /// Http [`reqwest::Method`] of this request. + fn method() -> reqwest::Method; + + /// Optional query parameters for this request. + fn query_params(&self) -> Option<&Self::QueryParams> { + None + } + + /// Optional Body for this request. + fn body(&self) -> Option<&Self::Body> { + None + } + + /// Http request timeout [`Duration`]. + fn timeout() -> Duration { + DEFAULT_HTTP_REQUEST_TIMEOUT + } +} diff --git a/barter-integration/src/protocol/mod.rs b/barter-integration/src/protocol/mod.rs new file mode 100644 index 00000000..b103cd15 --- /dev/null +++ b/barter-integration/src/protocol/mod.rs @@ -0,0 +1,25 @@ +use crate::SocketError; +use futures::Stream; +use serde::de::DeserializeOwned; + +/// Contains useful `WebSocket` type aliases and a default `WebSocket` implementation of a +/// [`StreamParser`]. +pub mod websocket; + +/// Contains HTTP client capable of executing signed & unsigned requests, as well as an associated +/// exchange oriented HTTP request. +pub mod http; + +/// `StreamParser`s are capable of parsing the input messages from a given stream protocol +/// (eg/ WebSocket, Financial Information eXchange (FIX), etc.) and deserialising into an `Output`. +pub trait StreamParser { + type Stream: Stream; + type Message; + type Error; + + fn parse( + input: Result, + ) -> Option> + where + Output: DeserializeOwned; +} diff --git a/barter-integration/src/protocol/websocket.rs b/barter-integration/src/protocol/websocket.rs new file mode 100644 index 00000000..72817424 --- /dev/null +++ b/barter-integration/src/protocol/websocket.rs @@ -0,0 +1,158 @@ +use crate::{error::SocketError, protocol::StreamParser}; +use serde::{de::DeserializeOwned, Deserialize, Serialize}; +use std::fmt::Debug; +use tokio::net::TcpStream; +use tokio_tungstenite::{ + connect_async, + tungstenite::{ + client::IntoClientRequest, + error::ProtocolError, + protocol::{frame::Frame, CloseFrame}, + }, + MaybeTlsStream, +}; +use tracing::debug; + +/// Convenient type alias for a tungstenite `WebSocketStream`. +pub type WebSocket = tokio_tungstenite::WebSocketStream>; + +/// Convenient type alias for the `Sink` half of a tungstenite [`WebSocket`]. +pub type WsSink = futures::stream::SplitSink; + +/// Convenient type alias for the `Stream` half of a tungstenite [`WebSocket`]. +pub type WsStream = futures::stream::SplitStream; + +/// Communicative type alias for a tungstenite [`WebSocket`] `Message`. +pub type WsMessage = tokio_tungstenite::tungstenite::Message; + +/// Communicative type alias for a tungstenite [`WebSocket`] `Error`. +pub type WsError = tokio_tungstenite::tungstenite::Error; + +/// Default [`StreamParser`] implementation for a [`WebSocket`]. +#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Deserialize, Serialize)] +pub struct WebSocketParser; + +impl StreamParser for WebSocketParser { + type Stream = WebSocket; + type Message = WsMessage; + type Error = WsError; + + fn parse( + input: Result, + ) -> Option> + where + Output: DeserializeOwned, + { + match input { + Ok(ws_message) => match ws_message { + WsMessage::Text(text) => process_text(text), + WsMessage::Binary(binary) => process_binary(binary), + WsMessage::Ping(ping) => process_ping(ping), + WsMessage::Pong(pong) => process_pong(pong), + WsMessage::Close(close_frame) => process_close_frame(close_frame), + WsMessage::Frame(frame) => process_frame(frame), + }, + Err(ws_err) => Some(Err(SocketError::WebSocket(ws_err))), + } + } +} + +/// Process a payload of `String` by deserialising into an `ExchangeMessage`. +pub fn process_text( + payload: String, +) -> Option> +where + ExchangeMessage: DeserializeOwned, +{ + Some( + serde_json::from_str::(&payload).map_err(|error| { + debug!( + ?error, + ?payload, + action = "returning Some(Err(err))", + "failed to deserialize WebSocket Message into domain specific Message" + ); + SocketError::Deserialise { error, payload } + }), + ) +} + +/// Process a payload of `Vec` bytes by deserialising into an `ExchangeMessage`. +pub fn process_binary( + payload: Vec, +) -> Option> +where + ExchangeMessage: DeserializeOwned, +{ + Some( + serde_json::from_slice::(&payload).map_err(|error| { + debug!( + ?error, + ?payload, + action = "returning Some(Err(err))", + "failed to deserialize WebSocket Message into domain specific Message" + ); + SocketError::Deserialise { + error, + payload: String::from_utf8(payload).unwrap_or_else(|x| x.to_string()), + } + }), + ) +} + +/// Basic process for a [`WebSocket`] ping message. Logs the payload at `trace` level. +pub fn process_ping( + ping: Vec, +) -> Option> { + debug!(payload = ?ping, "received Ping WebSocket message"); + None +} + +/// Basic process for a [`WebSocket`] pong message. Logs the payload at `trace` level. +pub fn process_pong( + pong: Vec, +) -> Option> { + debug!(payload = ?pong, "received Pong WebSocket message"); + None +} + +/// Basic process for a [`WebSocket`] CloseFrame message. Logs the payload at `trace` level. +pub fn process_close_frame( + close_frame: Option>, +) -> Option> { + let close_frame = format!("{:?}", close_frame); + debug!(payload = %close_frame, "received CloseFrame WebSocket message"); + Some(Err(SocketError::Terminated(close_frame))) +} + +/// Basic process for a [`WebSocket`] Frame message. Logs the payload at `trace` level. +pub fn process_frame( + frame: Frame, +) -> Option> { + let frame = format!("{:?}", frame); + debug!(payload = %frame, "received unexpected Frame WebSocket message"); + None +} + +/// Connect asynchronously to a [`WebSocket`] server. +pub async fn connect(request: R) -> Result +where + R: IntoClientRequest + Unpin + Debug, +{ + debug!(?request, "attempting to establish WebSocket connection"); + connect_async(request) + .await + .map(|(websocket, _)| websocket) + .map_err(SocketError::WebSocket) +} + +/// Determine whether a [`WsError`] indicates the [`WebSocket`] has disconnected. +pub fn is_websocket_disconnected(error: &WsError) -> bool { + matches!( + error, + WsError::ConnectionClosed + | WsError::AlreadyClosed + | WsError::Io(_) + | WsError::Protocol(ProtocolError::SendAfterClosing) + ) +} diff --git a/barter-macro/Cargo.toml b/barter-macro/Cargo.toml new file mode 100644 index 00000000..ac531fbe --- /dev/null +++ b/barter-macro/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "barter-macro" +version = "0.1.1" +authors = ["JustAStream"] +edition = "2021" +license = "MIT" +documentation = "https://docs.rs/barter-macro/" +repository = "https://github.com/barter-rs/barter-rs" +description = "Barter ecosystem macros" +keywords = ["trading", "backtesting", "crypto", "stocks", "investment"] +categories = ["accessibility", "simulation"] + +[lib] +proc-macro = true + +[dependencies] +# Macro +proc-macro2 = "1.0.49" +syn = "1.0.107" +quote = "1.0.23" + +# Misc +convert_case = "0.6.0" \ No newline at end of file diff --git a/barter-macro/LICENSE b/barter-macro/LICENSE new file mode 100644 index 00000000..ff764702 --- /dev/null +++ b/barter-macro/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 Barter + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/barter-macro/src/lib.rs b/barter-macro/src/lib.rs new file mode 100644 index 00000000..a257fe06 --- /dev/null +++ b/barter-macro/src/lib.rs @@ -0,0 +1,126 @@ +extern crate proc_macro; + +use convert_case::{Boundary, Case, Casing}; +use proc_macro::TokenStream; +use quote::quote; +use syn::DeriveInput; + +#[proc_macro_derive(DeExchange)] +pub fn de_exchange_derive(input: TokenStream) -> TokenStream { + // Parse Rust code abstract syntax tree with Syn from TokenStream -> DeriveInput + let ast: DeriveInput = + syn::parse(input).expect("de_exchange_derive() failed to parse input TokenStream"); + + // Determine exchange name + let exchange = &ast.ident; + + let generated = quote! { + impl<'de> serde::Deserialize<'de> for #exchange { + fn deserialize(deserializer: D) -> Result + where + D: serde::de::Deserializer<'de> + { + let input = ::deserialize(deserializer)?; + let expected = #exchange::ID.as_str(); + + if input.as_str() == expected { + Ok(Self::default()) + } else { + Err(serde::de::Error::invalid_value( + serde::de::Unexpected::Str(input.as_str()), + &expected + )) + } + } + } + }; + + TokenStream::from(generated) +} + +#[proc_macro_derive(SerExchange)] +pub fn ser_exchange_derive(input: TokenStream) -> TokenStream { + // Parse Rust code abstract syntax tree with Syn from TokenStream -> DeriveInput + let ast: DeriveInput = + syn::parse(input).expect("ser_exchange_derive() failed to parse input TokenStream"); + + // Determine Exchange + let exchange = &ast.ident; + + let generated = quote! { + impl serde::Serialize for #exchange { + fn serialize(&self, serializer: S) -> Result + where + S: serde::ser::Serializer, + { + let exchange_id = #exchange::ID.as_str(); + serializer.serialize_str(exchange_id) + } + } + }; + + TokenStream::from(generated) +} + +#[proc_macro_derive(DeSubKind)] +pub fn de_sub_kind_derive(input: TokenStream) -> TokenStream { + // Parse Rust code abstract syntax tree with Syn from TokenStream -> DeriveInput + let ast: DeriveInput = + syn::parse(input).expect("de_sub_kind_derive() failed to parse input TokenStream"); + + // Determine SubKind name + let sub_kind = &ast.ident; + + let expected_sub_kind = sub_kind + .to_string() + .from_case(Case::Pascal) + .without_boundaries(&Boundary::letter_digit()) + .to_case(Case::Snake); + + let generated = quote! { + impl<'de> serde::Deserialize<'de> for #sub_kind { + fn deserialize(deserializer: D) -> Result + where + D: serde::de::Deserializer<'de> + { + let input = ::deserialize(deserializer)?; + + if input == #expected_sub_kind { + Ok(Self) + } else { + Err(serde::de::Error::invalid_value( + serde::de::Unexpected::Str(input.as_str()), + &#expected_sub_kind + )) + } + } + } + }; + + TokenStream::from(generated) +} + +#[proc_macro_derive(SerSubKind)] +pub fn ser_sub_kind_derive(input: TokenStream) -> TokenStream { + // Parse Rust code abstract syntax tree with Syn from TokenStream -> DeriveInput + let ast: DeriveInput = + syn::parse(input).expect("ser_sub_kind_derive() failed to parse input TokenStream"); + + // Determine SubKind name + let sub_kind = &ast.ident; + let sub_kind_string = sub_kind.to_string().to_case(Case::Snake); + let sub_kind_str = sub_kind_string.as_str(); + + let generated = quote! { + impl serde::Serialize for #sub_kind { + fn serialize(&self, serializer: S) -> Result + where + S: serde::ser::Serializer, + { + serializer.serialize_str(#sub_kind_str) + } + } + }; + + TokenStream::from(generated) +} diff --git a/barter/Cargo.toml b/barter/Cargo.toml index 72865d70..78d76910 100644 --- a/barter/Cargo.toml +++ b/barter/Cargo.toml @@ -15,8 +15,8 @@ categories = ["accessibility", "simulation"] [dependencies] # Barter Ecosystem -barter-data = "0.7.1" -barter-integration = "0.7.2" +barter-data = "0.7.1" # Todo: update barter-rs to use latest barter-data-rs, and then add path = "../.." +barter-integration = "0.7.3" # Logging tracing = { workspace = true } @@ -25,6 +25,7 @@ tracing = { workspace = true } tokio = { workspace = true, features = ["sync"] } tokio-stream = { workspace = true, features = ["sync"] } futures = { workspace = true } +async-trait = { workspace = true } # Error thiserror = { workspace = true } @@ -37,7 +38,7 @@ serde_json = { workspace = true } redis = "0.25.4" # Strategy -ta = "0.5.0" +ta = { workspace = true } # Misc uuid = { workspace = true, features = ["v4", "serde"] } diff --git a/barter/src/engine/trader.rs b/barter/src/engine/trader.rs index 335f7745..d05b8be3 100644 --- a/barter/src/engine/trader.rs +++ b/barter/src/engine/trader.rs @@ -114,7 +114,7 @@ where data: lego.data, strategy: lego.strategy, execution: lego.execution, - _statistic_marker: PhantomData::default(), + _statistic_marker: PhantomData, } } @@ -391,7 +391,7 @@ where execution: self .execution .ok_or(EngineError::BuilderIncomplete("execution"))?, - _statistic_marker: PhantomData::default(), + _statistic_marker: PhantomData, }) } } diff --git a/barter/src/portfolio/portfolio.rs b/barter/src/portfolio/portfolio.rs index 54e2488c..04374fbd 100644 --- a/barter/src/portfolio/portfolio.rs +++ b/barter/src/portfolio/portfolio.rs @@ -342,7 +342,7 @@ where repository: lego.repository, allocation_manager: lego.allocator, risk_manager: lego.risk, - _statistic_marker: PhantomData::default(), + _statistic_marker: PhantomData, }; // Persist initial state in the repository @@ -500,7 +500,7 @@ where risk_manager: self .risk_manager .ok_or(PortfolioError::BuilderIncomplete("risk_manager"))?, - _statistic_marker: PhantomData::default(), + _statistic_marker: PhantomData, }; // Persist initial state in the Repository diff --git a/barter/src/portfolio/repository/in_memory.rs b/barter/src/portfolio/repository/in_memory.rs index a18c45c8..b49d3cb4 100644 --- a/barter/src/portfolio/repository/in_memory.rs +++ b/barter/src/portfolio/repository/in_memory.rs @@ -36,7 +36,7 @@ impl PositionHandler for InMemoryRepository Result, RepositoryError> { - Ok(self.open_positions.get(position_id).map(Position::clone)) + Ok(self.open_positions.get(position_id).cloned()) } fn get_open_positions<'a, Markets: Iterator>( @@ -52,7 +52,7 @@ impl PositionHandler for InMemoryRepository PositionHandler for InMemoryRepository Self { Self { conn: connection, - _statistic_marker: PhantomData::::default(), + _statistic_marker: PhantomData, } } @@ -215,7 +215,7 @@ where pub fn new() -> Self { Self { conn: None, - _statistic_marker: PhantomData::::default(), + _statistic_marker: PhantomData, } } @@ -229,7 +229,7 @@ where pub fn build(self) -> Result, PortfolioError> { Ok(RedisRepository { conn: self.conn.ok_or(PortfolioError::BuilderIncomplete("conn"))?, - _statistic_marker: PhantomData::::default(), + _statistic_marker: PhantomData, }) } } From ba8e95ee83e022b466a9a65541040123ab4edbb5 Mon Sep 17 00:00:00 2001 From: Keir Date: Thu, 11 Jul 2024 17:09:25 +0100 Subject: [PATCH 04/10] chore: update barter-rs to use latest barter-data v0.8.1 --- barter/Cargo.toml | 4 ++-- barter/examples/engine_with_historic_candles.rs | 2 +- barter/examples/engine_with_live_trades.rs | 8 ++++++-- barter/src/engine/mod.rs | 12 ++++++------ barter/src/engine/trader.rs | 12 ++++++------ barter/src/event.rs | 3 ++- barter/src/lib.rs | 4 ++-- barter/src/portfolio/mod.rs | 2 +- barter/src/portfolio/portfolio.rs | 4 ++-- barter/src/portfolio/position.rs | 4 ++-- barter/src/strategy/example.rs | 3 ++- barter/src/strategy/mod.rs | 2 +- 12 files changed, 33 insertions(+), 27 deletions(-) diff --git a/barter/Cargo.toml b/barter/Cargo.toml index 78d76910..8cbac263 100644 --- a/barter/Cargo.toml +++ b/barter/Cargo.toml @@ -15,8 +15,8 @@ categories = ["accessibility", "simulation"] [dependencies] # Barter Ecosystem -barter-data = "0.7.1" # Todo: update barter-rs to use latest barter-data-rs, and then add path = "../.." -barter-integration = "0.7.3" +barter-data = { path = "../barter-data", version = "0.8.1"} +barter-integration = { path = "../barter-integration", version = "0.7.3" } # Logging tracing = { workspace = true } diff --git a/barter/examples/engine_with_historic_candles.rs b/barter/examples/engine_with_historic_candles.rs index 9d060072..33e8bda8 100644 --- a/barter/examples/engine_with_historic_candles.rs +++ b/barter/examples/engine_with_historic_candles.rs @@ -118,7 +118,7 @@ async fn main() { engine.run().await; } -fn load_json_market_event_candles() -> Vec> { +fn load_json_market_event_candles() -> Vec> { let candles = fs::read_to_string(DATA_HISTORIC_CANDLES_1H).expect("failed to read file"); let candles = diff --git a/barter/examples/engine_with_live_trades.rs b/barter/examples/engine_with_live_trades.rs index 090cfed6..c1e8510c 100644 --- a/barter/examples/engine_with_live_trades.rs +++ b/barter/examples/engine_with_live_trades.rs @@ -22,7 +22,10 @@ use barter_data::{ streams::Streams, subscription::trade::PublicTrades, }; -use barter_integration::model::{instrument::kind::InstrumentKind, Market}; +use barter_integration::model::{ + instrument::{kind::InstrumentKind, Instrument}, + Market, +}; use parking_lot::Mutex; use std::{collections::HashMap, sync::Arc, time::Duration}; use tokio::sync::mpsc; @@ -115,7 +118,8 @@ async fn main() { let _ = tokio::time::timeout(ENGINE_RUN_TIMEOUT, engine.run()).await; } -async fn stream_market_event_trades() -> mpsc::UnboundedReceiver> { +async fn stream_market_event_trades() -> mpsc::UnboundedReceiver> +{ // Initialise PublicTrades Streams for BinanceSpot // '--> each call to StreamBuilder::subscribe() creates a separate WebSocket connection let mut streams = Streams::::builder() diff --git a/barter/src/engine/mod.rs b/barter/src/engine/mod.rs index 08c1603b..2c19f9d1 100644 --- a/barter/src/engine/mod.rs +++ b/barter/src/engine/mod.rs @@ -12,7 +12,7 @@ use crate::{ strategy::SignalGenerator, }; use barter_data::event::{DataKind, MarketEvent}; -use barter_integration::model::{Market, MarketId}; +use barter_integration::model::{instrument::Instrument, Market, MarketId}; use parking_lot::Mutex; use prettytable::Table; use serde::Serialize; @@ -54,7 +54,7 @@ where EventTx: MessageTransmitter + Send, Statistic: Serialize + Send, Portfolio: MarketUpdater + OrderGenerator + FillUpdater + Send, - Data: MarketGenerator> + Send, + Data: MarketGenerator> + Send, Strategy: SignalGenerator + Send, Execution: ExecutionClient + Send, { @@ -94,7 +94,7 @@ where + FillUpdater + Send + 'static, - Data: MarketGenerator> + Send + 'static, + Data: MarketGenerator> + Send + 'static, Strategy: SignalGenerator + Send, Execution: ExecutionClient + Send, { @@ -128,7 +128,7 @@ where + FillUpdater + Send + 'static, - Data: MarketGenerator> + Send, + Data: MarketGenerator> + Send, Strategy: SignalGenerator + Send + 'static, Execution: ExecutionClient + Send + 'static, { @@ -363,7 +363,7 @@ where EventTx: MessageTransmitter, Statistic: Serialize + Send, Portfolio: MarketUpdater + OrderGenerator + FillUpdater + Send, - Data: MarketGenerator> + Send, + Data: MarketGenerator> + Send, Strategy: SignalGenerator + Send, Execution: ExecutionClient + Send, { @@ -386,7 +386,7 @@ where + OrderGenerator + FillUpdater + Send, - Data: MarketGenerator> + Send, + Data: MarketGenerator> + Send, Strategy: SignalGenerator + Send, Execution: ExecutionClient + Send, { diff --git a/barter/src/engine/trader.rs b/barter/src/engine/trader.rs index d05b8be3..5ae25ae6 100644 --- a/barter/src/engine/trader.rs +++ b/barter/src/engine/trader.rs @@ -7,7 +7,7 @@ use crate::{ strategy::{SignalForceExit, SignalGenerator}, }; use barter_data::event::{DataKind, MarketEvent}; -use barter_integration::model::Market; +use barter_integration::model::{instrument::Instrument, Market}; use parking_lot::Mutex; use serde::Serialize; use std::{collections::VecDeque, fmt::Debug, marker::PhantomData, sync::Arc}; @@ -22,7 +22,7 @@ where EventTx: MessageTransmitter, Statistic: Serialize + Send, Portfolio: MarketUpdater + OrderGenerator + FillUpdater, - Data: MarketGenerator>, + Data: MarketGenerator>, Strategy: SignalGenerator, Execution: ExecutionClient, { @@ -58,7 +58,7 @@ where EventTx: MessageTransmitter, Statistic: Serialize + Send, Portfolio: MarketUpdater + OrderGenerator + FillUpdater, - Data: MarketGenerator> + Send, + Data: MarketGenerator> + Send, Strategy: SignalGenerator + Send, Execution: ExecutionClient + Send, { @@ -92,7 +92,7 @@ where EventTx: MessageTransmitter, Statistic: Serialize + Send, Portfolio: MarketUpdater + OrderGenerator + FillUpdater, - Data: MarketGenerator> + Send, + Data: MarketGenerator> + Send, Strategy: SignalGenerator + Send, Execution: ExecutionClient + Send, { @@ -269,7 +269,7 @@ where EventTx: MessageTransmitter, Statistic: Serialize + Send, Portfolio: MarketUpdater + OrderGenerator + FillUpdater, - Data: MarketGenerator>, + Data: MarketGenerator>, Strategy: SignalGenerator, Execution: ExecutionClient, { @@ -290,7 +290,7 @@ where EventTx: MessageTransmitter, Statistic: Serialize + Send, Portfolio: MarketUpdater + OrderGenerator + FillUpdater, - Data: MarketGenerator> + Send, + Data: MarketGenerator> + Send, Strategy: SignalGenerator + Send, Execution: ExecutionClient + Send, { diff --git a/barter/src/event.rs b/barter/src/event.rs index ad855440..ddcede1c 100644 --- a/barter/src/event.rs +++ b/barter/src/event.rs @@ -7,6 +7,7 @@ use crate::{ strategy::{Signal, SignalForceExit}, }; use barter_data::event::{DataKind, MarketEvent}; +use barter_integration::model::instrument::Instrument; use serde::{Deserialize, Serialize}; use std::fmt::Debug; use tokio::sync::mpsc; @@ -18,7 +19,7 @@ use tracing::warn; /// system, and is useful for analysing performance & reconciliations. #[derive(Clone, PartialEq, Debug, Serialize, Deserialize)] pub enum Event { - Market(MarketEvent), + Market(MarketEvent), Signal(Signal), SignalForceExit(SignalForceExit), OrderNew(OrderEvent), diff --git a/barter/src/lib.rs b/barter/src/lib.rs index 054409ed..e86321be 100644 --- a/barter/src/lib.rs +++ b/barter/src/lib.rs @@ -265,7 +265,7 @@ pub mod test_util { use std::ops::Add; /// Build a [`MarketEvent`] of [`DataKind::PublicTrade`](DataKind) with the provided [`Side`]. - pub fn market_event_trade(side: Side) -> MarketEvent { + pub fn market_event_trade(side: Side) -> MarketEvent { MarketEvent { exchange_time: Utc::now(), received_time: Utc::now(), @@ -281,7 +281,7 @@ pub mod test_util { } /// Build a [`MarketEvent`] of [`DataKind::Candle`](DataKind). - pub fn market_event_candle() -> MarketEvent { + pub fn market_event_candle() -> MarketEvent { let now = Utc::now(); MarketEvent { exchange_time: now, diff --git a/barter/src/portfolio/mod.rs b/barter/src/portfolio/mod.rs index d8d09fe7..2da34119 100644 --- a/barter/src/portfolio/mod.rs +++ b/barter/src/portfolio/mod.rs @@ -39,7 +39,7 @@ pub trait MarketUpdater { /// changes. fn update_from_market( &mut self, - market: &MarketEvent, + market: &MarketEvent, ) -> Result, PortfolioError>; } diff --git a/barter/src/portfolio/portfolio.rs b/barter/src/portfolio/portfolio.rs index 04374fbd..fc76b507 100644 --- a/barter/src/portfolio/portfolio.rs +++ b/barter/src/portfolio/portfolio.rs @@ -17,7 +17,7 @@ use crate::{ strategy::{Decision, Signal, SignalForceExit, SignalStrength}, }; use barter_data::event::{DataKind, MarketEvent}; -use barter_integration::model::{Market, MarketId, Side}; +use barter_integration::model::{instrument::Instrument, Market, MarketId, Side}; use chrono::Utc; use serde::Serialize; use std::{collections::HashMap, marker::PhantomData}; @@ -86,7 +86,7 @@ where { fn update_from_market( &mut self, - market: &MarketEvent, + market: &MarketEvent, ) -> Result, PortfolioError> { // Determine the position_id associated to the input MarketEvent let position_id = diff --git a/barter/src/portfolio/position.rs b/barter/src/portfolio/position.rs index 49594526..4d9a92ff 100644 --- a/barter/src/portfolio/position.rs +++ b/barter/src/portfolio/position.rs @@ -20,7 +20,7 @@ pub trait PositionEnterer { pub trait PositionUpdater { /// Updates an open [`Position`] using the latest input [`MarketEvent`], returning a /// [`PositionUpdate`] that communicates the open [`Position`]'s change in state. - fn update(&mut self, market: &MarketEvent) -> Option; + fn update(&mut self, market: &MarketEvent) -> Option; } /// Exits an open [`Position`]. @@ -146,7 +146,7 @@ impl PositionEnterer for Position { } impl PositionUpdater for Position { - fn update(&mut self, market: &MarketEvent) -> Option { + fn update(&mut self, market: &MarketEvent) -> Option { // Determine close from MarketEvent let close = match &market.kind { DataKind::Trade(trade) => trade.price, diff --git a/barter/src/strategy/example.rs b/barter/src/strategy/example.rs index 18224f5b..4df5404d 100644 --- a/barter/src/strategy/example.rs +++ b/barter/src/strategy/example.rs @@ -1,6 +1,7 @@ use super::{Decision, Signal, SignalGenerator, SignalStrength}; use crate::data::MarketMeta; use barter_data::event::{DataKind, MarketEvent}; +use barter_integration::model::instrument::Instrument; use chrono::Utc; use serde::{Deserialize, Serialize}; use std::collections::HashMap; @@ -19,7 +20,7 @@ pub struct RSIStrategy { } impl SignalGenerator for RSIStrategy { - fn generate_signal(&mut self, market: &MarketEvent) -> Option { + fn generate_signal(&mut self, market: &MarketEvent) -> Option { // Check if it's a MarketEvent with a candle let candle_close = match &market.kind { DataKind::Candle(candle) => candle.close, diff --git a/barter/src/strategy/mod.rs b/barter/src/strategy/mod.rs index 39197117..be620641 100644 --- a/barter/src/strategy/mod.rs +++ b/barter/src/strategy/mod.rs @@ -11,7 +11,7 @@ pub mod example; /// May generate an advisory [`Signal`] as a result of analysing an input [`MarketEvent`]. pub trait SignalGenerator { /// Optionally return a [`Signal`] given input [`MarketEvent`]. - fn generate_signal(&mut self, market: &MarketEvent) -> Option; + fn generate_signal(&mut self, market: &MarketEvent) -> Option; } /// Advisory [`Signal`] for a [`Market`] detailing the [`SignalStrength`] associated with each From a09d8c3b858bd266d849f5b1b83e7b53c88d5258 Mon Sep 17 00:00:00 2001 From: Keir Date: Thu, 11 Jul 2024 18:29:58 +0100 Subject: [PATCH 05/10] chore: barter workspace readme.md --- LICENSE | 21 ++++++++++++++++++ README.md | 64 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+) create mode 100644 LICENSE create mode 100644 README.md diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..f5b4bf8c --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Barter Ecosystem Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 00000000..1a86a027 --- /dev/null +++ b/README.md @@ -0,0 +1,64 @@ +# Barter Ecosystem +Barter is an open-source ecosystem of Rust libraries for building live-trading and back-testing systems. It is made up +of several easy-to-use, extensible crates: +* **Barter**: Framework for building event-driven live-trading and back-testing systems. Algorithmic trade with the +peace of mind that comes from knowing your strategies have been back-tested with a near-identical trading engine. +* **Barter-Data**: High-performance WebSocket integration library for streaming public market data from leading +cryptocurrency exchanges - batteries included. +* **Barter-Execution**: Feature rich simulated exchange to assist with back-testing and dry-trading. Also provides a +normalised trading interface capable of executing across different financial venues. +* **Barter-Integration**: Low-level frameworks for flexible REST/WebSocket integrations. +* **Barter-Macro**: Barter ecosystem macros. + +**See: [`Barter`], [`Barter-Data`], [`Barter-Integration`], [`Barter-Execution`] & [`Barter-Macro`] for more +comprehensive documentation.** + +[![Crates.io][crates-badge]][crates-url] +[![MIT licensed][mit-badge]][mit-url] +[![Build Status][actions-badge]][actions-url] +[![Discord chat][discord-badge]][discord-url] + +[crates-badge]: https://img.shields.io/crates/v/barter.svg +[crates-url]: https://crates.io/crates/barter + +[mit-badge]: https://img.shields.io/badge/license-MIT-blue.svg +[mit-url]: https://github.com/barter-rs/barter-rs/blob/develop/LICENSE + +[actions-badge]: https://gitlab.com/open-source-keir/financial-modelling/trading/barter-data-rs/badges/-/blob/main/pipeline.svg +[actions-url]: https://gitlab.com/open-source-keir/financial-modelling/trading/barter-data-rs/-/commits/main + +[discord-badge]: https://img.shields.io/discord/910237311332151317.svg?logo=discord&style=flat-square +[discord-url]: https://discord.gg/wE7RqhnQMV + +[`Barter`]: https://crates.io/crates/barter +[`Barter-Data`]: https://crates.io/crates/barter-data +[`Barter-Integration`]: https://crates.io/crates/barter-integration +[`Barter-Execution`]: https://crates.io/crates/barter-execution +[`Barter-Macro`]: https://crates.io/crates/barter-macro +[API Documentation]: https://docs.rs/barter/latest/barter/ +[Chat]: https://discord.gg/wE7RqhnQMV + +## Getting Help +Firstly, see if the answer to your question can be found in the [API Documentation]. If the answer is not there, I'd be +happy to help via [Chat] and try answer your question via Discord. + +## Contributing +Thanks in advance for helping to develop the Barter ecosystem! Please do get hesitate to get touch via the Discord +[Chat] to discuss development, new features, and the future roadmap. + +## Licence +This project is licensed under the [MIT license]. + +[MIT license]: https://github.com/barter-rs/barter-rs/blob/develop/LICENSE + +### Contribution +Unless you explicitly state otherwise, any contribution intentionally submitted +for inclusion in one of the Barter workspace crates by you, shall be licensed as MIT, without any additional +terms or conditions. + +## Disclaimer + +This software is for educational purposes only. Do not risk money which +you are afraid to lose. USE THE SOFTWARE AT YOUR OWN RISK. THE AUTHORS +AND ALL AFFILIATES ASSUME NO RESPONSIBILITY FOR YOUR TRADING RESULTS. + From dae29379a3ae6a07e607d40ea65128cf5aa53e62 Mon Sep 17 00:00:00 2001 From: Keir Date: Fri, 12 Jul 2024 21:37:07 +0100 Subject: [PATCH 06/10] chore: increment barter crate to 0.8.15 --- barter/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/barter/Cargo.toml b/barter/Cargo.toml index 8cbac263..6859c2b4 100644 --- a/barter/Cargo.toml +++ b/barter/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "barter" -version = "0.8.14" +version = "0.8.15" authors = ["Just A Stream <93921983+just-a-stream@users.noreply.github.com>"] edition = "2021" license = "MIT" From 6b7480bedb32ce13d675ffbb21e35dc435349080 Mon Sep 17 00:00:00 2001 From: jordy25519 Date: Fri, 19 Jul 2024 11:09:51 +0800 Subject: [PATCH 07/10] Add coinbase l1/ticker channel --- barter-data/src/exchange/coinbase/book/l1.rs | 127 ++++++++++++++++++ barter-data/src/exchange/coinbase/book/mod.rs | 1 + barter-data/src/exchange/coinbase/channel.rs | 14 +- barter-data/src/exchange/coinbase/mod.rs | 15 ++- 4 files changed, 155 insertions(+), 2 deletions(-) create mode 100644 barter-data/src/exchange/coinbase/book/l1.rs create mode 100644 barter-data/src/exchange/coinbase/book/mod.rs diff --git a/barter-data/src/exchange/coinbase/book/l1.rs b/barter-data/src/exchange/coinbase/book/l1.rs new file mode 100644 index 00000000..0113a223 --- /dev/null +++ b/barter-data/src/exchange/coinbase/book/l1.rs @@ -0,0 +1,127 @@ +use crate::{ + event::{MarketEvent, MarketIter}, + exchange::{coinbase::channel::CoinbaseChannel, subscription::ExchangeSub, ExchangeId}, + subscription::book::{Level, OrderBookL1}, + Identifier, +}; +use barter_integration::model::{Exchange, SubscriptionId}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, PartialEq, PartialOrd, Debug, Deserialize, Serialize)] +pub struct CoinbaseOrderBookL1 { + #[serde(rename(deserialize = "time"))] + pub time: DateTime, + #[serde(rename(deserialize = "type"))] + pub kind: String, + pub sequence: u64, + #[serde(rename(deserialize = "product_id"), deserialize_with = "de_ob_l1_subscription_id")] + pub subscription_id: SubscriptionId, + #[serde(rename(deserialize = "best_bid"), deserialize_with = "barter_integration::de::de_str")] + pub best_bid_price: f64, + #[serde(rename(deserialize = "best_bid_size"), deserialize_with = "barter_integration::de::de_str")] + pub best_bid_amount: f64, + #[serde(rename(deserialize = "best_ask"), deserialize_with = "barter_integration::de::de_str")] + pub best_ask_price: f64, + #[serde(rename(deserialize = "best_ask_size"), deserialize_with = "barter_integration::de::de_str")] + pub best_ask_amount: f64, +} + +impl Identifier> for CoinbaseOrderBookL1 { + fn id(&self) -> Option { + Some(self.subscription_id.clone()) + } +} + +impl From<(ExchangeId, InstrumentId, CoinbaseOrderBookL1)> + for MarketIter +{ + fn from( + (exchange_id, instrument, book): (ExchangeId, InstrumentId, CoinbaseOrderBookL1), + ) -> Self { + Self(vec![Ok(MarketEvent { + exchange_time: book.time, + received_time: Utc::now(), + exchange: Exchange::from(exchange_id), + instrument, + kind: OrderBookL1 { + last_update_time: book.time, + best_bid: Level::new(book.best_bid_price, book.best_bid_amount), + best_ask: Level::new(book.best_ask_price, book.best_ask_amount), + }, + })]) + } +} + +/// Deserialize a [`BinanceOrderBookL1`] "s" (eg/ "BTCUSDT") as the associated [`SubscriptionId`]. +/// +/// eg/ "@bookTicker|BTCUSDT" +pub fn de_ob_l1_subscription_id<'de, D>(deserializer: D) -> Result +where + D: serde::de::Deserializer<'de>, +{ + <&str as Deserialize>::deserialize(deserializer) + .map(|market| ExchangeSub::from((CoinbaseChannel::ORDER_BOOK_L1, market)).id()) +} + +#[cfg(test)] +mod tests { + use super::*; + + mod de { + use super::*; + + #[test] + fn test_coinbase_order_book_l1() { + struct TestCase { + input: &'static str, + expected: CoinbaseOrderBookL1, + } + + let time = Utc::now(); + + let tests = vec![ + TestCase { + // TC0: valid Spot CoinbaseOrderBookL1 + input: r#" + { + "type": "ticker", + "sequence": 37475248783, + "product_id": "ETH-USD", + "price": "1285.22", + "open_24h": "1310.79", + "volume_24h": "245532.79269678", + "low_24h": "1280.52", + "high_24h": "1313.8", + "volume_30d": "9788783.60117027", + "best_bid": "1285.04", + "best_bid_size": "0.46688654", + "best_ask": "1285.27", + "best_ask_size": "1.56637040", + "side": "buy", + "time": "2022-10-19T23:28:22.061769Z", + "trade_id": 370843401, + "last_size": "11.4396987" + } + "#, + expected: CoinbaseOrderBookL1 { + kind: "ticker".into(), + sequence: 37475248783, + subscription_id: SubscriptionId::from("ticker|ETH-USD"), + time, + best_bid_price: 1285.04, + best_bid_amount: 0.46688654, + best_ask_price: 1285.27, + best_ask_amount: 1.56637040, + }, + }, + ]; + + for (index, test) in tests.into_iter().enumerate() { + let actual = serde_json::from_str::(test.input).unwrap(); + let actual = CoinbaseOrderBookL1 { time, ..actual }; + assert_eq!(actual, test.expected, "TC{} failed", index); + } + } + } +} diff --git a/barter-data/src/exchange/coinbase/book/mod.rs b/barter-data/src/exchange/coinbase/book/mod.rs new file mode 100644 index 00000000..ad987b6e --- /dev/null +++ b/barter-data/src/exchange/coinbase/book/mod.rs @@ -0,0 +1 @@ +pub mod l1; \ No newline at end of file diff --git a/barter-data/src/exchange/coinbase/channel.rs b/barter-data/src/exchange/coinbase/channel.rs index ffe922ce..f75afa08 100644 --- a/barter-data/src/exchange/coinbase/channel.rs +++ b/barter-data/src/exchange/coinbase/channel.rs @@ -1,6 +1,6 @@ use super::Coinbase; use crate::{ - subscription::{trade::PublicTrades, Subscription}, + subscription::{book::OrderBooksL1, trade::PublicTrades, Subscription}, Identifier, }; use serde::Serialize; @@ -17,6 +17,10 @@ impl CoinbaseChannel { /// /// See docs: pub const TRADES: Self = Self("matches"); + /// [`Coinbase`] real-time L1 orderbook channel. + /// + /// See docs: + pub const ORDER_BOOK_L1: Self = Self("ticker"); } impl Identifier for Subscription { @@ -25,6 +29,14 @@ impl Identifier for Subscription Identifier + for Subscription +{ + fn id(&self) -> CoinbaseChannel { + CoinbaseChannel::ORDER_BOOK_L1 + } +} + impl AsRef for CoinbaseChannel { fn as_ref(&self) -> &str { self.0 diff --git a/barter-data/src/exchange/coinbase/mod.rs b/barter-data/src/exchange/coinbase/mod.rs index 01cf183e..81a3c690 100644 --- a/barter-data/src/exchange/coinbase/mod.rs +++ b/barter-data/src/exchange/coinbase/mod.rs @@ -6,15 +6,18 @@ use crate::{ exchange::{Connector, ExchangeId, ExchangeSub, StreamSelector}, instrument::InstrumentData, subscriber::{validator::WebSocketSubValidator, WebSocketSubscriber}, - subscription::trade::PublicTrades, + subscription::{book::OrderBooksL1, trade::PublicTrades}, transformer::stateless::StatelessTransformer, ExchangeWsStream, }; use barter_integration::{error::SocketError, protocol::websocket::WsMessage}; use barter_macro::{DeExchange, SerExchange}; +use book::l1::CoinbaseOrderBookL1; use serde_json::json; use url::Url; +pub mod book; + /// Defines the type that translates a Barter [`Subscription`](crate::subscription::Subscription) /// into an exchange [`Connector`] specific channel used for generating [`Connector::requests`]. pub mod channel; @@ -79,3 +82,13 @@ where type Stream = ExchangeWsStream>; } + + +impl StreamSelector for Coinbase +where + Instrument: InstrumentData, +{ + type Stream = ExchangeWsStream< + StatelessTransformer, + >; +} From c264a3b7aba9895635c8f1706a5e5897938b6679 Mon Sep 17 00:00:00 2001 From: jordy25519 Date: Fri, 19 Jul 2024 11:19:24 +0800 Subject: [PATCH 08/10] docs and format --- barter-data/src/exchange/coinbase/book/l1.rs | 191 +++++++++++------- barter-data/src/exchange/coinbase/book/mod.rs | 2 +- barter-data/src/exchange/coinbase/channel.rs | 6 +- barter-data/src/exchange/coinbase/mod.rs | 2 +- 4 files changed, 119 insertions(+), 82 deletions(-) diff --git a/barter-data/src/exchange/coinbase/book/l1.rs b/barter-data/src/exchange/coinbase/book/l1.rs index 0113a223..d1f13b91 100644 --- a/barter-data/src/exchange/coinbase/book/l1.rs +++ b/barter-data/src/exchange/coinbase/book/l1.rs @@ -1,89 +1,129 @@ use crate::{ - event::{MarketEvent, MarketIter}, - exchange::{coinbase::channel::CoinbaseChannel, subscription::ExchangeSub, ExchangeId}, - subscription::book::{Level, OrderBookL1}, - Identifier, + event::{MarketEvent, MarketIter}, + exchange::{coinbase::channel::CoinbaseChannel, subscription::ExchangeSub, ExchangeId}, + subscription::book::{Level, OrderBookL1}, + Identifier, }; use barter_integration::model::{Exchange, SubscriptionId}; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; +/// [`Coinbase`](super::super::Coinbase) real-time OrderBook Level1 (top of book) message. +/// +/// ### Raw Payload Examples +/// #### Coinbase OrderBookL1 +/// See docs: +/// ```json +///{ +/// "type": "ticker", +/// "sequence": 37475248783, +/// "product_id": "ETH-USD", +/// "price": "1285.22", +/// "open_24h": "1310.79", +/// "volume_24h": "245532.79269678", +/// "low_24h": "1280.52", +/// "high_24h": "1313.8", +/// "volume_30d": "9788783.60117027", +/// "best_bid": "1285.04", +/// "best_bid_size": "0.46688654", +/// "best_ask": "1285.27", +/// "best_ask_size": "1.56637040", +/// "side": "buy", +/// "time": "2022-10-19T23:28:22.061769Z", +/// "trade_id": 370843401, +/// "last_size": "11.4396987" +///} +/// ``` #[derive(Clone, PartialEq, PartialOrd, Debug, Deserialize, Serialize)] pub struct CoinbaseOrderBookL1 { - #[serde(rename(deserialize = "time"))] - pub time: DateTime, - #[serde(rename(deserialize = "type"))] - pub kind: String, - pub sequence: u64, - #[serde(rename(deserialize = "product_id"), deserialize_with = "de_ob_l1_subscription_id")] - pub subscription_id: SubscriptionId, - #[serde(rename(deserialize = "best_bid"), deserialize_with = "barter_integration::de::de_str")] - pub best_bid_price: f64, - #[serde(rename(deserialize = "best_bid_size"), deserialize_with = "barter_integration::de::de_str")] - pub best_bid_amount: f64, - #[serde(rename(deserialize = "best_ask"), deserialize_with = "barter_integration::de::de_str")] - pub best_ask_price: f64, - #[serde(rename(deserialize = "best_ask_size"), deserialize_with = "barter_integration::de::de_str")] - pub best_ask_amount: f64, + #[serde(rename(deserialize = "time"))] + pub time: DateTime, + #[serde(rename(deserialize = "type"))] + pub kind: String, + pub sequence: u64, + #[serde( + rename(deserialize = "product_id"), + deserialize_with = "de_ob_l1_subscription_id" + )] + pub subscription_id: SubscriptionId, + #[serde( + rename(deserialize = "best_bid"), + deserialize_with = "barter_integration::de::de_str" + )] + pub best_bid_price: f64, + #[serde( + rename(deserialize = "best_bid_size"), + deserialize_with = "barter_integration::de::de_str" + )] + pub best_bid_amount: f64, + #[serde( + rename(deserialize = "best_ask"), + deserialize_with = "barter_integration::de::de_str" + )] + pub best_ask_price: f64, + #[serde( + rename(deserialize = "best_ask_size"), + deserialize_with = "barter_integration::de::de_str" + )] + pub best_ask_amount: f64, } impl Identifier> for CoinbaseOrderBookL1 { - fn id(&self) -> Option { - Some(self.subscription_id.clone()) - } + fn id(&self) -> Option { + Some(self.subscription_id.clone()) + } } impl From<(ExchangeId, InstrumentId, CoinbaseOrderBookL1)> - for MarketIter + for MarketIter { - fn from( - (exchange_id, instrument, book): (ExchangeId, InstrumentId, CoinbaseOrderBookL1), - ) -> Self { - Self(vec![Ok(MarketEvent { - exchange_time: book.time, - received_time: Utc::now(), - exchange: Exchange::from(exchange_id), - instrument, - kind: OrderBookL1 { - last_update_time: book.time, - best_bid: Level::new(book.best_bid_price, book.best_bid_amount), - best_ask: Level::new(book.best_ask_price, book.best_ask_amount), - }, - })]) - } + fn from( + (exchange_id, instrument, book): (ExchangeId, InstrumentId, CoinbaseOrderBookL1), + ) -> Self { + Self(vec![Ok(MarketEvent { + exchange_time: book.time, + received_time: Utc::now(), + exchange: Exchange::from(exchange_id), + instrument, + kind: OrderBookL1 { + last_update_time: book.time, + best_bid: Level::new(book.best_bid_price, book.best_bid_amount), + best_ask: Level::new(book.best_ask_price, book.best_ask_amount), + }, + })]) + } } -/// Deserialize a [`BinanceOrderBookL1`] "s" (eg/ "BTCUSDT") as the associated [`SubscriptionId`]. +/// Deserialize a [`CoinbaseOrderBookL1`] "s" (eg/ "BTCUSDT") as the associated [`SubscriptionId`]. /// -/// eg/ "@bookTicker|BTCUSDT" +/// eg/ "ticker|BTC-USD" pub fn de_ob_l1_subscription_id<'de, D>(deserializer: D) -> Result where - D: serde::de::Deserializer<'de>, + D: serde::de::Deserializer<'de>, { - <&str as Deserialize>::deserialize(deserializer) - .map(|market| ExchangeSub::from((CoinbaseChannel::ORDER_BOOK_L1, market)).id()) + <&str as Deserialize>::deserialize(deserializer) + .map(|market| ExchangeSub::from((CoinbaseChannel::ORDER_BOOK_L1, market)).id()) } #[cfg(test)] mod tests { - use super::*; + use super::*; - mod de { - use super::*; + mod de { + use super::*; - #[test] - fn test_coinbase_order_book_l1() { - struct TestCase { - input: &'static str, - expected: CoinbaseOrderBookL1, - } + #[test] + fn test_coinbase_order_book_l1() { + struct TestCase { + input: &'static str, + expected: CoinbaseOrderBookL1, + } - let time = Utc::now(); + let time = Utc::now(); - let tests = vec![ - TestCase { - // TC0: valid Spot CoinbaseOrderBookL1 - input: r#" + let tests = vec![TestCase { + // TC0: valid Spot CoinbaseOrderBookL1 + input: r#" { "type": "ticker", "sequence": 37475248783, @@ -104,24 +144,23 @@ mod tests { "last_size": "11.4396987" } "#, - expected: CoinbaseOrderBookL1 { - kind: "ticker".into(), - sequence: 37475248783, - subscription_id: SubscriptionId::from("ticker|ETH-USD"), - time, - best_bid_price: 1285.04, - best_bid_amount: 0.46688654, - best_ask_price: 1285.27, - best_ask_amount: 1.56637040, - }, - }, - ]; + expected: CoinbaseOrderBookL1 { + kind: "ticker".into(), + sequence: 37475248783, + subscription_id: SubscriptionId::from("ticker|ETH-USD"), + time, + best_bid_price: 1285.04, + best_bid_amount: 0.46688654, + best_ask_price: 1285.27, + best_ask_amount: 1.56637040, + }, + }]; - for (index, test) in tests.into_iter().enumerate() { - let actual = serde_json::from_str::(test.input).unwrap(); - let actual = CoinbaseOrderBookL1 { time, ..actual }; - assert_eq!(actual, test.expected, "TC{} failed", index); - } - } - } + for (index, test) in tests.into_iter().enumerate() { + let actual = serde_json::from_str::(test.input).unwrap(); + let actual = CoinbaseOrderBookL1 { time, ..actual }; + assert_eq!(actual, test.expected, "TC{} failed", index); + } + } + } } diff --git a/barter-data/src/exchange/coinbase/book/mod.rs b/barter-data/src/exchange/coinbase/book/mod.rs index ad987b6e..9601fe47 100644 --- a/barter-data/src/exchange/coinbase/book/mod.rs +++ b/barter-data/src/exchange/coinbase/book/mod.rs @@ -1 +1 @@ -pub mod l1; \ No newline at end of file +pub mod l1; diff --git a/barter-data/src/exchange/coinbase/channel.rs b/barter-data/src/exchange/coinbase/channel.rs index f75afa08..45acb55e 100644 --- a/barter-data/src/exchange/coinbase/channel.rs +++ b/barter-data/src/exchange/coinbase/channel.rs @@ -18,7 +18,7 @@ impl CoinbaseChannel { /// See docs: pub const TRADES: Self = Self("matches"); /// [`Coinbase`] real-time L1 orderbook channel. - /// + /// /// See docs: pub const ORDER_BOOK_L1: Self = Self("ticker"); } @@ -29,9 +29,7 @@ impl Identifier for Subscription Identifier - for Subscription -{ +impl Identifier for Subscription { fn id(&self) -> CoinbaseChannel { CoinbaseChannel::ORDER_BOOK_L1 } diff --git a/barter-data/src/exchange/coinbase/mod.rs b/barter-data/src/exchange/coinbase/mod.rs index 81a3c690..42778504 100644 --- a/barter-data/src/exchange/coinbase/mod.rs +++ b/barter-data/src/exchange/coinbase/mod.rs @@ -16,6 +16,7 @@ use book::l1::CoinbaseOrderBookL1; use serde_json::json; use url::Url; +/// OrderBook types for [`Coinbase`] pub mod book; /// Defines the type that translates a Barter [`Subscription`](crate::subscription::Subscription) @@ -83,7 +84,6 @@ where ExchangeWsStream>; } - impl StreamSelector for Coinbase where Instrument: InstrumentData, From fb01ea188fd8947fc8b5c50bd85889e25bad1380 Mon Sep 17 00:00:00 2001 From: jordy25519 Date: Fri, 19 Jul 2024 11:20:24 +0800 Subject: [PATCH 09/10] readme --- barter-data/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/barter-data/README.md b/barter-data/README.md index 53ce12cc..73c1275e 100644 --- a/barter-data/README.md +++ b/barter-data/README.md @@ -54,7 +54,7 @@ arbitrary number of exchange `MarketStream`s using input `Subscription`s. Simply | **Bitmex** | `Bitmex` | Perpetual | PublicTrades | | **BybitSpot** | `BybitSpot::default()` | Spot | PublicTrades | | **BybitPerpetualsUsd** | `BybitPerpetualsUsd::default()` | Perpetual | PublicTrades | -| **Coinbase** | `Coinbase` | Spot | PublicTrades | +| **Coinbase** | `Coinbase` | Spot | PublicTrades
OrderBooksL1 | | **GateioSpot** | `GateioSpot::default()` | Spot | PublicTrades | | **GateioFuturesUsd** | `GateioFuturesUsd::default()` | Future | PublicTrades | | **GateioFuturesBtc** | `GateioFuturesBtc::default()` | Future | PublicTrades | From b3471446c11aed7d99b9400b3a2096315777266d Mon Sep 17 00:00:00 2001 From: jordy25519 Date: Thu, 15 Aug 2024 17:28:44 +0800 Subject: [PATCH 10/10] group import --- barter-data/src/exchange/coinbase/mod.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/barter-data/src/exchange/coinbase/mod.rs b/barter-data/src/exchange/coinbase/mod.rs index 14f80656..24bd0da7 100644 --- a/barter-data/src/exchange/coinbase/mod.rs +++ b/barter-data/src/exchange/coinbase/mod.rs @@ -1,7 +1,6 @@ -use self::book::l1::CoinbaseOrderBookL1; use self::{ - channel::CoinbaseChannel, market::CoinbaseMarket, subscription::CoinbaseSubResponse, - trade::CoinbaseTrade, + book::l1::CoinbaseOrderBookL1, channel::CoinbaseChannel, market::CoinbaseMarket, + subscription::CoinbaseSubResponse, trade::CoinbaseTrade, }; use crate::{ exchange::{Connector, ExchangeId, ExchangeSub, StreamSelector},