@@ -30,6 +30,9 @@ const (
30
30
DefaultRequestExpiry = "60m"
31
31
32
32
defaultDCRRatesPort = "7778"
33
+
34
+ aggregatedOrderbookKey = "aggregated"
35
+ orderbookKey = "depth"
33
36
)
34
37
35
38
var grpcClient dcrrates.DCRRatesClient
@@ -125,6 +128,14 @@ func (state *ExchangeBotState) BtcToFiat(btc float64) float64 {
125
128
return state .BtcPrice * btc
126
129
}
127
130
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
+
128
139
// ExchangeState doesn't have a Token field, so if the states are returned as a
129
140
// slice (rather than ranging over a map), a token is needed.
130
141
type tokenedExchange struct {
@@ -148,6 +159,39 @@ func (state *ExchangeBotState) VolumeOrderedExchanges() []*tokenedExchange {
148
159
return xcList
149
160
}
150
161
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
+
151
195
// FiatIndices maps currency codes to Bitcoin exchange rates.
152
196
type FiatIndices map [string ]float64
153
197
@@ -202,7 +246,6 @@ type depthResponse struct {
202
246
type versionedChart struct {
203
247
chartID string
204
248
dataID int
205
- time time.Time
206
249
chart []byte
207
250
}
208
251
@@ -687,7 +730,8 @@ func (bot *ExchangeBot) updateExchange(update *ExchangeUpdate) error {
687
730
}
688
731
}
689
732
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 ))
691
735
}
692
736
bot .currentState .DcrBtc [update .Token ] = update .State
693
737
return bot .updateState ()
@@ -881,48 +925,144 @@ func (bot *ExchangeBot) QuickSticks(token string, rawBin string) ([]byte, error)
881
925
vChart := & versionedChart {
882
926
chartID : chartID ,
883
927
dataID : bestVersion ,
884
- time : expiration ,
885
928
chart : chart ,
886
929
}
887
930
888
931
bot .versionedCharts [chartID ] = vChart
889
932
return vChart .chart , nil
890
933
}
891
934
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
+
892
1027
// QuickDepth returns the up-to-date depth chart data for the specified
893
1028
// 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 )
896
1031
data , bestVersion , isGood := bot .fetchFromCache (chartID )
897
1032
if isGood {
898
1033
return data , nil
899
1034
}
900
1035
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
+ })
911
1058
}
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
- })
918
1059
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 )
920
1061
}
921
1062
922
1063
vChart := & versionedChart {
923
1064
chartID : chartID ,
924
1065
dataID : bestVersion ,
925
- time : time .Unix (state .Depth .Time , 0 ),
926
1066
chart : chart ,
927
1067
}
928
1068
0 commit comments