Skip to content

Commit 4ee1782

Browse files
buck54321chappjc
authored andcommitted
exchanges: add an aggregated orderbook
This adds an aggregated orderbook. Combines the order book data into a single aggregated orderbook available at (ExchangeBot).QuickDepths, with special token 'aggregated'. A stacked depth chart is added to /charts. The depth chart gap width is also now displayed.
1 parent 1929b0c commit 4ee1782

File tree

8 files changed

+678
-92
lines changed

8 files changed

+678
-92
lines changed

exchanges/bot.go

+163-23
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,9 @@ const (
3030
DefaultRequestExpiry = "60m"
3131

3232
defaultDCRRatesPort = "7778"
33+
34+
aggregatedOrderbookKey = "aggregated"
35+
orderbookKey = "depth"
3336
)
3437

3538
var grpcClient dcrrates.DCRRatesClient
@@ -125,6 +128,14 @@ func (state *ExchangeBotState) BtcToFiat(btc float64) float64 {
125128
return state.BtcPrice * btc
126129
}
127130

131+
// FiatToBtc converts an amount of fiat in the default index to a value in BTC.
132+
func (state *ExchangeBotState) FiatToBtc(fiat float64) float64 {
133+
if state.BtcPrice == 0 {
134+
return -1
135+
}
136+
return fiat / state.BtcPrice
137+
}
138+
128139
// ExchangeState doesn't have a Token field, so if the states are returned as a
129140
// slice (rather than ranging over a map), a token is needed.
130141
type tokenedExchange struct {
@@ -148,6 +159,39 @@ func (state *ExchangeBotState) VolumeOrderedExchanges() []*tokenedExchange {
148159
return xcList
149160
}
150161

162+
// A price bin for the aggregated orderbook. The Volumes array will be length
163+
// N = number of depth-reporting exchanges. If any exchange has an order book
164+
// entry at price Price, then an agBookPt should be created. If a different
165+
// exchange does not have an order at Price, there will be a 0 in Volumes at
166+
// the exchange's index. An exchange's index in Volumes is set by its index
167+
// in (aggregateOrderbook).Tokens.
168+
type agBookPt struct {
169+
Price float64 `json:"price"`
170+
Volumes []float64 `json:"volumes"`
171+
}
172+
173+
// The aggregated depth data. Similar to DepthData, but with agBookPts instead.
174+
// For aggregateData, the Time will indicate the most recent time at which an
175+
// exchange with non-nil DepthData was updated.
176+
type aggregateData struct {
177+
Time int64 `json:"time"`
178+
Bids []agBookPt `json:"bids"`
179+
Asks []agBookPt `json:"asks"`
180+
}
181+
182+
// An aggregated orderbook. Combines all data from the DepthData of each
183+
// Exchange. For aggregatedOrderbook, the Expiration is set to the time of the
184+
// most recent DepthData update plus an additional (ExchangeBot).RequestExpiry,
185+
// though new data may be available before then.
186+
type aggregateOrderbook struct {
187+
BtcIndex string `json:"btc_index"`
188+
Price float64 `json:"price"`
189+
Tokens []string `json:"tokens"`
190+
UpdateTimes []int64 `json:"update_times"`
191+
Data aggregateData `json:"data"`
192+
Expiration int64 `json:"expiration"`
193+
}
194+
151195
// FiatIndices maps currency codes to Bitcoin exchange rates.
152196
type FiatIndices map[string]float64
153197

@@ -202,7 +246,6 @@ type depthResponse struct {
202246
type versionedChart struct {
203247
chartID string
204248
dataID int
205-
time time.Time
206249
chart []byte
207250
}
208251

@@ -687,7 +730,8 @@ func (bot *ExchangeBot) updateExchange(update *ExchangeUpdate) error {
687730
}
688731
}
689732
if update.State.Depth != nil {
690-
bot.incrementChart(genCacheID(update.Token, "depth"))
733+
bot.incrementChart(genCacheID(update.Token, orderbookKey))
734+
bot.incrementChart(genCacheID(aggregatedOrderbookKey, orderbookKey))
691735
}
692736
bot.currentState.DcrBtc[update.Token] = update.State
693737
return bot.updateState()
@@ -881,48 +925,144 @@ func (bot *ExchangeBot) QuickSticks(token string, rawBin string) ([]byte, error)
881925
vChart := &versionedChart{
882926
chartID: chartID,
883927
dataID: bestVersion,
884-
time: expiration,
885928
chart: chart,
886929
}
887930

888931
bot.versionedCharts[chartID] = vChart
889932
return vChart.chart, nil
890933
}
891934

