├── .editorconfig ├── .gitignore ├── .snapshots ├── TestCancel-found ├── TestCancel-not_found ├── TestDepthAsks ├── TestDethBids ├── TestProcessLimitOrderAsks-happy_path_-_1 ├── TestProcessLimitOrderAsks-happy_path_-_2 ├── TestProcessLimitOrderAsks-happy_path_-_3 ├── TestProcessLimitOrderAsks-happy_path_-_4 ├── TestProcessLimitOrderBids-happy_path_-_1 ├── TestProcessLimitOrderBids-happy_path_-_2 ├── TestProcessLimitOrderBids-happy_path_-_3 ├── TestProcessLimitOrderBids-happy_path_-_4 ├── TestProcessLimitOrderBids-happy_path_-_5 ├── TestProcessLimitOrderValidations-invalid_amount ├── TestProcessLimitOrderValidations-invalid_order_id ├── TestProcessLimitOrderValidations-invalid_price ├── TestProcessLimitOrderValidations-invalid_trader_id ├── TestProcessLimitOrderValidations-order_already_exists ├── TestProcessMarketOrderValidations-invalid_amount ├── TestProcessMarketOrderValidations-invalid_order_id ├── TestProcessMarketOrderValidations-invalid_price ├── TestProcessMarketOrderValidations-invalid_trader_id ├── TestProcessMarketOrderValidations-order_already_exists ├── TestProcessPostOnlyOrderAsks-happy_path_-_1 ├── TestProcessPostOnlyOrderBids-happy_path_-_1 ├── TestProcessPostOnlyOrderValidations-invalid_amount ├── TestProcessPostOnlyOrderValidations-invalid_order_id ├── TestProcessPostOnlyOrderValidations-invalid_price ├── TestProcessPostOnlyOrderValidations-invalid_trader_id ├── TestProcessPostOnlyOrderValidations-order_already_exists ├── TestQuoteAsks-empty_book ├── TestQuoteAsks-happy_path_-_1 ├── TestQuoteAsks-happy_path_-_2 ├── TestQuoteAsks-skip_same_trader ├── TestQuoteBids-empty_book ├── TestQuoteBids-happy_path_-_1 ├── TestQuoteBids-happy_path_-_2 ├── TestQuoteBids-skip_same_trader ├── TestQuoteValidations-invalid_amount └── TestQuoteValidations-invalid_trader_id ├── LICENSE ├── README.md ├── depth.go ├── doc.go ├── errors.go ├── example ├── go.mod ├── go.sum └── main.go ├── go.mod ├── go.sum ├── order.go ├── order_book.go ├── order_book_cancel.go ├── order_book_cancel_test.go ├── order_book_depth.go ├── order_book_depth_test.go ├── order_book_limit.go ├── order_book_limit_test.go ├── order_book_market.go ├── order_book_market_test.go ├── order_book_postonly.go ├── order_book_postonly_test.go ├── order_book_quote.go ├── order_book_quote_test.go ├── order_book_restore.go ├── order_queue.go ├── order_side.go ├── price_level.go ├── quote.go ├── side.go └── trade.go /.editorconfig: -------------------------------------------------------------------------------- 1 | ; indicate this is the root of the project 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | 7 | end_of_line = LF 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | indent_style = space 12 | indent_size = 4 13 | 14 | [*.go] 15 | indent_style = tab 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.exe 2 | *.exe~ 3 | *.dll 4 | *.so 5 | *.dylib 6 | *.test 7 | *.out 8 | 9 | bin/ 10 | data/ 11 | dist/ 12 | .idea/ 13 | .vscode/ 14 | -------------------------------------------------------------------------------- /.snapshots/TestCancel-found: -------------------------------------------------------------------------------- 1 | {"Book":{"symbol":"","bids":[],"asks":[{"id":"3","traderId":"3","side":"sell","amount":"1","price":"300"},{"id":"2","traderId":"2","side":"sell","amount":"2","price":"400"}],"version":1},"Order":{"id":"1","traderId":"1","side":"sell","amount":"5","price":"500"}} 2 | -------------------------------------------------------------------------------- /.snapshots/TestCancel-not_found: -------------------------------------------------------------------------------- 1 | {"Book":{"symbol":"","bids":[],"asks":[{"id":"3","traderId":"3","side":"sell","amount":"1","price":"300"},{"id":"2","traderId":"2","side":"sell","amount":"2","price":"400"},{"id":"1","traderId":"1","side":"sell","amount":"5","price":"500"}],"version":1},"Order":null} 2 | -------------------------------------------------------------------------------- /.snapshots/TestDepthAsks: -------------------------------------------------------------------------------- 1 | {"bids":[{"amount":"2","price":"600"},{"amount":"2","price":"400"},{"amount":"2","price":"200"}],"asks":[]} 2 | -------------------------------------------------------------------------------- /.snapshots/TestDethBids: -------------------------------------------------------------------------------- 1 | {"bids":[],"asks":[{"amount":"2","price":"600"},{"amount":"2","price":"400"},{"amount":"2","price":"200"}]} 2 | -------------------------------------------------------------------------------- /.snapshots/TestProcessLimitOrderAsks-happy_path_-_1: -------------------------------------------------------------------------------- 1 | {"Book":{"symbol":"","bids":[{"id":"1","traderId":"1","side":"buy","amount":"5","price":"500"},{"id":"2","traderId":"2","side":"buy","amount":"1","price":"400"},{"id":"3","traderId":"3","side":"buy","amount":"0.5","price":"300"}],"asks":[{"id":"4","traderId":"4","side":"sell","amount":"5","price":"550"}],"version":1},"Trades":[],"Err":""} 2 | -------------------------------------------------------------------------------- /.snapshots/TestProcessLimitOrderAsks-happy_path_-_2: -------------------------------------------------------------------------------- 1 | {"Book":{"symbol":"","bids":[{"id":"1","traderId":"1","side":"buy","amount":"3","price":"500"},{"id":"2","traderId":"2","side":"buy","amount":"1","price":"400"},{"id":"3","traderId":"3","side":"buy","amount":"0.5","price":"300"}],"asks":[],"version":1},"Trades":[{"takerOrderId":"4","makerOrderId":"1","amount":"2","price":"500"}],"Err":""} 2 | -------------------------------------------------------------------------------- /.snapshots/TestProcessLimitOrderAsks-happy_path_-_3: -------------------------------------------------------------------------------- 1 | {"Book":{"symbol":"","bids":[{"id":"2","traderId":"2","side":"buy","amount":"1","price":"400"},{"id":"3","traderId":"3","side":"buy","amount":"0.5","price":"300"}],"asks":[{"id":"4","traderId":"4","side":"sell","amount":"0.2","price":"500"}],"version":1},"Trades":[{"takerOrderId":"4","makerOrderId":"1","amount":"5","price":"500"}],"Err":""} 2 | -------------------------------------------------------------------------------- /.snapshots/TestProcessLimitOrderAsks-happy_path_-_4: -------------------------------------------------------------------------------- 1 | {"Book":{"symbol":"","bids":[{"id":"1","traderId":"1","side":"buy","amount":"5","price":"500"},{"id":"2","traderId":"2","side":"buy","amount":"1","price":"400"},{"id":"3","traderId":"3","side":"buy","amount":"0.5","price":"300"}],"asks":[{"id":"4","traderId":"1","side":"sell","amount":"0.5","price":"450"}],"version":1},"Trades":[],"Err":""} 2 | -------------------------------------------------------------------------------- /.snapshots/TestProcessLimitOrderBids-happy_path_-_1: -------------------------------------------------------------------------------- 1 | {"Book":{"symbol":"","bids":[{"id":"4","traderId":"4","side":"buy","amount":"10","price":"100"}],"asks":[{"id":"3","traderId":"3","side":"sell","amount":"1","price":"300"},{"id":"2","traderId":"2","side":"sell","amount":"2","price":"400"},{"id":"1","traderId":"1","side":"sell","amount":"5","price":"500"}],"version":1},"Trades":[],"Err":""} 2 | -------------------------------------------------------------------------------- /.snapshots/TestProcessLimitOrderBids-happy_path_-_2: -------------------------------------------------------------------------------- 1 | {"Book":{"symbol":"","bids":[],"asks":[{"id":"3","traderId":"3","side":"sell","amount":"0.7","price":"300"},{"id":"2","traderId":"2","side":"sell","amount":"2","price":"400"},{"id":"1","traderId":"1","side":"sell","amount":"5","price":"500"}],"version":1},"Trades":[{"takerOrderId":"4","makerOrderId":"3","amount":"0.3","price":"300"}],"Err":""} 2 | -------------------------------------------------------------------------------- /.snapshots/TestProcessLimitOrderBids-happy_path_-_3: -------------------------------------------------------------------------------- 1 | {"Book":{"symbol":"","bids":[{"id":"4","traderId":"4","side":"buy","amount":"0.5","price":"300"}],"asks":[{"id":"2","traderId":"2","side":"sell","amount":"2","price":"400"},{"id":"1","traderId":"1","side":"sell","amount":"5","price":"500"}],"version":1},"Trades":[{"takerOrderId":"4","makerOrderId":"3","amount":"1","price":"300"}],"Err":""} 2 | -------------------------------------------------------------------------------- /.snapshots/TestProcessLimitOrderBids-happy_path_-_4: -------------------------------------------------------------------------------- 1 | {"Book":{"symbol":"","bids":[{"id":"4","traderId":"3","side":"buy","amount":"1.5","price":"300"}],"asks":[{"id":"3","traderId":"3","side":"sell","amount":"1","price":"300"},{"id":"2","traderId":"2","side":"sell","amount":"2","price":"400"},{"id":"1","traderId":"1","side":"sell","amount":"5","price":"500"}],"version":1},"Trades":[],"Err":""} 2 | -------------------------------------------------------------------------------- /.snapshots/TestProcessLimitOrderBids-happy_path_-_5: -------------------------------------------------------------------------------- 1 | {"Book":{"symbol":"","bids":[{"id":"4","traderId":"4","side":"buy","amount":"1","price":"1000"}],"asks":[],"version":1},"Trades":[{"takerOrderId":"4","makerOrderId":"3","amount":"1","price":"300"},{"takerOrderId":"4","makerOrderId":"2","amount":"2","price":"400"},{"takerOrderId":"4","makerOrderId":"1","amount":"5","price":"500"}],"Err":""} 2 | -------------------------------------------------------------------------------- /.snapshots/TestProcessLimitOrderValidations-invalid_amount: -------------------------------------------------------------------------------- 1 | {"Book":{"symbol":"","bids":[{"id":"1","traderId":"1","side":"buy","amount":"5","price":"500"},{"id":"2","traderId":"2","side":"buy","amount":"1","price":"400"},{"id":"3","traderId":"3","side":"buy","amount":"0.5","price":"300"}],"asks":[],"version":1},"Trades":null,"Err":"Invalid amount"} 2 | -------------------------------------------------------------------------------- /.snapshots/TestProcessLimitOrderValidations-invalid_order_id: -------------------------------------------------------------------------------- 1 | {"Book":{"symbol":"","bids":[{"id":"1","traderId":"1","side":"buy","amount":"5","price":"500"},{"id":"2","traderId":"2","side":"buy","amount":"1","price":"400"},{"id":"3","traderId":"3","side":"buy","amount":"0.5","price":"300"}],"asks":[],"version":1},"Trades":null,"Err":"Invalid order id"} 2 | -------------------------------------------------------------------------------- /.snapshots/TestProcessLimitOrderValidations-invalid_price: -------------------------------------------------------------------------------- 1 | {"Book":{"symbol":"","bids":[{"id":"1","traderId":"1","side":"buy","amount":"5","price":"500"},{"id":"2","traderId":"2","side":"buy","amount":"1","price":"400"},{"id":"3","traderId":"3","side":"buy","amount":"0.5","price":"300"}],"asks":[],"version":1},"Trades":null,"Err":"Invalid price"} 2 | -------------------------------------------------------------------------------- /.snapshots/TestProcessLimitOrderValidations-invalid_trader_id: -------------------------------------------------------------------------------- 1 | {"Book":{"symbol":"","bids":[{"id":"1","traderId":"1","side":"buy","amount":"5","price":"500"},{"id":"2","traderId":"2","side":"buy","amount":"1","price":"400"},{"id":"3","traderId":"3","side":"buy","amount":"0.5","price":"300"}],"asks":[],"version":1},"Trades":null,"Err":"Invalid trader id"} 2 | -------------------------------------------------------------------------------- /.snapshots/TestProcessLimitOrderValidations-order_already_exists: -------------------------------------------------------------------------------- 1 | {"Book":{"symbol":"","bids":[{"id":"1","traderId":"1","side":"buy","amount":"5","price":"500"},{"id":"2","traderId":"2","side":"buy","amount":"1","price":"400"},{"id":"3","traderId":"3","side":"buy","amount":"0.5","price":"300"}],"asks":[],"version":1},"Trades":null,"Err":"Order already exists"} 2 | -------------------------------------------------------------------------------- /.snapshots/TestProcessMarketOrderValidations-invalid_amount: -------------------------------------------------------------------------------- 1 | {"Book":{"symbol":"","bids":[{"id":"1","traderId":"1","side":"buy","amount":"5","price":"500"},{"id":"2","traderId":"2","side":"buy","amount":"1","price":"400"},{"id":"3","traderId":"3","side":"buy","amount":"0.5","price":"300"}],"asks":[],"version":1},"Trades":null,"Err":"Invalid amount"} 2 | -------------------------------------------------------------------------------- /.snapshots/TestProcessMarketOrderValidations-invalid_order_id: -------------------------------------------------------------------------------- 1 | {"Book":{"symbol":"","bids":[{"id":"1","traderId":"1","side":"buy","amount":"5","price":"500"},{"id":"2","traderId":"2","side":"buy","amount":"1","price":"400"},{"id":"3","traderId":"3","side":"buy","amount":"0.5","price":"300"}],"asks":[],"version":1},"Trades":null,"Err":"Invalid order id"} 2 | -------------------------------------------------------------------------------- /.snapshots/TestProcessMarketOrderValidations-invalid_price: -------------------------------------------------------------------------------- 1 | {"Book":{"symbol":"","bids":[{"id":"1","traderId":"1","side":"buy","amount":"5","price":"500"},{"id":"2","traderId":"2","side":"buy","amount":"1","price":"400"},{"id":"3","traderId":"3","side":"buy","amount":"0.5","price":"300"}],"asks":[],"version":1},"Trades":null,"Err":"Invalid price"} 2 | -------------------------------------------------------------------------------- /.snapshots/TestProcessMarketOrderValidations-invalid_trader_id: -------------------------------------------------------------------------------- 1 | {"Book":{"symbol":"","bids":[{"id":"1","traderId":"1","side":"buy","amount":"5","price":"500"},{"id":"2","traderId":"2","side":"buy","amount":"1","price":"400"},{"id":"3","traderId":"3","side":"buy","amount":"0.5","price":"300"}],"asks":[],"version":1},"Trades":null,"Err":"Invalid trader id"} 2 | -------------------------------------------------------------------------------- /.snapshots/TestProcessMarketOrderValidations-order_already_exists: -------------------------------------------------------------------------------- 1 | {"Book":{"symbol":"","bids":[{"id":"1","traderId":"1","side":"buy","amount":"5","price":"500"},{"id":"2","traderId":"2","side":"buy","amount":"1","price":"400"},{"id":"3","traderId":"3","side":"buy","amount":"0.5","price":"300"}],"asks":[],"version":1},"Trades":null,"Err":"Order already exists"} 2 | -------------------------------------------------------------------------------- /.snapshots/TestProcessPostOnlyOrderAsks-happy_path_-_1: -------------------------------------------------------------------------------- 1 | {"Book":{"symbol":"","bids":[],"asks":[{"id":"4","traderId":"4","side":"sell","amount":"5","price":"550"}],"version":1},"Trades":[],"Err":""} 2 | -------------------------------------------------------------------------------- /.snapshots/TestProcessPostOnlyOrderBids-happy_path_-_1: -------------------------------------------------------------------------------- 1 | {"Book":{"symbol":"","bids":[{"id":"4","traderId":"4","side":"buy","amount":"10","price":"100"}],"asks":[],"version":1},"Trades":[],"Err":""} 2 | -------------------------------------------------------------------------------- /.snapshots/TestProcessPostOnlyOrderValidations-invalid_amount: -------------------------------------------------------------------------------- 1 | {"Book":{"symbol":"","bids":[{"id":"1","traderId":"1","side":"buy","amount":"5","price":"500"},{"id":"2","traderId":"2","side":"buy","amount":"1","price":"400"},{"id":"3","traderId":"3","side":"buy","amount":"0.5","price":"300"}],"asks":[],"version":1},"Trades":null,"Err":"Invalid amount"} 2 | -------------------------------------------------------------------------------- /.snapshots/TestProcessPostOnlyOrderValidations-invalid_order_id: -------------------------------------------------------------------------------- 1 | {"Book":{"symbol":"","bids":[{"id":"1","traderId":"1","side":"buy","amount":"5","price":"500"},{"id":"2","traderId":"2","side":"buy","amount":"1","price":"400"},{"id":"3","traderId":"3","side":"buy","amount":"0.5","price":"300"}],"asks":[],"version":1},"Trades":null,"Err":"Invalid order id"} 2 | -------------------------------------------------------------------------------- /.snapshots/TestProcessPostOnlyOrderValidations-invalid_price: -------------------------------------------------------------------------------- 1 | {"Book":{"symbol":"","bids":[{"id":"1","traderId":"1","side":"buy","amount":"5","price":"500"},{"id":"2","traderId":"2","side":"buy","amount":"1","price":"400"},{"id":"3","traderId":"3","side":"buy","amount":"0.5","price":"300"}],"asks":[],"version":1},"Trades":null,"Err":"Invalid price"} 2 | -------------------------------------------------------------------------------- /.snapshots/TestProcessPostOnlyOrderValidations-invalid_trader_id: -------------------------------------------------------------------------------- 1 | {"Book":{"symbol":"","bids":[{"id":"1","traderId":"1","side":"buy","amount":"5","price":"500"},{"id":"2","traderId":"2","side":"buy","amount":"1","price":"400"},{"id":"3","traderId":"3","side":"buy","amount":"0.5","price":"300"}],"asks":[],"version":1},"Trades":null,"Err":"Invalid trader id"} 2 | -------------------------------------------------------------------------------- /.snapshots/TestProcessPostOnlyOrderValidations-order_already_exists: -------------------------------------------------------------------------------- 1 | {"Book":{"symbol":"","bids":[{"id":"1","traderId":"1","side":"buy","amount":"5","price":"500"},{"id":"2","traderId":"2","side":"buy","amount":"1","price":"400"},{"id":"3","traderId":"3","side":"buy","amount":"0.5","price":"300"}],"asks":[],"version":1},"Trades":null,"Err":"Order already exists"} 2 | -------------------------------------------------------------------------------- /.snapshots/TestQuoteAsks-empty_book: -------------------------------------------------------------------------------- 1 | {"Book":{"symbol":"","bids":[{"id":"3","traderId":"3","side":"buy","amount":"2","price":"600"},{"id":"2","traderId":"2","side":"buy","amount":"2","price":"400"},{"id":"1","traderId":"1","side":"buy","amount":"2","price":"200"}],"asks":[],"version":0},"Result":{"price":"2400","remainingAmount":"0"},"Err":""} 2 | -------------------------------------------------------------------------------- /.snapshots/TestQuoteAsks-happy_path_-_1: -------------------------------------------------------------------------------- 1 | {"Book":{"symbol":"","bids":[{"id":"3","traderId":"3","side":"buy","amount":"2","price":"600"},{"id":"2","traderId":"2","side":"buy","amount":"2","price":"400"},{"id":"1","traderId":"1","side":"buy","amount":"2","price":"200"}],"asks":[],"version":0},"Result":{"price":"2400","remainingAmount":"0"},"Err":""} 2 | -------------------------------------------------------------------------------- /.snapshots/TestQuoteAsks-happy_path_-_2: -------------------------------------------------------------------------------- 1 | {"Book":{"symbol":"","bids":[{"id":"3","traderId":"3","side":"buy","amount":"2","price":"600"},{"id":"2","traderId":"2","side":"buy","amount":"2","price":"400"},{"id":"1","traderId":"1","side":"buy","amount":"2","price":"200"}],"asks":[],"version":0},"Result":{"price":"600","remainingAmount":"0"},"Err":""} 2 | -------------------------------------------------------------------------------- /.snapshots/TestQuoteAsks-skip_same_trader: -------------------------------------------------------------------------------- 1 | {"Book":{"symbol":"","bids":[{"id":"3","traderId":"3","side":"buy","amount":"2","price":"600"},{"id":"2","traderId":"2","side":"buy","amount":"2","price":"400"},{"id":"1","traderId":"1","side":"buy","amount":"2","price":"200"}],"asks":[],"version":0},"Result":{"price":"1600","remainingAmount":"2"},"Err":""} 2 | -------------------------------------------------------------------------------- /.snapshots/TestQuoteBids-empty_book: -------------------------------------------------------------------------------- 1 | {"Book":{"symbol":"","bids":[],"asks":[{"id":"1","traderId":"1","side":"sell","amount":"2","price":"200"},{"id":"2","traderId":"2","side":"sell","amount":"2","price":"400"},{"id":"3","traderId":"3","side":"sell","amount":"2","price":"600"}],"version":0},"Result":{"price":"0","remainingAmount":"6"},"Err":""} 2 | -------------------------------------------------------------------------------- /.snapshots/TestQuoteBids-happy_path_-_1: -------------------------------------------------------------------------------- 1 | {"Book":{"symbol":"","bids":[],"asks":[{"id":"1","traderId":"1","side":"sell","amount":"2","price":"200"},{"id":"2","traderId":"2","side":"sell","amount":"2","price":"400"},{"id":"3","traderId":"3","side":"sell","amount":"2","price":"600"}],"version":0},"Result":{"price":"2400","remainingAmount":"0"},"Err":""} 2 | -------------------------------------------------------------------------------- /.snapshots/TestQuoteBids-happy_path_-_2: -------------------------------------------------------------------------------- 1 | {"Book":{"symbol":"","bids":[],"asks":[{"id":"1","traderId":"1","side":"sell","amount":"2","price":"200"},{"id":"2","traderId":"2","side":"sell","amount":"2","price":"400"},{"id":"3","traderId":"3","side":"sell","amount":"2","price":"600"}],"version":0},"Result":{"price":"200","remainingAmount":"0"},"Err":""} 2 | -------------------------------------------------------------------------------- /.snapshots/TestQuoteBids-skip_same_trader: -------------------------------------------------------------------------------- 1 | {"Book":{"symbol":"","bids":[],"asks":[{"id":"1","traderId":"1","side":"sell","amount":"2","price":"200"},{"id":"2","traderId":"2","side":"sell","amount":"2","price":"400"},{"id":"3","traderId":"3","side":"sell","amount":"2","price":"600"}],"version":0},"Result":{"price":"1600","remainingAmount":"2"},"Err":""} 2 | -------------------------------------------------------------------------------- /.snapshots/TestQuoteValidations-invalid_amount: -------------------------------------------------------------------------------- 1 | {"Book":{"symbol":"","bids":[],"asks":[{"id":"1","traderId":"1","side":"sell","amount":"2","price":"200"},{"id":"2","traderId":"2","side":"sell","amount":"2","price":"400"},{"id":"3","traderId":"3","side":"sell","amount":"2","price":"600"}],"version":0},"Result":null,"Err":"Invalid amount"} 2 | -------------------------------------------------------------------------------- /.snapshots/TestQuoteValidations-invalid_trader_id: -------------------------------------------------------------------------------- 1 | {"Book":{"symbol":"","bids":[],"asks":[{"id":"1","traderId":"1","side":"sell","amount":"2","price":"200"},{"id":"2","traderId":"2","side":"sell","amount":"2","price":"400"},{"id":"3","traderId":"3","side":"sell","amount":"2","price":"600"}],"version":0},"Result":null,"Err":"Invalid trader id"} 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Daniel Gatis 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Go - Orderbook 2 | 3 | [![Go Report Card](https://goreportcard.com/badge/github.com/danielgatis/go-orderbook?style=flat-square)](https://goreportcard.com/report/github.com/danielgatis/go-orderbook) 4 | [![License MIT](https://img.shields.io/badge/license-MIT-blue.svg)](https://raw.githubusercontent.com/danielgatis/go-orderbook/master/LICENSE) 5 | [![Go Doc](https://img.shields.io/badge/godoc-reference-blue.svg?style=flat-square)](https://godoc.org/github.com/danielgatis/go-orderbook) 6 | 7 | The pkg `go-orderbook` implements a limit order book for high-frequency trading (HFT), as described by WK Selph. 8 | 9 | Based on WK Selph's Blogpost: 10 | https://goo.gl/KF1SRm 11 | 12 | ## Install 13 | 14 | ```bash 15 | go get -u github.com/danielgatis/go-orderbook 16 | ``` 17 | 18 | And then import the package in your code: 19 | 20 | ```go 21 | import "github.com/danielgatis/go-orderbook" 22 | ``` 23 | 24 | ### Example 25 | 26 | An example described below is one of the use cases. 27 | 28 | ```go 29 | package main 30 | 31 | import ( 32 | "bytes" 33 | "encoding/json" 34 | "fmt" 35 | "math/rand" 36 | "time" 37 | 38 | "github.com/danielgatis/go-orderbook" 39 | "github.com/google/uuid" 40 | "github.com/shopspring/decimal" 41 | ) 42 | 43 | func main() { 44 | book := orderbook.NewOrderBook("BTC/BRL") 45 | 46 | for i := 0; i < 10; i++ { 47 | rand.Seed(time.Now().UnixNano()) 48 | side := []orderbook.Side{orderbook.Buy, orderbook.Sell}[rand.Intn(2)] 49 | 50 | book.ProcessPostOnlyOrder(uuid.New().String(), uuid.New().String(), side, decimal.NewFromInt(rand.Int63n(1000)), decimal.NewFromInt(rand.Int63n(1000))) 51 | } 52 | 53 | depth, _ := json.Marshal(book.Depth()) 54 | var buf bytes.Buffer 55 | json.Indent(&buf, depth, "", " ") 56 | fmt.Println(buf.String()) 57 | } 58 | ``` 59 | 60 | ``` 61 | ❯ go run main.go 62 | { 63 | "bids": [ 64 | { 65 | "amount": "392", 66 | "price": "930" 67 | }, 68 | { 69 | "amount": "872", 70 | "price": "907" 71 | }, 72 | { 73 | "amount": "859", 74 | "price": "790" 75 | }, 76 | { 77 | "amount": "643", 78 | "price": "424" 79 | }, 80 | { 81 | "amount": "269", 82 | "price": "244" 83 | }, 84 | { 85 | "amount": "160", 86 | "price": "83" 87 | }, 88 | { 89 | "amount": "74", 90 | "price": "65" 91 | } 92 | ], 93 | "asks": [ 94 | { 95 | "amount": "178", 96 | "price": "705" 97 | }, 98 | { 99 | "amount": "253", 100 | "price": "343" 101 | }, 102 | { 103 | "amount": "805", 104 | "price": "310" 105 | } 106 | ] 107 | } 108 | ``` 109 | 110 | ## License 111 | 112 | Copyright (c) 2020-present [Daniel Gatis](https://github.com/danielgatis) 113 | 114 | Licensed under [MIT License](./LICENSE) 115 | 116 | ### Buy me a coffee 117 | 118 | Liked some of my work? Buy me a coffee (or more likely a beer) 119 | 120 | Buy Me A Coffee 121 | -------------------------------------------------------------------------------- /depth.go: -------------------------------------------------------------------------------- 1 | package orderbook 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | ) 7 | 8 | var _ json.Marshaler = (*Depth)(nil) 9 | var _ json.Unmarshaler = (*Depth)(nil) 10 | 11 | // Depth represents a order book depth. 12 | type Depth struct { 13 | bids []*PriceLevel 14 | asks []*PriceLevel 15 | } 16 | 17 | // NewDepth creates a new depth. 18 | func NewDepth(bids, asks []*PriceLevel) *Depth { 19 | return &Depth{bids, asks} 20 | } 21 | 22 | // Bids returns a range of price leves. 23 | func (d *Depth) Bids() []*PriceLevel { 24 | return d.bids 25 | } 26 | 27 | // Asks returns a range of price leves. 28 | func (d *Depth) Asks() []*PriceLevel { 29 | return d.asks 30 | } 31 | 32 | // MarshalJSON implements json.MarshalJSON. 33 | func (d *Depth) MarshalJSON() ([]byte, error) { 34 | return json.Marshal( 35 | &struct { 36 | Bids []*PriceLevel `json:"bids"` 37 | Asks []*PriceLevel `json:"asks"` 38 | }{ 39 | d.bids, 40 | d.asks, 41 | }, 42 | ) 43 | } 44 | 45 | // UnmarshalJSON implements json.Unmarshaler. 46 | func (d *Depth) UnmarshalJSON(data []byte) error { 47 | obj := struct { 48 | Bids []*PriceLevel `json:"bids"` 49 | Asks []*PriceLevel `json:"asks"` 50 | }{} 51 | 52 | if err := json.Unmarshal(data, &obj); err != nil { 53 | return fmt.Errorf("Depth.Unmarshal(%s): %w", data, err) 54 | } 55 | 56 | d.bids = obj.Bids 57 | d.asks = obj.Asks 58 | 59 | return nil 60 | } 61 | -------------------------------------------------------------------------------- /doc.go: -------------------------------------------------------------------------------- 1 | /* 2 | Package orderbook is a Limit Order Book for high-frequency trading. 3 | */ 4 | package orderbook 5 | -------------------------------------------------------------------------------- /errors.go: -------------------------------------------------------------------------------- 1 | package orderbook 2 | 3 | import "errors" 4 | 5 | // Orderbook erros 6 | var ( 7 | ErrInvalidOrderID = errors.New("Invalid order id") 8 | ErrInvalidTraderID = errors.New("Invalid trader id") 9 | ErrInvalidAmount = errors.New("Invalid amount") 10 | ErrInvalidPrice = errors.New("Invalid price") 11 | ErrInvalidSide = errors.New("Invalid side") 12 | ErrOrderAlreadyExists = errors.New("Order already exists") 13 | ) 14 | -------------------------------------------------------------------------------- /example/go.mod: -------------------------------------------------------------------------------- 1 | module example 2 | 3 | go 1.17 4 | 5 | require ( 6 | github.com/danielgatis/go-orderbook v0.0.0-20211221232158-a76adcecdd12 7 | github.com/google/uuid v1.3.0 8 | github.com/shopspring/decimal v1.3.1 9 | ) 10 | 11 | require github.com/emirpasic/gods v1.12.0 // indirect 12 | -------------------------------------------------------------------------------- /example/go.sum: -------------------------------------------------------------------------------- 1 | github.com/bradleyjkemp/cupaloy/v2 v2.7.0 h1:AT0vOjO68RcLyenLCHOGZzSNiuto7ziqzq6Q1/3xzMQ= 2 | github.com/bradleyjkemp/cupaloy/v2 v2.7.0/go.mod h1:bm7JXdkRd4BHJk9HpwqAI8BoAY1lps46Enkdqw6aRX0= 3 | github.com/danielgatis/go-orderbook v0.0.0-20211221232158-a76adcecdd12 h1:r0vv9QNnghlz7FWcO3XeTQhV0V4jFeb3tOKakC/bUUY= 4 | github.com/danielgatis/go-orderbook v0.0.0-20211221232158-a76adcecdd12/go.mod h1:L1WoaZYNl1Fvg1r3bQ4QRze8gtgnsnme+7JprcFqUX4= 5 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 6 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 7 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 8 | github.com/emirpasic/gods v1.12.0 h1:QAUIPSaCu4G+POclxeqb3F+WPpdKqFGlw36+yOzGlrg= 9 | github.com/emirpasic/gods v1.12.0/go.mod h1:YfzfFFoVP/catgzJb4IKIqXjX78Ha8FMSDh3ymbK86o= 10 | github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= 11 | github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 12 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 13 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 14 | github.com/shopspring/decimal v1.3.1 h1:2Usl1nmF/WZucqkFZhnfFYxxxu8LG21F6nPQBE5gKV8= 15 | github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= 16 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 17 | github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 18 | github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 19 | github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= 20 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 21 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 22 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= 23 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 24 | -------------------------------------------------------------------------------- /example/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "math/rand" 8 | "time" 9 | 10 | "github.com/danielgatis/go-orderbook" 11 | "github.com/google/uuid" 12 | "github.com/shopspring/decimal" 13 | ) 14 | 15 | func main() { 16 | book := orderbook.NewOrderBook("BTC/BRL") 17 | 18 | for i := 0; i < 10; i++ { 19 | rand.Seed(time.Now().UnixNano()) 20 | side := []orderbook.Side{orderbook.Buy, orderbook.Sell}[rand.Intn(2)] 21 | 22 | book.ProcessPostOnlyOrder(uuid.New().String(), uuid.New().String(), side, decimal.NewFromInt(rand.Int63n(1000)), decimal.NewFromInt(rand.Int63n(1000))) 23 | } 24 | 25 | depth, _ := json.Marshal(book.Depth()) 26 | var buf bytes.Buffer 27 | json.Indent(&buf, depth, "", " ") 28 | fmt.Println(buf.String()) 29 | } 30 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/danielgatis/go-orderbook 2 | 3 | go 1.20 4 | 5 | require ( 6 | github.com/bradleyjkemp/cupaloy/v2 v2.8.0 7 | github.com/emirpasic/gods v1.18.1 8 | github.com/google/uuid v1.3.0 9 | github.com/shopspring/decimal v1.3.1 10 | github.com/stretchr/testify v1.8.4 11 | ) 12 | 13 | require ( 14 | github.com/davecgh/go-spew v1.1.1 // indirect 15 | github.com/pmezard/go-difflib v1.0.0 // indirect 16 | gopkg.in/yaml.v3 v3.0.1 // indirect 17 | ) 18 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/bradleyjkemp/cupaloy/v2 v2.8.0 h1:any4BmKE+jGIaMpnU8YgH/I2LPiLBufr6oMMlVBbn9M= 2 | github.com/bradleyjkemp/cupaloy/v2 v2.8.0/go.mod h1:bm7JXdkRd4BHJk9HpwqAI8BoAY1lps46Enkdqw6aRX0= 3 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 4 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 5 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 6 | github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= 7 | github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= 8 | github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= 9 | github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 10 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 11 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 12 | github.com/shopspring/decimal v1.3.1 h1:2Usl1nmF/WZucqkFZhnfFYxxxu8LG21F6nPQBE5gKV8= 13 | github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= 14 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 15 | github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 16 | github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 17 | github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= 18 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 19 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 20 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 21 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 22 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 23 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 24 | -------------------------------------------------------------------------------- /order.go: -------------------------------------------------------------------------------- 1 | package orderbook 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | 7 | "github.com/shopspring/decimal" 8 | ) 9 | 10 | var _ json.Marshaler = (*Order)(nil) 11 | var _ json.Unmarshaler = (*Order)(nil) 12 | 13 | // Order represents a bid or an ask. 14 | type Order struct { 15 | id string 16 | traderID string 17 | side Side 18 | amount decimal.Decimal 19 | price decimal.Decimal 20 | } 21 | 22 | // NewOrder creates a new order. 23 | func NewOrder(ID, traderID string, side Side, amount, price decimal.Decimal) *Order { 24 | return &Order{ID, traderID, side, amount, price} 25 | } 26 | 27 | // ID returns the order ID. 28 | func (o *Order) ID() string { 29 | return o.id 30 | } 31 | 32 | // TraderID returns the trader ID. 33 | func (o *Order) TraderID() string { 34 | return o.traderID 35 | } 36 | 37 | // Side returns the side. 38 | func (o *Order) Side() Side { 39 | return o.side 40 | } 41 | 42 | // Amount returns the amount. 43 | func (o *Order) Amount() decimal.Decimal { 44 | return o.amount 45 | } 46 | 47 | // Price returns the price. 48 | func (o *Order) Price() decimal.Decimal { 49 | return o.price 50 | } 51 | 52 | // MarshalJSON implements json.Marshaler. 53 | func (o *Order) MarshalJSON() ([]byte, error) { 54 | return json.Marshal( 55 | &struct { 56 | ID string `json:"id"` 57 | TraderID string `json:"traderId"` 58 | Side Side `json:"side"` 59 | Amount decimal.Decimal `json:"amount"` 60 | Price decimal.Decimal `json:"price"` 61 | }{ 62 | o.id, 63 | o.traderID, 64 | o.side, 65 | o.amount, 66 | o.price, 67 | }, 68 | ) 69 | } 70 | 71 | // UnmarshalJSON implements json.Unmarshaler. 72 | func (o *Order) UnmarshalJSON(data []byte) error { 73 | obj := struct { 74 | ID string `json:"id"` 75 | TraderID string `json:"traderId"` 76 | Side Side `json:"side"` 77 | Amount decimal.Decimal `json:"amount"` 78 | Price decimal.Decimal `json:"price"` 79 | }{} 80 | 81 | if err := json.Unmarshal(data, &obj); err != nil { 82 | return fmt.Errorf("Order.Unmarshal(%s): %w", data, err) 83 | } 84 | 85 | o.id = obj.ID 86 | o.side = obj.Side 87 | o.traderID = obj.TraderID 88 | o.side = obj.Side 89 | o.amount = obj.Amount 90 | o.price = obj.Price 91 | 92 | return nil 93 | } 94 | -------------------------------------------------------------------------------- /order_book.go: -------------------------------------------------------------------------------- 1 | package orderbook 2 | 3 | import ( 4 | "container/list" 5 | "encoding/json" 6 | "fmt" 7 | "sync" 8 | ) 9 | 10 | var _ json.Marshaler = (*OrderBook)(nil) 11 | var _ json.Unmarshaler = (*OrderBook)(nil) 12 | 13 | // OrderBook represents a order book for a given market symbol. 14 | type OrderBook struct { 15 | sync.RWMutex 16 | symbol string 17 | version uint64 18 | orders map[string]*list.Element 19 | asks *OrderSide 20 | bids *OrderSide 21 | } 22 | 23 | // NewOrderBook creates a new order book. 24 | func NewOrderBook(symbol string) *OrderBook { 25 | return &OrderBook{sync.RWMutex{}, symbol, 0, make(map[string]*list.Element), NewOrderSide(Sell), NewOrderSide(Buy)} 26 | } 27 | 28 | // Symbol returns the symbol. 29 | func (ob *OrderBook) Symbol() string { 30 | return ob.symbol 31 | } 32 | 33 | // Version returns the version. The version is auto incremented by each change. 34 | func (ob *OrderBook) Version() uint64 { 35 | return ob.version 36 | } 37 | 38 | // Reset resets the order book. 39 | func (ob *OrderBook) Reset(version uint64) { 40 | defer ob.Unlock() 41 | ob.Lock() 42 | 43 | ob.orders = make(map[string]*list.Element) 44 | ob.asks = NewOrderSide(Sell) 45 | ob.bids = NewOrderSide(Buy) 46 | ob.version = version 47 | } 48 | 49 | // MarshalJSON implements json.MarshalJSON. 50 | func (ob *OrderBook) MarshalJSON() ([]byte, error) { 51 | return json.Marshal( 52 | &struct { 53 | Symbol string `json:"symbol"` 54 | Bids []*Order `json:"bids"` 55 | Asks []*Order `json:"asks"` 56 | Version uint64 `json:"version"` 57 | }{ 58 | ob.symbol, 59 | ob.bids.Orders(), 60 | ob.asks.Orders(), 61 | ob.version, 62 | }, 63 | ) 64 | } 65 | 66 | // UnmarshalJSON implements json.Unmarshaler. 67 | func (ob *OrderBook) UnmarshalJSON(data []byte) error { 68 | defer ob.Unlock() 69 | ob.Lock() 70 | 71 | obj := struct { 72 | Symbol string `json:"symbol"` 73 | Bids []*Order `json:"bids"` 74 | Asks []*Order `json:"asks"` 75 | Version uint64 `json:"version"` 76 | }{} 77 | 78 | if err := json.Unmarshal(data, &obj); err != nil { 79 | return fmt.Errorf("OrderBook.Unmarshal(%s): %w", data, err) 80 | } 81 | 82 | ob.symbol = obj.Symbol 83 | ob.version = obj.Version 84 | ob.orders = make(map[string]*list.Element) 85 | 86 | ob.asks = NewOrderSide(Sell) 87 | for _, order := range obj.Asks { 88 | ob.orders[order.id] = ob.asks.Append(order) 89 | } 90 | 91 | ob.bids = NewOrderSide(Buy) 92 | for _, order := range obj.Bids { 93 | ob.orders[order.id] = ob.bids.Append(order) 94 | } 95 | 96 | return nil 97 | } 98 | -------------------------------------------------------------------------------- /order_book_cancel.go: -------------------------------------------------------------------------------- 1 | package orderbook 2 | 3 | // CancelOrder canacels an order. 4 | func (ob *OrderBook) CancelOrder(orderID string) *Order { 5 | defer func() { 6 | ob.version++ 7 | ob.Unlock() 8 | }() 9 | 10 | ob.Lock() 11 | 12 | return ob.remove(orderID) 13 | } 14 | 15 | func (ob *OrderBook) remove(orderID string) *Order { 16 | e, ok := ob.orders[orderID] 17 | if !ok { 18 | return nil 19 | } 20 | 21 | delete(ob.orders, orderID) 22 | 23 | if e.Value.(*Order).side == Buy { 24 | return ob.bids.Remove(e) 25 | } 26 | 27 | return ob.asks.Remove(e) 28 | } 29 | -------------------------------------------------------------------------------- /order_book_cancel_test.go: -------------------------------------------------------------------------------- 1 | package orderbook_test 2 | 3 | import ( 4 | "encoding/json" 5 | "testing" 6 | 7 | "github.com/bradleyjkemp/cupaloy/v2" 8 | "github.com/danielgatis/go-orderbook" 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func TestCancel(t *testing.T) { 13 | type input struct { 14 | OrderID string 15 | } 16 | 17 | type snapshot struct { 18 | Book *orderbook.OrderBook 19 | Order *orderbook.Order 20 | } 21 | 22 | tests := []struct { 23 | name string 24 | input input 25 | }{ 26 | { 27 | name: "found", 28 | input: input{ 29 | OrderID: "1", 30 | }, 31 | }, 32 | { 33 | name: "not found", 34 | input: input{ 35 | OrderID: "foo", 36 | }, 37 | }, 38 | } 39 | 40 | for _, tt := range tests { 41 | t.Run(tt.name, func(t *testing.T) { 42 | given := []byte(` 43 | { 44 | "bids": [], 45 | "asks": [ 46 | { 47 | "id": "1", 48 | "traderId": "1", 49 | "side": "sell", 50 | "amount": "5", 51 | "price": "500" 52 | }, 53 | { 54 | "id": "2", 55 | "traderId": "2", 56 | "side": "sell", 57 | "amount": "2", 58 | "price": "400" 59 | }, 60 | { 61 | "id": "3", 62 | "traderId": "3", 63 | "side": "sell", 64 | "amount": "1", 65 | "price": "300" 66 | } 67 | ] 68 | } 69 | `) 70 | 71 | var book orderbook.OrderBook 72 | err := json.Unmarshal(given, &book) 73 | assert.Nil(t, err) 74 | 75 | order := book.CancelOrder(tt.input.OrderID) 76 | 77 | s, err := json.Marshal(&snapshot{ 78 | Book: &book, 79 | Order: order, 80 | }) 81 | 82 | assert.Nil(t, err) 83 | cupaloy.SnapshotT(t, s) 84 | }) 85 | } 86 | 87 | } 88 | -------------------------------------------------------------------------------- /order_book_depth.go: -------------------------------------------------------------------------------- 1 | package orderbook 2 | 3 | // Depth retruns the depth. 4 | func (ob *OrderBook) Depth() *Depth { 5 | defer ob.RUnlock() 6 | ob.RLock() 7 | 8 | asks := make([]*PriceLevel, 0) 9 | level := ob.asks.MaxPriceQueue() 10 | 11 | for level != nil { 12 | asks = append(asks, NewPriceLevel(level.price, level.amount)) 13 | level = ob.asks.LessThan(level.price) 14 | } 15 | 16 | bids := make([]*PriceLevel, 0) 17 | level = ob.bids.MaxPriceQueue() 18 | 19 | for level != nil { 20 | bids = append(bids, NewPriceLevel(level.price, level.amount)) 21 | level = ob.bids.LessThan(level.price) 22 | } 23 | 24 | return &Depth{bids, asks} 25 | } 26 | -------------------------------------------------------------------------------- /order_book_depth_test.go: -------------------------------------------------------------------------------- 1 | package orderbook_test 2 | 3 | import ( 4 | "encoding/json" 5 | "testing" 6 | 7 | "github.com/bradleyjkemp/cupaloy/v2" 8 | "github.com/danielgatis/go-orderbook" 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func TestDethBids(t *testing.T) { 13 | given := []byte(` 14 | { 15 | "bids": [], 16 | "asks": [ 17 | { 18 | "id": "1", 19 | "traderId": "1", 20 | "side": "sell", 21 | "amount": "2", 22 | "price": "200" 23 | }, 24 | { 25 | "id": "2", 26 | "traderId": "2", 27 | "side": "sell", 28 | "amount": "2", 29 | "price": "400" 30 | }, 31 | { 32 | "id": "3", 33 | "traderId": "3", 34 | "side": "sell", 35 | "amount": "2", 36 | "price": "600" 37 | } 38 | ] 39 | } 40 | `) 41 | 42 | var book orderbook.OrderBook 43 | err := json.Unmarshal(given, &book) 44 | assert.Nil(t, err) 45 | 46 | result := book.Depth() 47 | s, err := json.Marshal(result) 48 | 49 | assert.Nil(t, err) 50 | cupaloy.SnapshotT(t, s) 51 | } 52 | 53 | func TestDepthAsks(t *testing.T) { 54 | given := []byte(` 55 | { 56 | "bids": [ 57 | { 58 | "id": "1", 59 | "traderId": "1", 60 | "side": "buy", 61 | "amount": "2", 62 | "price": "200" 63 | }, 64 | { 65 | "id": "2", 66 | "traderId": "2", 67 | "side": "buy", 68 | "amount": "2", 69 | "price": "400" 70 | }, 71 | { 72 | "id": "3", 73 | "traderId": "3", 74 | "side": "buy", 75 | "amount": "2", 76 | "price": "600" 77 | } 78 | ], 79 | "asks": [] 80 | } 81 | `) 82 | 83 | var book orderbook.OrderBook 84 | err := json.Unmarshal(given, &book) 85 | assert.Nil(t, err) 86 | 87 | result := book.Depth() 88 | s, err := json.Marshal(result) 89 | 90 | assert.Nil(t, err) 91 | cupaloy.SnapshotT(t, s) 92 | } 93 | -------------------------------------------------------------------------------- /order_book_limit.go: -------------------------------------------------------------------------------- 1 | package orderbook 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/shopspring/decimal" 7 | ) 8 | 9 | // ProcessLimitOrder processes a limit order. 10 | func (ob *OrderBook) ProcessLimitOrder(orderID, traderID string, side Side, amount, price decimal.Decimal) ([]*Trade, error) { 11 | defer func() { 12 | ob.version++ 13 | ob.Unlock() 14 | }() 15 | 16 | ob.Lock() 17 | 18 | if strings.TrimSpace(orderID) == "" { 19 | return nil, ErrInvalidOrderID 20 | } 21 | 22 | if ob.orders[orderID] != nil { 23 | return nil, ErrOrderAlreadyExists 24 | } 25 | 26 | if strings.TrimSpace(traderID) == "" { 27 | return nil, ErrInvalidTraderID 28 | } 29 | 30 | if amount.LessThanOrEqual(decimal.Zero) { 31 | return nil, ErrInvalidAmount 32 | } 33 | 34 | if price.LessThanOrEqual(decimal.Zero) { 35 | return nil, ErrInvalidPrice 36 | } 37 | 38 | var ( 39 | sideToAdd *OrderSide 40 | sideToProcess *OrderSide 41 | comparator func(decimal.Decimal) bool 42 | best func() *OrderQueue 43 | next func(decimal.Decimal) *OrderQueue 44 | ) 45 | 46 | if side == Buy { 47 | sideToAdd = ob.bids 48 | sideToProcess = ob.asks 49 | comparator = price.GreaterThanOrEqual 50 | best = ob.asks.MinPriceQueue 51 | next = ob.asks.GreaterThan 52 | } else { 53 | sideToAdd = ob.asks 54 | sideToProcess = ob.bids 55 | comparator = price.LessThanOrEqual 56 | best = ob.bids.MaxPriceQueue 57 | next = ob.bids.LessThan 58 | } 59 | 60 | trades := make([]*Trade, 0) 61 | amountToTrade := amount 62 | bestPrice := best() 63 | 64 | for bestPrice != nil && amountToTrade.GreaterThan(decimal.Zero) && comparator(bestPrice.price) { 65 | headOrderEl := bestPrice.Front() 66 | bestPrice = next(bestPrice.price) 67 | 68 | for headOrderEl != nil && amountToTrade.GreaterThan(decimal.Zero) { 69 | headOrder := headOrderEl.Value.(*Order) 70 | 71 | if headOrder.traderID == traderID { 72 | headOrderEl = headOrderEl.Next() 73 | continue 74 | } 75 | 76 | if amountToTrade.GreaterThanOrEqual(headOrder.amount) { 77 | trades = append(trades, NewTrade(orderID, headOrder.id, headOrder.amount, headOrder.price)) 78 | amountToTrade = amountToTrade.Sub(headOrder.amount) 79 | 80 | headOrderEl = headOrderEl.Next() 81 | ob.remove(headOrder.id) 82 | } else { 83 | trades = append(trades, NewTrade(orderID, headOrder.id, amountToTrade, headOrder.price)) 84 | sideToProcess.UpdateAmount(headOrderEl, headOrder.amount.Sub(amountToTrade)) 85 | amountToTrade = decimal.Zero 86 | 87 | headOrderEl = headOrderEl.Next() 88 | } 89 | } 90 | } 91 | 92 | if amountToTrade.GreaterThan(decimal.Zero) { 93 | order := NewOrder(orderID, traderID, side, amountToTrade, price) 94 | ob.orders[order.id] = sideToAdd.Append(order) 95 | } 96 | 97 | return trades, nil 98 | } 99 | -------------------------------------------------------------------------------- /order_book_limit_test.go: -------------------------------------------------------------------------------- 1 | package orderbook_test 2 | 3 | import ( 4 | "encoding/json" 5 | "math/rand" 6 | "strconv" 7 | "testing" 8 | 9 | "github.com/bradleyjkemp/cupaloy/v2" 10 | "github.com/danielgatis/go-orderbook" 11 | "github.com/shopspring/decimal" 12 | "github.com/stretchr/testify/assert" 13 | ) 14 | 15 | func TestProcessLimitOrderBids(t *testing.T) { 16 | type input struct { 17 | OrderID string 18 | traderID string 19 | side orderbook.Side 20 | amount decimal.Decimal 21 | price decimal.Decimal 22 | } 23 | 24 | type snapshot struct { 25 | Book *orderbook.OrderBook 26 | Trades []*orderbook.Trade 27 | Err string 28 | } 29 | 30 | tests := []struct { 31 | name string 32 | input input 33 | }{ 34 | { 35 | name: "happy path - 1", 36 | input: input{ 37 | OrderID: "4", 38 | traderID: "4", 39 | side: orderbook.Buy, 40 | amount: decimal.NewFromInt(10), 41 | price: decimal.NewFromInt(100), 42 | }, 43 | }, 44 | { 45 | name: "happy path - 2", 46 | input: input{ 47 | OrderID: "4", 48 | traderID: "4", 49 | side: orderbook.Buy, 50 | amount: decimal.RequireFromString("0.3"), 51 | price: decimal.NewFromInt(500), 52 | }, 53 | }, 54 | { 55 | name: "happy path - 3", 56 | input: input{ 57 | OrderID: "4", 58 | traderID: "4", 59 | side: orderbook.Buy, 60 | amount: decimal.RequireFromString("1.5"), 61 | price: decimal.NewFromInt(300), 62 | }, 63 | }, 64 | { 65 | name: "happy path - 4", 66 | input: input{ 67 | OrderID: "4", 68 | traderID: "3", 69 | side: orderbook.Buy, 70 | amount: decimal.RequireFromString("1.5"), 71 | price: decimal.NewFromInt(300), 72 | }, 73 | }, 74 | { 75 | name: "happy path - 5", 76 | input: input{ 77 | OrderID: "4", 78 | traderID: "4", 79 | side: orderbook.Buy, 80 | amount: decimal.RequireFromString("9"), 81 | price: decimal.NewFromInt(1000), 82 | }, 83 | }, 84 | } 85 | 86 | for _, tt := range tests { 87 | t.Run(tt.name, func(t *testing.T) { 88 | given := []byte(` 89 | { 90 | "bids": [], 91 | "asks": [ 92 | { 93 | "id": "1", 94 | "traderId": "1", 95 | "side": "sell", 96 | "amount": "5", 97 | "price": "500" 98 | }, 99 | { 100 | "id": "2", 101 | "traderId": "2", 102 | "side": "sell", 103 | "amount": "2", 104 | "price": "400" 105 | }, 106 | { 107 | "id": "3", 108 | "traderId": "3", 109 | "side": "sell", 110 | "amount": "1", 111 | "price": "300" 112 | } 113 | ] 114 | } 115 | `) 116 | 117 | var book orderbook.OrderBook 118 | err := json.Unmarshal(given, &book) 119 | assert.Nil(t, err) 120 | 121 | trades, err := book.ProcessLimitOrder(tt.input.OrderID, tt.input.traderID, tt.input.side, tt.input.amount, tt.input.price) 122 | 123 | var errorStr string 124 | if err != nil { 125 | errorStr = err.Error() 126 | } 127 | 128 | s, err := json.Marshal(&snapshot{ 129 | Book: &book, 130 | Trades: trades, 131 | Err: errorStr, 132 | }) 133 | 134 | assert.Nil(t, err) 135 | cupaloy.SnapshotT(t, s) 136 | }) 137 | } 138 | } 139 | 140 | func TestProcessLimitOrderAsks(t *testing.T) { 141 | type input struct { 142 | OrderID string 143 | traderID string 144 | side orderbook.Side 145 | amount decimal.Decimal 146 | price decimal.Decimal 147 | } 148 | 149 | type snapshot struct { 150 | Book *orderbook.OrderBook 151 | Trades []*orderbook.Trade 152 | Err string 153 | } 154 | 155 | tests := []struct { 156 | name string 157 | input input 158 | }{ 159 | { 160 | name: "happy path - 1", 161 | input: input{ 162 | OrderID: "4", 163 | traderID: "4", 164 | side: orderbook.Sell, 165 | amount: decimal.NewFromInt(5), 166 | price: decimal.NewFromInt(550), 167 | }, 168 | }, 169 | { 170 | name: "happy path - 2", 171 | input: input{ 172 | OrderID: "4", 173 | traderID: "4", 174 | side: orderbook.Sell, 175 | amount: decimal.NewFromInt(2), 176 | price: decimal.NewFromInt(300), 177 | }, 178 | }, 179 | { 180 | name: "happy path - 3", 181 | input: input{ 182 | OrderID: "4", 183 | traderID: "4", 184 | side: orderbook.Sell, 185 | amount: decimal.RequireFromString("5.2"), 186 | price: decimal.NewFromInt(500), 187 | }, 188 | }, 189 | { 190 | name: "happy path - 4", 191 | input: input{ 192 | OrderID: "4", 193 | traderID: "1", 194 | side: orderbook.Sell, 195 | amount: decimal.RequireFromString("0.5"), 196 | price: decimal.NewFromInt(450), 197 | }, 198 | }, 199 | } 200 | 201 | for _, tt := range tests { 202 | t.Run(tt.name, func(t *testing.T) { 203 | given := []byte(` 204 | { 205 | "bids": [ 206 | { 207 | "id": "1", 208 | "traderId": "1", 209 | "side": "buy", 210 | "amount": "5", 211 | "price": "500" 212 | }, 213 | { 214 | "id": "2", 215 | "traderId": "2", 216 | "side": "buy", 217 | "amount": "1", 218 | "price": "400" 219 | }, 220 | { 221 | "id": "3", 222 | "traderId": "3", 223 | "side": "buy", 224 | "amount": "0.5", 225 | "price": "300" 226 | } 227 | ], 228 | "asks": [] 229 | } 230 | `) 231 | 232 | var book orderbook.OrderBook 233 | err := json.Unmarshal(given, &book) 234 | assert.Nil(t, err) 235 | 236 | trades, err := book.ProcessLimitOrder(tt.input.OrderID, tt.input.traderID, tt.input.side, tt.input.amount, tt.input.price) 237 | 238 | var errorStr string 239 | if err != nil { 240 | errorStr = err.Error() 241 | } 242 | 243 | s, err := json.Marshal(&snapshot{ 244 | Book: &book, 245 | Trades: trades, 246 | Err: errorStr, 247 | }) 248 | 249 | assert.Nil(t, err) 250 | cupaloy.SnapshotT(t, s) 251 | }) 252 | } 253 | } 254 | 255 | func TestProcessLimitOrderValidations(t *testing.T) { 256 | type input struct { 257 | OrderID string 258 | traderID string 259 | side orderbook.Side 260 | amount decimal.Decimal 261 | price decimal.Decimal 262 | } 263 | 264 | type snapshot struct { 265 | Book *orderbook.OrderBook 266 | Trades []*orderbook.Trade 267 | Err string 268 | } 269 | 270 | tests := []struct { 271 | name string 272 | input input 273 | }{ 274 | { 275 | name: "invalid order id", 276 | input: input{ 277 | OrderID: "", 278 | traderID: "4", 279 | side: orderbook.Sell, 280 | amount: decimal.NewFromInt(5), 281 | price: decimal.NewFromInt(550), 282 | }, 283 | }, 284 | { 285 | name: "order already exists", 286 | input: input{ 287 | OrderID: "1", 288 | traderID: "4", 289 | side: orderbook.Sell, 290 | amount: decimal.NewFromInt(5), 291 | price: decimal.NewFromInt(550), 292 | }, 293 | }, 294 | { 295 | name: "invalid trader id", 296 | input: input{ 297 | OrderID: "4", 298 | traderID: "", 299 | side: orderbook.Sell, 300 | amount: decimal.NewFromInt(5), 301 | price: decimal.NewFromInt(550), 302 | }, 303 | }, 304 | { 305 | name: "invalid amount", 306 | input: input{ 307 | OrderID: "4", 308 | traderID: "4", 309 | side: orderbook.Sell, 310 | amount: decimal.NewFromInt(0), 311 | price: decimal.NewFromInt(550), 312 | }, 313 | }, 314 | { 315 | name: "invalid price", 316 | input: input{ 317 | OrderID: "4", 318 | traderID: "4", 319 | side: orderbook.Sell, 320 | amount: decimal.NewFromInt(5), 321 | price: decimal.NewFromInt(0), 322 | }, 323 | }, 324 | } 325 | 326 | for _, tt := range tests { 327 | t.Run(tt.name, func(t *testing.T) { 328 | given := []byte(` 329 | { 330 | "bids": [ 331 | { 332 | "id": "1", 333 | "traderId": "1", 334 | "side": "buy", 335 | "amount": "5", 336 | "price": "500" 337 | }, 338 | { 339 | "id": "2", 340 | "traderId": "2", 341 | "side": "buy", 342 | "amount": "1", 343 | "price": "400" 344 | }, 345 | { 346 | "id": "3", 347 | "traderId": "3", 348 | "side": "buy", 349 | "amount": "0.5", 350 | "price": "300" 351 | } 352 | ], 353 | "asks": [] 354 | } 355 | `) 356 | 357 | var book orderbook.OrderBook 358 | err := json.Unmarshal(given, &book) 359 | assert.Nil(t, err) 360 | 361 | trades, err := book.ProcessLimitOrder(tt.input.OrderID, tt.input.traderID, tt.input.side, tt.input.amount, tt.input.price) 362 | 363 | var errorStr string 364 | if err != nil { 365 | errorStr = err.Error() 366 | } 367 | 368 | s, err := json.Marshal(&snapshot{ 369 | Book: &book, 370 | Trades: trades, 371 | Err: errorStr, 372 | }) 373 | 374 | assert.Nil(t, err) 375 | cupaloy.SnapshotT(t, s) 376 | }) 377 | } 378 | } 379 | 380 | func benchmarkProcessLimitOrder(l int, b *testing.B) { 381 | pickSide := func(j int) orderbook.Side { 382 | if rand.Intn(100)%2 == 0 { 383 | return orderbook.Buy 384 | } else { 385 | return orderbook.Sell 386 | } 387 | } 388 | 389 | pickQty := func() decimal.Decimal { 390 | return decimal.NewFromInt(int64(rand.Intn(100))) 391 | } 392 | 393 | pickPrice := func() decimal.Decimal { 394 | return decimal.NewFromInt(int64(rand.Intn(100))) 395 | } 396 | 397 | book := orderbook.NewOrderBook("USD/BTC") 398 | 399 | for n := 0; n < b.N; n++ { 400 | for j := 0; j < l; j++ { 401 | book.ProcessLimitOrder(strconv.Itoa(j), strconv.Itoa(j), pickSide(j), pickQty(), pickPrice()) 402 | } 403 | } 404 | } 405 | 406 | func BenchmarkProcessLimitOrder100(b *testing.B) { benchmarkProcessLimitOrder(100, b) } 407 | func BenchmarkProcessLimitOrder1000(b *testing.B) { benchmarkProcessLimitOrder(1000, b) } 408 | func BenchmarkProcessLimitOrder10000(b *testing.B) { benchmarkProcessLimitOrder(10000, b) } 409 | func BenchmarkProcessLimitOrder100000(b *testing.B) { benchmarkProcessLimitOrder(100000, b) } 410 | func BenchmarkProcessLimitOrder1000000(b *testing.B) { benchmarkProcessLimitOrder(1000000, b) } 411 | -------------------------------------------------------------------------------- /order_book_market.go: -------------------------------------------------------------------------------- 1 | package orderbook 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/shopspring/decimal" 7 | ) 8 | 9 | // ProcessMarketOrder processes a market order. 10 | func (ob *OrderBook) ProcessMarketOrder(orderID, traderID string, side Side, amount, price decimal.Decimal) ([]*Trade, error) { 11 | defer func() { 12 | ob.version++ 13 | ob.Unlock() 14 | }() 15 | 16 | ob.Lock() 17 | 18 | if strings.TrimSpace(orderID) == "" { 19 | return nil, ErrInvalidOrderID 20 | } 21 | 22 | if ob.orders[orderID] != nil { 23 | return nil, ErrOrderAlreadyExists 24 | } 25 | 26 | if strings.TrimSpace(traderID) == "" { 27 | return nil, ErrInvalidTraderID 28 | } 29 | 30 | if amount.LessThanOrEqual(decimal.Zero) { 31 | return nil, ErrInvalidAmount 32 | } 33 | 34 | if price.LessThanOrEqual(decimal.Zero) { 35 | return nil, ErrInvalidPrice 36 | } 37 | 38 | var ( 39 | sideToProcess *OrderSide 40 | level *OrderQueue 41 | next func(decimal.Decimal) *OrderQueue 42 | ) 43 | 44 | if side == Buy { 45 | sideToProcess = ob.asks 46 | level = ob.asks.MinPriceQueue() 47 | next = ob.asks.GreaterThan 48 | } else { 49 | sideToProcess = ob.bids 50 | level = ob.bids.MaxPriceQueue() 51 | next = ob.bids.LessThan 52 | } 53 | 54 | amountToTrade := amount 55 | priceToTrade := price 56 | trades := make([]*Trade, 0) 57 | 58 | for level != nil && amountToTrade.GreaterThan(decimal.Zero) && priceToTrade.GreaterThan(decimal.Zero) { 59 | headOrderEl := level.Front() 60 | 61 | for headOrderEl != nil && amountToTrade.GreaterThan(decimal.Zero) && priceToTrade.GreaterThan(decimal.Zero) { 62 | headOrder := headOrderEl.Value.(*Order) 63 | 64 | if headOrder.traderID == traderID { 65 | headOrderEl = headOrderEl.Next() 66 | continue 67 | } 68 | 69 | if amount.GreaterThanOrEqual(headOrder.amount) { 70 | trades = append(trades, NewTrade(orderID, headOrder.id, headOrder.amount, headOrder.price)) 71 | amountToTrade = amountToTrade.Sub(headOrder.amount) 72 | priceToTrade = priceToTrade.Sub(headOrder.price) 73 | 74 | headOrderEl = headOrderEl.Next() 75 | ob.remove(headOrder.id) 76 | } else { 77 | trades = append(trades, NewTrade(orderID, headOrder.id, amountToTrade, headOrder.price)) 78 | sideToProcess.UpdateAmount(headOrderEl, headOrder.amount.Sub(amountToTrade)) 79 | amountToTrade = decimal.Zero 80 | priceToTrade = decimal.Zero 81 | 82 | headOrderEl = headOrderEl.Next() 83 | } 84 | } 85 | 86 | level = next(level.price) 87 | } 88 | 89 | return trades, nil 90 | } 91 | -------------------------------------------------------------------------------- /order_book_market_test.go: -------------------------------------------------------------------------------- 1 | package orderbook_test 2 | 3 | import ( 4 | "encoding/json" 5 | "testing" 6 | 7 | "github.com/bradleyjkemp/cupaloy/v2" 8 | "github.com/danielgatis/go-orderbook" 9 | "github.com/shopspring/decimal" 10 | "github.com/stretchr/testify/assert" 11 | ) 12 | 13 | func TestProcessMarketOrderBids(t *testing.T) { 14 | type input struct { 15 | OrderID string 16 | traderID string 17 | side orderbook.Side 18 | amount decimal.Decimal 19 | price decimal.Decimal 20 | } 21 | 22 | type snapshot struct { 23 | Book *orderbook.OrderBook 24 | Trades []*orderbook.Trade 25 | Err string 26 | } 27 | 28 | tests := []struct { 29 | name string 30 | input input 31 | }{} 32 | 33 | for _, tt := range tests { 34 | t.Run(tt.name, func(t *testing.T) { 35 | given := []byte(` 36 | { 37 | "bids": [], 38 | "asks": [ 39 | { 40 | "id": "1", 41 | "traderId": "1", 42 | "side": "sell", 43 | "amount": "5", 44 | "price": "500" 45 | }, 46 | { 47 | "id": "2", 48 | "traderId": "2", 49 | "side": "sell", 50 | "amount": "2", 51 | "price": "400" 52 | }, 53 | { 54 | "id": "3", 55 | "traderId": "3", 56 | "side": "sell", 57 | "amount": "1", 58 | "price": "300" 59 | } 60 | ] 61 | } 62 | `) 63 | 64 | var book orderbook.OrderBook 65 | err := json.Unmarshal(given, &book) 66 | assert.Nil(t, err) 67 | 68 | trades, err := book.ProcessMarketOrder(tt.input.OrderID, tt.input.traderID, tt.input.side, tt.input.amount, tt.input.price) 69 | 70 | var errorStr string 71 | if err != nil { 72 | errorStr = err.Error() 73 | } 74 | 75 | s, err := json.Marshal(&snapshot{ 76 | Book: &book, 77 | Trades: trades, 78 | Err: errorStr, 79 | }) 80 | 81 | assert.Nil(t, err) 82 | cupaloy.SnapshotT(t, s) 83 | }) 84 | } 85 | } 86 | 87 | func TestProcessMarketOrderAsks(t *testing.T) { 88 | type input struct { 89 | OrderID string 90 | traderID string 91 | side orderbook.Side 92 | amount decimal.Decimal 93 | price decimal.Decimal 94 | } 95 | 96 | type snapshot struct { 97 | Book *orderbook.OrderBook 98 | Trades []*orderbook.Trade 99 | Err string 100 | } 101 | 102 | tests := []struct { 103 | name string 104 | input input 105 | }{} 106 | 107 | for _, tt := range tests { 108 | t.Run(tt.name, func(t *testing.T) { 109 | given := []byte(` 110 | { 111 | "bids": [ 112 | { 113 | "id": "1", 114 | "traderId": "1", 115 | "side": "buy", 116 | "amount": "5", 117 | "price": "500" 118 | }, 119 | { 120 | "id": "2", 121 | "traderId": "2", 122 | "side": "buy", 123 | "amount": "1", 124 | "price": "400" 125 | }, 126 | { 127 | "id": "3", 128 | "traderId": "3", 129 | "side": "buy", 130 | "amount": "0.5", 131 | "price": "300" 132 | } 133 | ], 134 | "asks": [] 135 | } 136 | `) 137 | 138 | var book orderbook.OrderBook 139 | err := json.Unmarshal(given, &book) 140 | assert.Nil(t, err) 141 | 142 | trades, err := book.ProcessMarketOrder(tt.input.OrderID, tt.input.traderID, tt.input.side, tt.input.amount, tt.input.price) 143 | 144 | var errorStr string 145 | if err != nil { 146 | errorStr = err.Error() 147 | } 148 | 149 | s, err := json.Marshal(&snapshot{ 150 | Book: &book, 151 | Trades: trades, 152 | Err: errorStr, 153 | }) 154 | 155 | assert.Nil(t, err) 156 | cupaloy.SnapshotT(t, s) 157 | }) 158 | } 159 | } 160 | 161 | func TestProcessMarketOrderValidations(t *testing.T) { 162 | type input struct { 163 | OrderID string 164 | traderID string 165 | side orderbook.Side 166 | amount decimal.Decimal 167 | price decimal.Decimal 168 | } 169 | 170 | type snapshot struct { 171 | Book *orderbook.OrderBook 172 | Trades []*orderbook.Trade 173 | Err string 174 | } 175 | 176 | tests := []struct { 177 | name string 178 | input input 179 | }{ 180 | { 181 | name: "invalid order id", 182 | input: input{ 183 | OrderID: "", 184 | traderID: "4", 185 | side: orderbook.Sell, 186 | amount: decimal.NewFromInt(5), 187 | price: decimal.NewFromInt(550), 188 | }, 189 | }, 190 | { 191 | name: "order already exists", 192 | input: input{ 193 | OrderID: "1", 194 | traderID: "4", 195 | side: orderbook.Sell, 196 | amount: decimal.NewFromInt(5), 197 | price: decimal.NewFromInt(550), 198 | }, 199 | }, 200 | { 201 | name: "invalid trader id", 202 | input: input{ 203 | OrderID: "4", 204 | traderID: "", 205 | side: orderbook.Sell, 206 | amount: decimal.NewFromInt(5), 207 | price: decimal.NewFromInt(550), 208 | }, 209 | }, 210 | { 211 | name: "invalid amount", 212 | input: input{ 213 | OrderID: "4", 214 | traderID: "4", 215 | side: orderbook.Sell, 216 | amount: decimal.NewFromInt(0), 217 | price: decimal.NewFromInt(550), 218 | }, 219 | }, 220 | { 221 | name: "invalid price", 222 | input: input{ 223 | OrderID: "4", 224 | traderID: "4", 225 | side: orderbook.Sell, 226 | amount: decimal.NewFromInt(5), 227 | price: decimal.NewFromInt(0), 228 | }, 229 | }, 230 | } 231 | 232 | for _, tt := range tests { 233 | t.Run(tt.name, func(t *testing.T) { 234 | given := []byte(` 235 | { 236 | "bids": [ 237 | { 238 | "id": "1", 239 | "traderId": "1", 240 | "side": "buy", 241 | "amount": "5", 242 | "price": "500" 243 | }, 244 | { 245 | "id": "2", 246 | "traderId": "2", 247 | "side": "buy", 248 | "amount": "1", 249 | "price": "400" 250 | }, 251 | { 252 | "id": "3", 253 | "traderId": "3", 254 | "side": "buy", 255 | "amount": "0.5", 256 | "price": "300" 257 | } 258 | ], 259 | "asks": [] 260 | } 261 | `) 262 | 263 | var book orderbook.OrderBook 264 | err := json.Unmarshal(given, &book) 265 | assert.Nil(t, err) 266 | 267 | trades, err := book.ProcessMarketOrder(tt.input.OrderID, tt.input.traderID, tt.input.side, tt.input.amount, tt.input.price) 268 | 269 | var errorStr string 270 | if err != nil { 271 | errorStr = err.Error() 272 | } 273 | 274 | s, err := json.Marshal(&snapshot{ 275 | Book: &book, 276 | Trades: trades, 277 | Err: errorStr, 278 | }) 279 | 280 | assert.Nil(t, err) 281 | cupaloy.SnapshotT(t, s) 282 | }) 283 | } 284 | } 285 | -------------------------------------------------------------------------------- /order_book_postonly.go: -------------------------------------------------------------------------------- 1 | package orderbook 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/shopspring/decimal" 7 | ) 8 | 9 | // ProcessPostOnlyOrder processes a post only order. 10 | func (ob *OrderBook) ProcessPostOnlyOrder(orderID, traderID string, side Side, amount, price decimal.Decimal) ([]*Trade, error) { 11 | defer func() { 12 | ob.version++ 13 | ob.Unlock() 14 | }() 15 | 16 | ob.Lock() 17 | 18 | if strings.TrimSpace(orderID) == "" { 19 | return nil, ErrInvalidOrderID 20 | } 21 | 22 | if ob.orders[orderID] != nil { 23 | return nil, ErrOrderAlreadyExists 24 | } 25 | 26 | if strings.TrimSpace(traderID) == "" { 27 | return nil, ErrInvalidTraderID 28 | } 29 | 30 | if amount.LessThanOrEqual(decimal.Zero) { 31 | return nil, ErrInvalidAmount 32 | } 33 | 34 | if price.LessThanOrEqual(decimal.Zero) { 35 | return nil, ErrInvalidPrice 36 | } 37 | 38 | order := NewOrder(orderID, traderID, side, amount, price) 39 | 40 | if side == Buy { 41 | ob.orders[order.id] = ob.bids.Append(order) 42 | } else { 43 | ob.orders[order.id] = ob.asks.Append(order) 44 | } 45 | 46 | return make([]*Trade, 0), nil 47 | } 48 | -------------------------------------------------------------------------------- /order_book_postonly_test.go: -------------------------------------------------------------------------------- 1 | package orderbook_test 2 | 3 | import ( 4 | "encoding/json" 5 | "testing" 6 | 7 | "github.com/bradleyjkemp/cupaloy/v2" 8 | "github.com/danielgatis/go-orderbook" 9 | "github.com/shopspring/decimal" 10 | "github.com/stretchr/testify/assert" 11 | ) 12 | 13 | func TestProcessPostOnlyOrderBids(t *testing.T) { 14 | type input struct { 15 | OrderID string 16 | traderID string 17 | side orderbook.Side 18 | amount decimal.Decimal 19 | price decimal.Decimal 20 | } 21 | 22 | type snapshot struct { 23 | Book *orderbook.OrderBook 24 | Trades []*orderbook.Trade 25 | Err string 26 | } 27 | 28 | tests := []struct { 29 | name string 30 | input input 31 | }{ 32 | { 33 | name: "happy path - 1", 34 | input: input{ 35 | OrderID: "4", 36 | traderID: "4", 37 | side: orderbook.Buy, 38 | amount: decimal.NewFromInt(10), 39 | price: decimal.NewFromInt(100), 40 | }, 41 | }, 42 | } 43 | 44 | for _, tt := range tests { 45 | t.Run(tt.name, func(t *testing.T) { 46 | given := []byte(` 47 | { 48 | "bids": [], 49 | "asks": [] 50 | } 51 | `) 52 | 53 | var book orderbook.OrderBook 54 | err := json.Unmarshal(given, &book) 55 | assert.Nil(t, err) 56 | 57 | trades, err := book.ProcessPostOnlyOrder(tt.input.OrderID, tt.input.traderID, tt.input.side, tt.input.amount, tt.input.price) 58 | 59 | var errorStr string 60 | if err != nil { 61 | errorStr = err.Error() 62 | } 63 | 64 | s, err := json.Marshal(&snapshot{ 65 | Book: &book, 66 | Trades: trades, 67 | Err: errorStr, 68 | }) 69 | 70 | assert.Nil(t, err) 71 | cupaloy.SnapshotT(t, s) 72 | }) 73 | } 74 | } 75 | 76 | func TestProcessPostOnlyOrderAsks(t *testing.T) { 77 | type input struct { 78 | OrderID string 79 | traderID string 80 | side orderbook.Side 81 | amount decimal.Decimal 82 | price decimal.Decimal 83 | } 84 | 85 | type snapshot struct { 86 | Book *orderbook.OrderBook 87 | Trades []*orderbook.Trade 88 | Err string 89 | } 90 | 91 | tests := []struct { 92 | name string 93 | input input 94 | }{ 95 | { 96 | name: "happy path - 1", 97 | input: input{ 98 | OrderID: "4", 99 | traderID: "4", 100 | side: orderbook.Sell, 101 | amount: decimal.NewFromInt(5), 102 | price: decimal.NewFromInt(550), 103 | }, 104 | }, 105 | } 106 | 107 | for _, tt := range tests { 108 | t.Run(tt.name, func(t *testing.T) { 109 | given := []byte(` 110 | { 111 | "bids": [], 112 | "asks": [] 113 | } 114 | `) 115 | 116 | var book orderbook.OrderBook 117 | err := json.Unmarshal(given, &book) 118 | assert.Nil(t, err) 119 | 120 | trades, err := book.ProcessPostOnlyOrder(tt.input.OrderID, tt.input.traderID, tt.input.side, tt.input.amount, tt.input.price) 121 | 122 | var errorStr string 123 | if err != nil { 124 | errorStr = err.Error() 125 | } 126 | 127 | s, err := json.Marshal(&snapshot{ 128 | Book: &book, 129 | Trades: trades, 130 | Err: errorStr, 131 | }) 132 | 133 | assert.Nil(t, err) 134 | cupaloy.SnapshotT(t, s) 135 | }) 136 | } 137 | } 138 | 139 | func TestProcessPostOnlyOrderValidations(t *testing.T) { 140 | type input struct { 141 | OrderID string 142 | traderID string 143 | side orderbook.Side 144 | amount decimal.Decimal 145 | price decimal.Decimal 146 | } 147 | 148 | type snapshot struct { 149 | Book *orderbook.OrderBook 150 | Trades []*orderbook.Trade 151 | Err string 152 | } 153 | 154 | tests := []struct { 155 | name string 156 | input input 157 | }{ 158 | { 159 | name: "invalid order id", 160 | input: input{ 161 | OrderID: "", 162 | traderID: "4", 163 | side: orderbook.Sell, 164 | amount: decimal.NewFromInt(5), 165 | price: decimal.NewFromInt(550), 166 | }, 167 | }, 168 | { 169 | name: "order already exists", 170 | input: input{ 171 | OrderID: "1", 172 | traderID: "4", 173 | side: orderbook.Sell, 174 | amount: decimal.NewFromInt(5), 175 | price: decimal.NewFromInt(550), 176 | }, 177 | }, 178 | { 179 | name: "invalid trader id", 180 | input: input{ 181 | OrderID: "4", 182 | traderID: "", 183 | side: orderbook.Sell, 184 | amount: decimal.NewFromInt(5), 185 | price: decimal.NewFromInt(550), 186 | }, 187 | }, 188 | { 189 | name: "invalid amount", 190 | input: input{ 191 | OrderID: "4", 192 | traderID: "4", 193 | side: orderbook.Sell, 194 | amount: decimal.NewFromInt(0), 195 | price: decimal.NewFromInt(550), 196 | }, 197 | }, 198 | { 199 | name: "invalid price", 200 | input: input{ 201 | OrderID: "4", 202 | traderID: "4", 203 | side: orderbook.Sell, 204 | amount: decimal.NewFromInt(5), 205 | price: decimal.NewFromInt(0), 206 | }, 207 | }, 208 | } 209 | 210 | for _, tt := range tests { 211 | t.Run(tt.name, func(t *testing.T) { 212 | given := []byte(` 213 | { 214 | "bids": [ 215 | { 216 | "id": "1", 217 | "traderId": "1", 218 | "side": "buy", 219 | "amount": "5", 220 | "price": "500" 221 | }, 222 | { 223 | "id": "2", 224 | "traderId": "2", 225 | "side": "buy", 226 | "amount": "1", 227 | "price": "400" 228 | }, 229 | { 230 | "id": "3", 231 | "traderId": "3", 232 | "side": "buy", 233 | "amount": "0.5", 234 | "price": "300" 235 | } 236 | ], 237 | "asks": [] 238 | } 239 | `) 240 | 241 | var book orderbook.OrderBook 242 | err := json.Unmarshal(given, &book) 243 | assert.Nil(t, err) 244 | 245 | trades, err := book.ProcessPostOnlyOrder(tt.input.OrderID, tt.input.traderID, tt.input.side, tt.input.amount, tt.input.price) 246 | 247 | var errorStr string 248 | if err != nil { 249 | errorStr = err.Error() 250 | } 251 | 252 | s, err := json.Marshal(&snapshot{ 253 | Book: &book, 254 | Trades: trades, 255 | Err: errorStr, 256 | }) 257 | 258 | assert.Nil(t, err) 259 | cupaloy.SnapshotT(t, s) 260 | }) 261 | } 262 | } 263 | -------------------------------------------------------------------------------- /order_book_quote.go: -------------------------------------------------------------------------------- 1 | package orderbook 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/shopspring/decimal" 7 | ) 8 | 9 | // Quote quotes a market order. 10 | func (ob *OrderBook) Quote(traderID string, side Side, amount decimal.Decimal) (*Quote, error) { 11 | defer ob.RUnlock() 12 | ob.RLock() 13 | 14 | if strings.TrimSpace(traderID) == "" { 15 | return nil, ErrInvalidTraderID 16 | } 17 | 18 | if amount.LessThanOrEqual(decimal.Zero) { 19 | return nil, ErrInvalidAmount 20 | } 21 | 22 | price := decimal.Zero 23 | 24 | var ( 25 | level *OrderQueue 26 | next func(decimal.Decimal) *OrderQueue 27 | ) 28 | 29 | if side == Buy { 30 | level = ob.asks.MinPriceQueue() 31 | next = ob.asks.GreaterThan 32 | } else { 33 | level = ob.bids.MaxPriceQueue() 34 | next = ob.bids.LessThan 35 | } 36 | 37 | for level != nil && amount.GreaterThan(decimal.Zero) { 38 | headOrderEl := level.Front() 39 | 40 | for headOrderEl != nil && amount.GreaterThan(decimal.Zero) { 41 | headOrder := headOrderEl.Value.(*Order) 42 | 43 | if headOrder.traderID == traderID { 44 | headOrderEl = headOrderEl.Next() 45 | continue 46 | } 47 | 48 | if amount.GreaterThanOrEqual(headOrder.amount) { 49 | price = price.Add(headOrder.price.Mul(headOrder.amount)) 50 | amount = amount.Sub(headOrder.amount) 51 | } else { 52 | price = price.Add(headOrder.price.Mul(amount)) 53 | amount = decimal.Zero 54 | } 55 | 56 | headOrderEl = headOrderEl.Next() 57 | } 58 | 59 | level = next(level.price) 60 | } 61 | 62 | return NewQuote(price, amount), nil 63 | } 64 | -------------------------------------------------------------------------------- /order_book_quote_test.go: -------------------------------------------------------------------------------- 1 | package orderbook_test 2 | 3 | import ( 4 | "encoding/json" 5 | "testing" 6 | 7 | "github.com/bradleyjkemp/cupaloy/v2" 8 | "github.com/danielgatis/go-orderbook" 9 | "github.com/shopspring/decimal" 10 | "github.com/stretchr/testify/assert" 11 | ) 12 | 13 | func TestQuoteBids(t *testing.T) { 14 | type input struct { 15 | traderID string 16 | side orderbook.Side 17 | amount decimal.Decimal 18 | } 19 | 20 | type snapshot struct { 21 | Book *orderbook.OrderBook 22 | Result *orderbook.Quote 23 | Err string 24 | } 25 | 26 | tests := []struct { 27 | name string 28 | input input 29 | }{ 30 | { 31 | name: "happy path - 1", 32 | input: input{ 33 | traderID: "4", 34 | side: orderbook.Buy, 35 | amount: decimal.NewFromInt(6), 36 | }, 37 | }, 38 | { 39 | name: "happy path - 2", 40 | input: input{ 41 | traderID: "4", 42 | side: orderbook.Buy, 43 | amount: decimal.NewFromInt(1), 44 | }, 45 | }, 46 | { 47 | name: "empty book", 48 | input: input{ 49 | traderID: "4", 50 | side: orderbook.Sell, 51 | amount: decimal.NewFromInt(6), 52 | }, 53 | }, 54 | { 55 | name: "skip same trader", 56 | input: input{ 57 | traderID: "2", 58 | side: orderbook.Buy, 59 | amount: decimal.NewFromInt(6), 60 | }, 61 | }, 62 | } 63 | 64 | for _, tt := range tests { 65 | t.Run(tt.name, func(t *testing.T) { 66 | given := []byte(` 67 | { 68 | "bids": [], 69 | "asks": [ 70 | { 71 | "id": "1", 72 | "traderId": "1", 73 | "side": "sell", 74 | "amount": "2", 75 | "price": "200" 76 | }, 77 | { 78 | "id": "2", 79 | "traderId": "2", 80 | "side": "sell", 81 | "amount": "2", 82 | "price": "400" 83 | }, 84 | { 85 | "id": "3", 86 | "traderId": "3", 87 | "side": "sell", 88 | "amount": "2", 89 | "price": "600" 90 | } 91 | ] 92 | } 93 | `) 94 | 95 | var book orderbook.OrderBook 96 | err := json.Unmarshal(given, &book) 97 | assert.Nil(t, err) 98 | 99 | result, err := book.Quote(tt.input.traderID, tt.input.side, tt.input.amount) 100 | 101 | var errorStr string 102 | if err != nil { 103 | errorStr = err.Error() 104 | } 105 | 106 | s, err := json.Marshal(&snapshot{ 107 | Book: &book, 108 | Result: result, 109 | Err: errorStr, 110 | }) 111 | 112 | assert.Nil(t, err) 113 | cupaloy.SnapshotT(t, s) 114 | }) 115 | } 116 | } 117 | 118 | func TestQuoteAsks(t *testing.T) { 119 | type input struct { 120 | traderID string 121 | side orderbook.Side 122 | amount decimal.Decimal 123 | } 124 | 125 | type snapshot struct { 126 | Book *orderbook.OrderBook 127 | Result *orderbook.Quote 128 | Err string 129 | } 130 | 131 | tests := []struct { 132 | name string 133 | input input 134 | }{ 135 | { 136 | name: "happy path - 1", 137 | input: input{ 138 | traderID: "4", 139 | side: orderbook.Sell, 140 | amount: decimal.NewFromInt(6), 141 | }, 142 | }, 143 | { 144 | name: "happy path - 2", 145 | input: input{ 146 | traderID: "4", 147 | side: orderbook.Sell, 148 | amount: decimal.NewFromInt(1), 149 | }, 150 | }, 151 | { 152 | name: "empty book", 153 | input: input{ 154 | traderID: "4", 155 | side: orderbook.Sell, 156 | amount: decimal.NewFromInt(6), 157 | }, 158 | }, 159 | { 160 | name: "skip same trader", 161 | input: input{ 162 | traderID: "2", 163 | side: orderbook.Sell, 164 | amount: decimal.NewFromInt(6), 165 | }, 166 | }, 167 | } 168 | 169 | for _, tt := range tests { 170 | t.Run(tt.name, func(t *testing.T) { 171 | given := []byte(` 172 | { 173 | "bids": [ 174 | { 175 | "id": "1", 176 | "traderId": "1", 177 | "side": "buy", 178 | "amount": "2", 179 | "price": "200" 180 | }, 181 | { 182 | "id": "2", 183 | "traderId": "2", 184 | "side": "buy", 185 | "amount": "2", 186 | "price": "400" 187 | }, 188 | { 189 | "id": "3", 190 | "traderId": "3", 191 | "side": "buy", 192 | "amount": "2", 193 | "price": "600" 194 | } 195 | ], 196 | "asks": [] 197 | } 198 | `) 199 | 200 | var book orderbook.OrderBook 201 | err := json.Unmarshal(given, &book) 202 | assert.Nil(t, err) 203 | 204 | result, err := book.Quote(tt.input.traderID, tt.input.side, tt.input.amount) 205 | 206 | var errorStr string 207 | if err != nil { 208 | errorStr = err.Error() 209 | } 210 | 211 | s, err := json.Marshal(&snapshot{ 212 | Book: &book, 213 | Result: result, 214 | Err: errorStr, 215 | }) 216 | 217 | assert.Nil(t, err) 218 | cupaloy.SnapshotT(t, s) 219 | }) 220 | } 221 | } 222 | 223 | func TestQuoteValidations(t *testing.T) { 224 | type input struct { 225 | traderID string 226 | side orderbook.Side 227 | amount decimal.Decimal 228 | } 229 | 230 | type snapshot struct { 231 | Book *orderbook.OrderBook 232 | Result *orderbook.Quote 233 | Err string 234 | } 235 | 236 | tests := []struct { 237 | name string 238 | input input 239 | }{ 240 | { 241 | name: "invalid trader id", 242 | input: input{ 243 | traderID: "", 244 | side: orderbook.Buy, 245 | amount: decimal.NewFromInt(6), 246 | }, 247 | }, 248 | { 249 | name: "invalid amount", 250 | input: input{ 251 | traderID: "4", 252 | side: orderbook.Buy, 253 | amount: decimal.Zero, 254 | }, 255 | }, 256 | } 257 | 258 | for _, tt := range tests { 259 | t.Run(tt.name, func(t *testing.T) { 260 | given := []byte(` 261 | { 262 | "bids": [], 263 | "asks": [ 264 | { 265 | "id": "1", 266 | "traderId": "1", 267 | "side": "sell", 268 | "amount": "2", 269 | "price": "200" 270 | }, 271 | { 272 | "id": "2", 273 | "traderId": "2", 274 | "side": "sell", 275 | "amount": "2", 276 | "price": "400" 277 | }, 278 | { 279 | "id": "3", 280 | "traderId": "3", 281 | "side": "sell", 282 | "amount": "2", 283 | "price": "600" 284 | } 285 | ] 286 | } 287 | `) 288 | 289 | var book orderbook.OrderBook 290 | err := json.Unmarshal(given, &book) 291 | assert.Nil(t, err) 292 | 293 | result, err := book.Quote(tt.input.traderID, tt.input.side, tt.input.amount) 294 | 295 | var errorStr string 296 | if err != nil { 297 | errorStr = err.Error() 298 | } 299 | 300 | s, err := json.Marshal(&snapshot{ 301 | Book: &book, 302 | Result: result, 303 | Err: errorStr, 304 | }) 305 | 306 | assert.Nil(t, err) 307 | cupaloy.SnapshotT(t, s) 308 | }) 309 | } 310 | } 311 | -------------------------------------------------------------------------------- /order_book_restore.go: -------------------------------------------------------------------------------- 1 | package orderbook 2 | 3 | import ( 4 | "github.com/google/uuid" 5 | "github.com/shopspring/decimal" 6 | ) 7 | 8 | // Restore restores a new order book from raw representation. Raw format is a nested arrays like: raw[ask[price][amount]][bid[price][amount]][symbol]. 9 | func Restore(version uint64, raw [][][]string) *OrderBook { 10 | book := NewOrderBook(raw[0][0][1]) 11 | book.version = version 12 | 13 | for _, ask := range raw[1] { 14 | id := uuid.New() 15 | price := decimal.RequireFromString(ask[0]) 16 | amount := decimal.RequireFromString(ask[1]) 17 | book.ProcessPostOnlyOrder(id.String(), id.String(), Sell, amount, price) 18 | } 19 | 20 | for _, bid := range raw[2] { 21 | id := uuid.New() 22 | price := decimal.RequireFromString(bid[0]) 23 | amount := decimal.RequireFromString(bid[1]) 24 | book.ProcessPostOnlyOrder(id.String(), id.String(), Buy, amount, price) 25 | } 26 | 27 | return book 28 | } 29 | -------------------------------------------------------------------------------- /order_queue.go: -------------------------------------------------------------------------------- 1 | package orderbook 2 | 3 | import ( 4 | "container/list" 5 | 6 | "github.com/shopspring/decimal" 7 | ) 8 | 9 | // OrderQueue represents a queue of orders. 10 | type OrderQueue struct { 11 | amount decimal.Decimal 12 | price decimal.Decimal 13 | orders *list.List 14 | } 15 | 16 | // NewOrderQueue creates a new order queue. 17 | func NewOrderQueue(price decimal.Decimal) *OrderQueue { 18 | return &OrderQueue{decimal.Zero, price, list.New()} 19 | } 20 | 21 | // Price returns the queue price. 22 | func (oq *OrderQueue) Price() decimal.Decimal { 23 | return oq.price 24 | } 25 | 26 | // Amount returns the queue amout. 27 | func (oq *OrderQueue) Amount() decimal.Decimal { 28 | return oq.amount 29 | } 30 | 31 | // Orders returns the orders as a list. 32 | func (oq *OrderQueue) Orders() *list.List { 33 | return oq.orders 34 | } 35 | 36 | // Len returns the length. 37 | func (oq *OrderQueue) Len() int { 38 | return oq.orders.Len() 39 | } 40 | 41 | // Front returns the first order of the queue. 42 | func (oq *OrderQueue) Front() *list.Element { 43 | return oq.orders.Front() 44 | } 45 | 46 | // Back returns the last order of the queue. 47 | func (oq *OrderQueue) Back() *list.Element { 48 | return oq.orders.Back() 49 | } 50 | 51 | // Append appends an order. 52 | func (oq *OrderQueue) Append(order *Order) *list.Element { 53 | oq.amount = oq.amount.Add(order.amount) 54 | return oq.orders.PushBack(order) 55 | } 56 | 57 | // UpdateAmount updates an order amount. 58 | func (oq *OrderQueue) UpdateAmount(e *list.Element, amount decimal.Decimal) *Order { 59 | order := e.Value.(*Order) 60 | oq.amount = oq.amount.Sub(order.amount) 61 | oq.amount = oq.amount.Add(amount) 62 | order.amount = amount 63 | return order 64 | } 65 | 66 | // Remove removes an order. 67 | func (oq *OrderQueue) Remove(e *list.Element) *Order { 68 | oq.amount = oq.amount.Sub(e.Value.(*Order).amount) 69 | return oq.orders.Remove(e).(*Order) 70 | } 71 | -------------------------------------------------------------------------------- /order_side.go: -------------------------------------------------------------------------------- 1 | package orderbook 2 | 3 | import ( 4 | "container/list" 5 | "sort" 6 | 7 | "github.com/emirpasic/gods/examples/redblacktreeextended" 8 | "github.com/emirpasic/gods/trees/redblacktree" 9 | "github.com/shopspring/decimal" 10 | ) 11 | 12 | // OrderSide represents all the prices about bids or asks. 13 | type OrderSide struct { 14 | side Side 15 | tree *redblacktreeextended.RedBlackTreeExtended 16 | queue map[decimal.Decimal]*OrderQueue 17 | amount decimal.Decimal 18 | size int 19 | depth int 20 | } 21 | 22 | // NewOrderSide creates a new order side. 23 | func NewOrderSide(side Side) *OrderSide { 24 | tree := &redblacktreeextended.RedBlackTreeExtended{ 25 | Tree: redblacktree.NewWith(func(a, b interface{}) int { 26 | return a.(decimal.Decimal).Cmp(b.(decimal.Decimal)) 27 | }), 28 | } 29 | 30 | return &OrderSide{side, tree, make(map[decimal.Decimal]*OrderQueue), decimal.Zero, 0, 0} 31 | } 32 | 33 | // Append appends an order. 34 | func (os *OrderSide) Append(order *Order) *list.Element { 35 | price := order.price 36 | 37 | priceQueue, ok := os.queue[price] 38 | if !ok { 39 | priceQueue = NewOrderQueue(price) 40 | os.queue[order.price] = priceQueue 41 | os.tree.Put(price, priceQueue) 42 | os.depth++ 43 | } 44 | 45 | os.size++ 46 | os.amount = os.amount.Add(order.amount) 47 | return priceQueue.Append(order) 48 | } 49 | 50 | // Remove removes an order. 51 | func (os *OrderSide) Remove(e *list.Element) *Order { 52 | order := e.Value.(*Order) 53 | price := order.price 54 | 55 | priceQueue := os.queue[price] 56 | o := priceQueue.Remove(e) 57 | 58 | if priceQueue.Len() == 0 { 59 | delete(os.queue, price) 60 | os.tree.Remove(price) 61 | os.depth-- 62 | } 63 | 64 | os.size-- 65 | os.amount = os.amount.Sub(o.Amount()) 66 | return o 67 | } 68 | 69 | // UpdateAmount updates an order amount. 70 | func (os *OrderSide) UpdateAmount(e *list.Element, amount decimal.Decimal) *Order { 71 | order := e.Value.(*Order) 72 | price := order.price 73 | 74 | os.amount = os.amount.Sub(order.amount) 75 | os.amount = os.amount.Add(amount) 76 | 77 | priceQueue := os.queue[price] 78 | o := priceQueue.UpdateAmount(e, amount) 79 | 80 | return o 81 | } 82 | 83 | // MaxPriceQueue returns the order queue for the max price. 84 | func (os *OrderSide) MaxPriceQueue() *OrderQueue { 85 | if os.depth <= 0 { 86 | return nil 87 | } 88 | 89 | if value, found := os.tree.GetMax(); found { 90 | return value.(*OrderQueue) 91 | } 92 | 93 | return nil 94 | } 95 | 96 | // MinPriceQueue returns the order queue for the min price. 97 | func (os *OrderSide) MinPriceQueue() *OrderQueue { 98 | if os.depth <= 0 { 99 | return nil 100 | } 101 | 102 | if value, found := os.tree.GetMin(); found { 103 | return value.(*OrderQueue) 104 | } 105 | 106 | return nil 107 | } 108 | 109 | // LessThan returns the order queue for the price less than the given price. 110 | func (os *OrderSide) LessThan(price decimal.Decimal) *OrderQueue { 111 | tree := os.tree.Tree 112 | node := tree.Root 113 | 114 | var floor *redblacktree.Node 115 | for node != nil { 116 | if tree.Comparator(price, node.Key) > 0 { 117 | floor = node 118 | node = node.Right 119 | } else { 120 | node = node.Left 121 | } 122 | } 123 | 124 | if floor != nil { 125 | return floor.Value.(*OrderQueue) 126 | } 127 | 128 | return nil 129 | } 130 | 131 | // GreaterThan returns the order queue for the price greater than the given price. 132 | func (os *OrderSide) GreaterThan(price decimal.Decimal) *OrderQueue { 133 | tree := os.tree.Tree 134 | node := tree.Root 135 | 136 | var ceiling *redblacktree.Node 137 | for node != nil { 138 | if tree.Comparator(price, node.Key) < 0 { 139 | ceiling = node 140 | node = node.Left 141 | } else { 142 | node = node.Right 143 | } 144 | } 145 | 146 | if ceiling != nil { 147 | return ceiling.Value.(*OrderQueue) 148 | } 149 | 150 | return nil 151 | } 152 | 153 | // Orders return all the orders sorted by price. Desc when side is buy. Asc when side is sell. 154 | func (os *OrderSide) Orders() []*Order { 155 | orders := make([]*Order, 0) 156 | 157 | for _, price := range os.queue { 158 | iter := price.Front() 159 | 160 | for iter != nil { 161 | orders = append(orders, iter.Value.(*Order)) 162 | iter = iter.Next() 163 | } 164 | } 165 | 166 | if os.side == Buy { 167 | sort.Slice(orders[:], func(i, j int) bool { 168 | return orders[i].price.GreaterThan(orders[j].price) 169 | }) 170 | } else { 171 | sort.Slice(orders[:], func(i, j int) bool { 172 | return orders[i].price.LessThan(orders[j].price) 173 | }) 174 | } 175 | 176 | return orders 177 | } 178 | -------------------------------------------------------------------------------- /price_level.go: -------------------------------------------------------------------------------- 1 | package orderbook 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | 7 | "github.com/shopspring/decimal" 8 | ) 9 | 10 | var _ json.Marshaler = (*PriceLevel)(nil) 11 | var _ json.Unmarshaler = (*PriceLevel)(nil) 12 | 13 | // PriceLevel takes a count of how many assets have that price. 14 | type PriceLevel struct { 15 | price decimal.Decimal 16 | amount decimal.Decimal 17 | } 18 | 19 | // NewPriceLevel creates a new price level. 20 | func NewPriceLevel(price, amount decimal.Decimal) *PriceLevel { 21 | return &PriceLevel{price, amount} 22 | } 23 | 24 | // Price returns the price. 25 | func (p *PriceLevel) Price() decimal.Decimal { 26 | return p.price 27 | } 28 | 29 | // Amount returns the amount. 30 | func (p *PriceLevel) Amount() decimal.Decimal { 31 | return p.amount 32 | } 33 | 34 | // MarshalJSON implements json.Marshaler. 35 | func (p *PriceLevel) MarshalJSON() ([]byte, error) { 36 | return json.Marshal( 37 | &struct { 38 | Amount decimal.Decimal `json:"amount"` 39 | Price decimal.Decimal `json:"price"` 40 | }{ 41 | p.amount, 42 | p.price, 43 | }, 44 | ) 45 | } 46 | 47 | // UnmarshalJSON implements json.Unmarshaler. 48 | func (p *PriceLevel) UnmarshalJSON(data []byte) error { 49 | obj := struct { 50 | Amount decimal.Decimal `json:"amount"` 51 | Price decimal.Decimal `json:"price"` 52 | }{} 53 | 54 | if err := json.Unmarshal(data, &obj); err != nil { 55 | return fmt.Errorf("PriceLevel.Unmarshal(%s): %w", data, err) 56 | } 57 | 58 | p.amount = obj.Amount 59 | p.price = obj.Price 60 | 61 | return nil 62 | } 63 | -------------------------------------------------------------------------------- /quote.go: -------------------------------------------------------------------------------- 1 | package orderbook 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | 7 | "github.com/shopspring/decimal" 8 | ) 9 | 10 | var _ json.Marshaler = (*Quote)(nil) 11 | var _ json.Unmarshaler = (*Quote)(nil) 12 | 13 | // Quote represents a quote. 14 | type Quote struct { 15 | price decimal.Decimal 16 | remainingAmount decimal.Decimal 17 | } 18 | 19 | // NewQuote creates a new quote. 20 | func NewQuote(price, remainingAmount decimal.Decimal) *Quote { 21 | return &Quote{price, remainingAmount} 22 | } 23 | 24 | // Price returns the price. 25 | func (m *Quote) Price() decimal.Decimal { 26 | return m.price 27 | } 28 | 29 | // RemainingAmount returns the remaining amount. 30 | func (m *Quote) RemainingAmount() decimal.Decimal { 31 | return m.remainingAmount 32 | } 33 | 34 | // MarshalJSON implements json.Marshaler. 35 | func (m *Quote) MarshalJSON() ([]byte, error) { 36 | return json.Marshal( 37 | &struct { 38 | Price decimal.Decimal `json:"price"` 39 | RemainingAmount decimal.Decimal `json:"remainingAmount"` 40 | }{ 41 | m.price, 42 | m.remainingAmount, 43 | }, 44 | ) 45 | } 46 | 47 | // UnmarshalJSON implements json.Unmarshaler. 48 | func (m *Quote) UnmarshalJSON(data []byte) error { 49 | obj := struct { 50 | Price decimal.Decimal `json:"price"` 51 | RemainingAmount decimal.Decimal `json:"remainingAmount"` 52 | }{} 53 | 54 | if err := json.Unmarshal(data, &obj); err != nil { 55 | return fmt.Errorf("Quote.Unmarshal(%s): %w", data, err) 56 | } 57 | 58 | m.price = obj.Price 59 | m.remainingAmount = obj.RemainingAmount 60 | 61 | return nil 62 | } 63 | -------------------------------------------------------------------------------- /side.go: -------------------------------------------------------------------------------- 1 | package orderbook 2 | 3 | import ( 4 | "encoding/json" 5 | "reflect" 6 | ) 7 | 8 | var _ json.Marshaler = (*Side)(nil) 9 | var _ json.Unmarshaler = (*Side)(nil) 10 | 11 | // A Side of the order. 12 | type Side int 13 | 14 | const ( 15 | // Sell for asks 16 | Sell Side = 0 17 | 18 | // Buy for bids 19 | Buy Side = 1 20 | ) 21 | 22 | // String implements fmt.Stringer. 23 | func (s Side) String() string { 24 | if s == Buy { 25 | return "buy" 26 | } 27 | 28 | return "sell" 29 | } 30 | 31 | // MarshalJSON implements json.Marshaler. 32 | func (s Side) MarshalJSON() ([]byte, error) { 33 | return []byte(`"` + s.String() + `"`), nil 34 | } 35 | 36 | // UnmarshalJSON implements json.Unmarshaler. 37 | func (s *Side) UnmarshalJSON(data []byte) error { 38 | switch string(data) { 39 | case `"buy"`: 40 | *s = Buy 41 | case `"sell"`: 42 | *s = Sell 43 | default: 44 | return &json.UnsupportedValueError{ 45 | Value: reflect.New(reflect.TypeOf(data)), 46 | Str: string(data), 47 | } 48 | } 49 | 50 | return nil 51 | } 52 | -------------------------------------------------------------------------------- /trade.go: -------------------------------------------------------------------------------- 1 | package orderbook 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | 7 | "github.com/shopspring/decimal" 8 | ) 9 | 10 | var _ json.Marshaler = (*Trade)(nil) 11 | var _ json.Unmarshaler = (*Trade)(nil) 12 | 13 | // Trade represents a match between a maker order and a taker order. 14 | type Trade struct { 15 | takerOrderID string 16 | makerOrderID string 17 | amount decimal.Decimal 18 | price decimal.Decimal 19 | } 20 | 21 | // TakerOrderID returns the taker order id. 22 | func (t *Trade) TakerOrderID() string { 23 | return t.takerOrderID 24 | } 25 | 26 | // MakerOrderID returns the maker order id. 27 | func (t *Trade) MakerOrderID() string { 28 | return t.makerOrderID 29 | } 30 | 31 | // Amount returns the amount. 32 | func (t *Trade) Amount() decimal.Decimal { 33 | return t.amount 34 | } 35 | 36 | // Price returns the price. 37 | func (t *Trade) Price() decimal.Decimal { 38 | return t.price 39 | } 40 | 41 | // NewTrade creates a new trade. 42 | func NewTrade(takerOrderID, makerOrderID string, amount, price decimal.Decimal) *Trade { 43 | return &Trade{takerOrderID, makerOrderID, amount, price} 44 | } 45 | 46 | // MarshalJSON implements json.Marshaler. 47 | func (t *Trade) MarshalJSON() ([]byte, error) { 48 | return json.Marshal( 49 | &struct { 50 | TakerOrderID string `json:"takerOrderId"` 51 | MakerOrderID string `json:"makerOrderId"` 52 | Amount decimal.Decimal `json:"amount"` 53 | Price decimal.Decimal `json:"price"` 54 | }{ 55 | t.takerOrderID, 56 | t.makerOrderID, 57 | t.amount, 58 | t.price, 59 | }, 60 | ) 61 | } 62 | 63 | // UnmarshalJSON implements json.Unmarshaler. 64 | func (t *Trade) UnmarshalJSON(data []byte) error { 65 | obj := struct { 66 | TakerOrderID string `json:"takerOrderId"` 67 | MakerOrderID string `json:"makerOrderId"` 68 | Amount decimal.Decimal `json:"amount"` 69 | Price decimal.Decimal `json:"price"` 70 | }{} 71 | 72 | if err := json.Unmarshal(data, &obj); err != nil { 73 | return fmt.Errorf("Trade.Unmarshal(%s): %w", data, err) 74 | } 75 | 76 | t.takerOrderID = obj.TakerOrderID 77 | t.makerOrderID = obj.MakerOrderID 78 | t.amount = obj.Amount 79 | t.price = obj.Price 80 | 81 | return nil 82 | } 83 | --------------------------------------------------------------------------------