935+
// Move the DepthPoint array into a map whose entries are agBookPt, inserting
936+
// the (DepthPoint).Quantity values at xcIndex of Volumes. Creates Volumes
937+
// if it does not yet exist.
938+
func mapifyDepthPoints(source []DepthPoint, target map[int64]agBookPt, xcIndex, ptCount int) {
939+
for _, pt := range source {
940+
k := eightPtKey(pt.Price)
941+
_, found := target[k]
942+
if !found {
943+
target[k] = agBookPt{
944+
Price: pt.Price,
945+
Volumes: make([]float64, ptCount),
946+
}
947+
}
948+
target[k].Volumes[xcIndex] = pt.Quantity
949+
}
950+
}
951+
952+
// A list of eightPtKey keys from an orderbook tracking map. Used for sorting.
953+
func agBookMapKeys(book map[int64]agBookPt) []int64 {
954+
keys := make([]int64, 0, len(book))
955+
for k := range book {
956+
keys = append(keys, k)
957+
}
958+
return keys
959+
}
960+
961+
// After the aggregate orderbook map is fully assembled, sort the keys and
962+
// process the map into a list of lists.
963+
func unmapAgOrders(book map[int64]agBookPt, reverse bool) []agBookPt {
964+
orderedBook := make([]agBookPt, 0, len(book))
965+
keys := agBookMapKeys(book)
966+
if reverse {
967+
sort.Slice(keys, func(i, j int) bool { return keys[j] < keys[i] })
968+
} else {
969+
sort.Slice(keys, func(i, j int) bool { return keys[i] < keys[j] })
970+
}
971+
for _, k := range keys {
972+
orderedBook = append(orderedBook, book[k])
973+
}
974+
return orderedBook
975+
}
976+
977+
// Make an aggregate orderbook from all depth data.
978+
func (bot *ExchangeBot) aggOrderbook() *aggregateOrderbook {
979+
state := bot.State()
980+
if state == nil {
981+
return nil
982+
}
983+
bids := make(map[int64]agBookPt)
984+
asks := make(map[int64]agBookPt)
985+
986+
oldestUpdate := time.Now().Unix()
987+
var newestTime int64
988+
// First, grab the tokens for exchanges with depth data so that they can be
989+
// counted and sorted alphabetically.
990+
tokens := []string{}
991+
for token, xcState := range state.DcrBtc {
992+
if !xcState.HasDepth() {
993+
continue
994+
}
995+
tokens = append(tokens, token)
996+
}
997+
numXc := len(tokens)
998+
updateTimes := make([]int64, 0, numXc)
999+
sort.Strings(tokens)
1000+
for i, token := range tokens {
1001+
xcState := state.DcrBtc[token]
1002+
depth := xcState.Depth
1003+
if depth.Time < oldestUpdate {
1004+
oldestUpdate = depth.Time
1005+
}
1006+
if depth.Time > newestTime {
1007+
newestTime = depth.Time
1008+
}
1009+
updateTimes = append(updateTimes, depth.Time)
1010+
mapifyDepthPoints(depth.Bids, bids, i, numXc)
1011+
mapifyDepthPoints(depth.Asks, asks, i, numXc)
1012+
}
1013+
return &aggregateOrderbook{
1014+
Tokens: tokens,
1015+
BtcIndex: bot.BtcIndex,
1016+
Price: state.Price,
1017+
UpdateTimes: updateTimes,
1018+
Data: aggregateData{
1019+
Time: newestTime,
1020+
Bids: unmapAgOrders(bids, true),
1021+
Asks: unmapAgOrders(asks, false),
1022+
},
1023+
Expiration: oldestUpdate + int64(bot.RequestExpiry.Seconds()),
1024+
}
1025+
}
1026+
8921027
// QuickDepth returns the up-to-date depth chart data for the specified
8931028
// exchange, pulling from the cache if appropriate.
894-
func (bot *ExchangeBot) QuickDepth(token string) ([]byte, error) {
895-
chartID := genCacheID(token, "depth")
1029+
func (bot *ExchangeBot) QuickDepth(token string) (chart []byte, err error) {
1030+
chartID := genCacheID(token, orderbookKey)
8961031
data, bestVersion, isGood := bot.fetchFromCache(chartID)
8971032
if isGood {
8981033
return data, nil
8991034
}
9001035

901-
// No hit on cache. Re-encode.
902-
903-
bot.mtx.Lock()
904-
defer bot.mtx.Unlock()
905-
state, found := bot.currentState.DcrBtc[token]
906-
if !found {
907-
return []byte{}, fmt.Errorf("Failed to find DCR exchange state for %s", token)
908-
}
909-
if state.Depth == nil {
910-
return []byte{}, fmt.Errorf("Failed to find depth for %s", token)
1036+
if token == aggregatedOrderbookKey {
1037+
agDepth := bot.aggOrderbook()
1038+
if agDepth == nil {
1039+
return nil, fmt.Errorf("Failed to find depth for %s", token)
1040+
}
1041+
chart, err = bot.jsonify(agDepth)
1042+
} else {
1043+
bot.mtx.Lock()
1044+
defer bot.mtx.Unlock()
1045+
xcState, found := bot.currentState.DcrBtc[token]
1046+
if !found {
1047+
return nil, fmt.Errorf("Failed to find DCR exchange state for %s", token)
1048+
}
1049+
if xcState.Depth == nil {
1050+
return nil, fmt.Errorf("Failed to find depth for %s", token)
1051+
}
1052+
chart, err = bot.jsonify(&depthResponse{
1053+
BtcIndex: bot.BtcIndex,
1054+
Price: bot.currentState.Price,
1055+
Data: xcState.Depth,
1056+
Expiration: xcState.Depth.Time + int64(bot.RequestExpiry.Seconds()),
1057+
})
9111058
}
912-
chart, err := bot.jsonify(&depthResponse{
913-
BtcIndex: bot.BtcIndex,
914-
Price: bot.currentState.Price,
915-
Data: state.Depth,
916-
Expiration: state.Depth.Time + int64(bot.RequestExpiry.Seconds()),
917-
})
9181059
if err != nil {
919-
return []byte{}, fmt.Errorf("JSON encode error for %s depth chart", token)
1060+
return nil, fmt.Errorf("JSON encode error for %s depth chart", token)
9201061
}
9211062

9221063
vChart := &versionedChart{
9231064
chartID: chartID,
9241065
dataID: bestVersion,
925-
time: time.Unix(state.Depth.Time, 0),
9261066
chart: chart,
9271067
}
9281068

exchanges/exchanges.go

+10
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ package exchanges
66
import (
77
"encoding/json"
88
"fmt"
9+
"math"
910
"net/http"
1011
"sort"
1112
"strconv"
@@ -232,6 +233,15 @@ func (sticks Candlesticks) needsUpdate(bin candlestickKey) bool {
232233
return time.Now().After(lastStick.Start.Add(bin.duration() * 2))
233234
}
234235

236+
// Most exchanges bin price values on a float precision of 8 decimal points.
237+
// eightPtKey reliably converts the float to an int64 that is unique for a price
238+
// bin.
239+
func eightPtKey(rate float64) int64 {
240+
// Bittrex values are sometimes parsed with floating point error that can
241+
// affect the key if you simply use int64(rate).
242+
return int64(math.Round(rate * 1e8))
243+
}
244+
235245
// ExchangeState is the simple template for a price. The only member that is
236246
// guaranteed is a price. For Decred exchanges, the volumes will also be
237247
// populated.

exchanges/exchanges_test.go

+37-8
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import (
1717
"github.com/decred/slog"
1818
)
1919

20-
func testExchanges(asSlave bool, t *testing.T) {
20+
func testExchanges(asSlave, quickTest bool, t *testing.T) {
2121
UseLogger(slog.NewBackend(os.Stdout).Logger("EXE"))
2222
log.SetLevel(slog.LevelTrace)
2323

@@ -59,22 +59,41 @@ func testExchanges(asSlave bool, t *testing.T) {
5959
t.Fatalf("Error creating bot. Shutting down: %v", err)
6060
}
6161

62+
updateCounts := make(map[string]int)
63+
for token := range bot.Exchanges {
64+
updateCounts[token] = 0
65+
}
66+
logUpdate := func(token string) {
67+
if !quickTest {
68+
return
69+
}
70+
updateCounts[token]++
71+
lowest := updateCounts[token]
72+
for _, v := range updateCounts {
73+
if v < lowest {
74+
lowest = v
75+
}
76+
}
77+
if lowest > 0 {
78+
log.Infof("Quick test conditions met. Shutting down early")
79+
shutdown()
80+
}
81+
}
82+
6283
wg.Add(1)
6384
go bot.Start(ctx, wg)
6485

6586
quitTimer := time.NewTimer(time.Minute * 7)
66-
var updated []string
67-
6887
ch := bot.UpdateChannels()
6988

7089
out:
7190
for {
7291
select {
7392
case update := <-ch.Exchange:
74-
updated = append(updated, update.Token)
93+
logUpdate(update.Token)
7594
log.Infof("Update received from exchange %s", update.Token)
7695
case update := <-ch.Index:
77-
updated = append(updated, update.Token)
96+
logUpdate(update.Token)
7897
log.Infof("Update received from index %s", update.Token)
7998
case <-ch.Quit:
8099
t.Errorf("Exchange bot has quit.")
@@ -91,7 +110,7 @@ out:
91110
}
92111

93112
logMissing := func(token string) {
94-
for _, xc := range updated {
113+
for xc := range updateCounts {
95114
if xc == token {
96115
return
97116
}
@@ -103,6 +122,12 @@ out:
103122
logMissing(token)
104123
}
105124

125+
depth, err := bot.QuickDepth(aggregatedOrderbookKey)
126+
if err != nil {
127+
t.Errorf("Failed to create aggregated orderbook")
128+
}
129+
log.Infof("aggregated orderbook size: %d kiB", len(depth)/1024)
130+
106131
log.Infof("%d Bitcoin indices available", len(bot.AvailableIndices()))
107132
log.Infof("final state is %d kiB", len(bot.StateBytes())/1024)
108133

@@ -111,13 +136,17 @@ out:
111136
}
112137

113138
func TestExchanges(t *testing.T) {
114-
testExchanges(false, t)
139+
testExchanges(false, false, t)
115140
}
116141

117142
func TestSlaveBot(t *testing.T) {
118143
// Points to DCRData on local machine port 7778.
119144
// Start server with --exchange-refresh=1m --exchange-expiry=2m
120-
testExchanges(true, t)
145+
testExchanges(true, false, t)
146+
}
147+
148+
func TestQuickExchanges(t *testing.T) {
149+
testExchanges(false, true, t)
121150
}
122151

123152
var initialPoloniexOrderbook = []byte(`[

public/images/exchange-logos-25.svg

+1-1
Loading

0 commit comments

Comments
 (0)