mirror of
https://github.com/d0zingcat/gocryptotrader.git
synced 2026-06-01 15:10:44 +00:00
* Websocket: Remove IsInit and simplify SetProxyAddress IsInit was basically the same as IsConnected. Any time Connect was called both would be set to true. Any time we had a disconnect they'd both be set to false Shutdown() incorrectly didn't setInit(false) SetProxyAddress simplified to only reconnect a connected Websocket. Any other state means it hasn't been Connected, or it's about to reconnect anyway. There's no handling for IsConnecting previously, either, so I've wrapped that behind the main mutex. * Websocket: Expand and Assertify tests * Websocket: Simplify state transistions * Websocket: Simplify Connecting/Connected state * Websocket: Tests and errors for websocket * Websocket: Make WebsocketNotEnabled a real error This allows for testing and avoids the repetition. If each returned error is a error.New() you can never use errors.Is() * Websocket: Add more testable errors * Websocket: Improve GenerateMessageID test Testing just the last id doesn't feel very robust * Websocket: Protect Setup() from races * Websocket: Use atomics instead of mutex This was spurred by looking at the setState call in trafficMonitor and the effect on blocking and efficiency. With the new atomic types in Go 1.19, and the small types in use here, atomics should be safe for our usage. bools should be truly atomic, and uint32 is atomic when the accepted value range is less than one byte/uint8 since that can be written atomicly by concurrent processors. Maybe that's not even a factor any more, however we don't even have to worry enough to check. * Websocket: Fix and simplify traffic monitor trafficMonitor had a check throttle at the end of the for loop to stop it just gobbling the (blocking) trafficAlert channel non-stop. That makes sense, except that nothing is sent to the trafficAlert channel if there's no listener. So that means that it's out by one second on the trafficAlert, because any traffic received during the pause is doesn't try to send a traffic alert. The unstopped timer is deliberately leaked for later GC when shutdown. It won't delay/block anything, and it's a trivial memory leak during an infrequent event. Deliberately Choosing to recreate the timer each time instead of using Stop, drain and reset * Websocket: Split traficMonitor test on behaviours * Websocket: Remove trafficMonitor connected status trafficMonitor does not need to set the connection to be connected. Connect() does that. Anything after that should result in a full shutdown and restart. It can't and shouldn't become connected unexpectedly, and this is most likely a race anyway. Also dropped trafficCheckInterval to 100ms to mitigate races of traffic alerts being buffered for too long. * Websocket: Set disconnected earlier in Shutdown This caused a possible race where state is still connected, but we start to trigger interested actors via ShutdownC and Wait. They may check state and then call Shutdown again, such as trafficMonitor * Websocket: Wait 5s for slow tests to pass traffic draining Keep getting failures upstream on test rigs. Think they can be very contended, so this pushes the boundary right out to 5s
1190 lines
29 KiB
Go
1190 lines
29 KiB
Go
package poloniex
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gorilla/websocket"
|
|
"github.com/thrasher-corp/gocryptotrader/common"
|
|
"github.com/thrasher-corp/gocryptotrader/common/crypto"
|
|
"github.com/thrasher-corp/gocryptotrader/currency"
|
|
"github.com/thrasher-corp/gocryptotrader/exchanges/account"
|
|
"github.com/thrasher-corp/gocryptotrader/exchanges/asset"
|
|
"github.com/thrasher-corp/gocryptotrader/exchanges/order"
|
|
"github.com/thrasher-corp/gocryptotrader/exchanges/orderbook"
|
|
"github.com/thrasher-corp/gocryptotrader/exchanges/stream"
|
|
"github.com/thrasher-corp/gocryptotrader/exchanges/subscription"
|
|
"github.com/thrasher-corp/gocryptotrader/exchanges/ticker"
|
|
"github.com/thrasher-corp/gocryptotrader/exchanges/trade"
|
|
"github.com/thrasher-corp/gocryptotrader/log"
|
|
)
|
|
|
|
const (
|
|
poloniexWebsocketAddress = "wss://api2.poloniex.com"
|
|
wsAccountNotificationID = 1000
|
|
wsTickerDataID = 1002
|
|
ws24HourExchangeVolumeID = 1003
|
|
wsHeartbeat = 1010
|
|
|
|
accountNotificationBalanceUpdate = "b"
|
|
accountNotificationOrderUpdate = "o"
|
|
accountNotificationPendingOrder = "p"
|
|
accountNotificationOrderLimitCreated = "n"
|
|
accountNotificationTrades = "t"
|
|
accountNotificationKilledOrder = "k"
|
|
accountNotificationMarginPosition = "m"
|
|
|
|
orderbookInitial = "i"
|
|
orderbookUpdate = "o"
|
|
tradeUpdate = "t"
|
|
)
|
|
|
|
var (
|
|
errNotEnoughData = errors.New("element length not adequate to process")
|
|
errTypeAssertionFailure = errors.New("type assertion failure")
|
|
errIDNotFoundInPairMap = errors.New("id not associated with currency pair map")
|
|
errIDNotFoundInCodeMap = errors.New("id not associated with currency code map")
|
|
)
|
|
|
|
// WsConnect initiates a websocket connection
|
|
func (p *Poloniex) WsConnect() error {
|
|
if !p.Websocket.IsEnabled() || !p.IsEnabled() {
|
|
return stream.ErrWebsocketNotEnabled
|
|
}
|
|
var dialer websocket.Dialer
|
|
err := p.Websocket.Conn.Dial(&dialer, http.Header{})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
err = p.loadCurrencyDetails(context.TODO())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
p.Websocket.Wg.Add(1)
|
|
go p.wsReadData()
|
|
|
|
return nil
|
|
}
|
|
|
|
// TODO: Create routine to refresh list every day/week(?) for production
|
|
func (p *Poloniex) loadCurrencyDetails(ctx context.Context) error {
|
|
if p.details.isInitial() {
|
|
ticks, err := p.GetTicker(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
err = p.details.loadPairs(ticks)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
currs, err := p.GetCurrencies(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
err = p.details.loadCodes(currs)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// wsReadData handles data from the websocket connection
|
|
func (p *Poloniex) wsReadData() {
|
|
defer p.Websocket.Wg.Done()
|
|
for {
|
|
resp := p.Websocket.Conn.ReadMessage()
|
|
if resp.Raw == nil {
|
|
return
|
|
}
|
|
err := p.wsHandleData(resp.Raw)
|
|
if err != nil {
|
|
p.Websocket.DataHandler <- fmt.Errorf("%s: %w", p.Name, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (p *Poloniex) wsHandleData(respRaw []byte) error {
|
|
var result interface{}
|
|
err := json.Unmarshal(respRaw, &result)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
data, ok := result.([]interface{})
|
|
if !ok {
|
|
return fmt.Errorf("%w data is not []interface{}",
|
|
errTypeAssertionFailure)
|
|
}
|
|
|
|
if len(data) == 0 {
|
|
return nil
|
|
}
|
|
if len(data) == 2 {
|
|
// subscription acknowledgement
|
|
// TODO: Add in subscriber ack
|
|
return nil
|
|
}
|
|
|
|
channelID, ok := data[0].(float64)
|
|
if !ok {
|
|
return fmt.Errorf("%w channel id is not of type float64",
|
|
errTypeAssertionFailure)
|
|
}
|
|
switch channelID {
|
|
case ws24HourExchangeVolumeID, wsHeartbeat:
|
|
return nil
|
|
case wsAccountNotificationID:
|
|
var notificationsArray []interface{}
|
|
notificationsArray, ok = data[2].([]interface{})
|
|
if !ok {
|
|
return fmt.Errorf("%w account notification is not a []interface{}",
|
|
errTypeAssertionFailure)
|
|
}
|
|
for i := range notificationsArray {
|
|
var notification []interface{}
|
|
notification, ok = (notificationsArray[i]).([]interface{})
|
|
if !ok {
|
|
return fmt.Errorf("%w notification array element is not a []interface{}",
|
|
errTypeAssertionFailure)
|
|
}
|
|
var updateType string
|
|
updateType, ok = notification[0].(string)
|
|
if !ok {
|
|
return fmt.Errorf("%w update type is not a string",
|
|
errTypeAssertionFailure)
|
|
}
|
|
|
|
switch updateType {
|
|
case accountNotificationPendingOrder:
|
|
err = p.processAccountPendingOrder(notification)
|
|
if err != nil {
|
|
return fmt.Errorf("account notification pending order: %w", err)
|
|
}
|
|
case accountNotificationOrderUpdate:
|
|
err = p.processAccountOrderUpdate(notification)
|
|
if err != nil {
|
|
return fmt.Errorf("account notification order update: %w", err)
|
|
}
|
|
case accountNotificationOrderLimitCreated:
|
|
err = p.processAccountOrderLimit(notification)
|
|
if err != nil {
|
|
return fmt.Errorf("account notification limit order creation: %w", err)
|
|
}
|
|
case accountNotificationBalanceUpdate:
|
|
err = p.processAccountBalanceUpdate(notification)
|
|
if err != nil {
|
|
return fmt.Errorf("account notification balance update: %w", err)
|
|
}
|
|
case accountNotificationTrades:
|
|
err = p.processAccountTrades(notification)
|
|
if err != nil {
|
|
return fmt.Errorf("account notification trades: %w", err)
|
|
}
|
|
case accountNotificationKilledOrder:
|
|
err = p.processAccountKilledOrder(notification)
|
|
if err != nil {
|
|
return fmt.Errorf("account notification killed order: %w", err)
|
|
}
|
|
case accountNotificationMarginPosition:
|
|
err = p.processAccountMarginPosition(notification)
|
|
if err != nil {
|
|
return fmt.Errorf("account notification margin position: %w", err)
|
|
}
|
|
default:
|
|
return fmt.Errorf("unhandled account update: %s", string(respRaw))
|
|
}
|
|
}
|
|
return nil
|
|
case wsTickerDataID:
|
|
err = p.wsHandleTickerData(data)
|
|
if err != nil {
|
|
return fmt.Errorf("websocket ticker process: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
priceAggBook, ok := data[2].([]interface{})
|
|
if !ok {
|
|
return fmt.Errorf("%w price aggregated book not []interface{}",
|
|
errTypeAssertionFailure)
|
|
}
|
|
|
|
for x := range priceAggBook {
|
|
subData, ok := priceAggBook[x].([]interface{})
|
|
if !ok {
|
|
return fmt.Errorf("%w price aggregated book element not []interface{}",
|
|
errTypeAssertionFailure)
|
|
}
|
|
|
|
updateIdent, ok := subData[0].(string)
|
|
if !ok {
|
|
return fmt.Errorf("%w update identifier not a string",
|
|
errTypeAssertionFailure)
|
|
}
|
|
|
|
switch updateIdent {
|
|
case orderbookInitial:
|
|
err = p.WsProcessOrderbookSnapshot(subData)
|
|
if err != nil {
|
|
return fmt.Errorf("websocket process orderbook snapshot: %w", err)
|
|
}
|
|
case orderbookUpdate:
|
|
var pair currency.Pair
|
|
pair, err = p.details.GetPair(channelID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var seqNo float64
|
|
seqNo, ok = data[1].(float64)
|
|
if !ok {
|
|
return fmt.Errorf("%w sequence number is not a float64",
|
|
errTypeAssertionFailure)
|
|
}
|
|
err = p.WsProcessOrderbookUpdate(seqNo, subData, pair)
|
|
if err != nil {
|
|
return fmt.Errorf("websocket process orderbook update: %w", err)
|
|
}
|
|
case tradeUpdate:
|
|
err = p.processTrades(channelID, subData)
|
|
if err != nil {
|
|
return fmt.Errorf("websocket process trades update: %w", err)
|
|
}
|
|
default:
|
|
p.Websocket.DataHandler <- stream.UnhandledMessageWarning{
|
|
Message: p.Name + stream.UnhandledMessage + string(respRaw),
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (p *Poloniex) wsHandleTickerData(data []interface{}) error {
|
|
tickerData, ok := data[2].([]interface{})
|
|
if !ok {
|
|
return fmt.Errorf("%w ticker data is not []interface{}",
|
|
errTypeAssertionFailure)
|
|
}
|
|
|
|
currencyID, ok := tickerData[0].(float64)
|
|
if !ok {
|
|
return fmt.Errorf("%w currency ID not float64", errTypeAssertionFailure)
|
|
}
|
|
|
|
pair, err := p.details.GetPair(currencyID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
enabled, err := p.GetEnabledPairs(asset.Spot)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if !enabled.Contains(pair, true) {
|
|
return nil
|
|
}
|
|
|
|
tlp, ok := tickerData[1].(string)
|
|
if !ok {
|
|
return fmt.Errorf("%w last price not string", errTypeAssertionFailure)
|
|
}
|
|
|
|
lastPrice, err := strconv.ParseFloat(tlp, 64)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
la, ok := tickerData[2].(string)
|
|
if !ok {
|
|
return fmt.Errorf("%w lowest ask price not string",
|
|
errTypeAssertionFailure)
|
|
}
|
|
|
|
lowestAsk, err := strconv.ParseFloat(la, 64)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
hb, ok := tickerData[3].(string)
|
|
if !ok {
|
|
return fmt.Errorf("%w highest bid price not string",
|
|
errTypeAssertionFailure)
|
|
}
|
|
|
|
highestBid, err := strconv.ParseFloat(hb, 64)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
bcv, ok := tickerData[5].(string)
|
|
if !ok {
|
|
return fmt.Errorf("%w base currency volume not string",
|
|
errTypeAssertionFailure)
|
|
}
|
|
|
|
baseCurrencyVolume24H, err := strconv.ParseFloat(bcv, 64)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
qcv, ok := tickerData[6].(string)
|
|
if !ok {
|
|
return fmt.Errorf("%w quote currency volume not string",
|
|
errTypeAssertionFailure)
|
|
}
|
|
|
|
quoteCurrencyVolume24H, err := strconv.ParseFloat(qcv, 64)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Unused variables below, can add later if needed:
|
|
// percentageChange, ok := tickerData[4].(string)
|
|
// Not integrating isFrozen with currency details as this will slow down
|
|
// the sync RW mutex (can use REST calls for now).
|
|
// isFrozen, ok := tickerData[7].(float64) // == 1 means it is frozen
|
|
// highestTradeIn24Hm, ok := tickerData[8].(string)
|
|
// lowestTradePrice24H, ok := tickerData[9].(string)
|
|
|
|
p.Websocket.DataHandler <- &ticker.Price{
|
|
ExchangeName: p.Name,
|
|
Volume: baseCurrencyVolume24H,
|
|
QuoteVolume: quoteCurrencyVolume24H,
|
|
High: highestBid,
|
|
Low: lowestAsk,
|
|
Bid: highestBid,
|
|
Ask: lowestAsk,
|
|
Last: lastPrice,
|
|
AssetType: asset.Spot,
|
|
Pair: pair,
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// WsProcessOrderbookSnapshot processes a new orderbook snapshot into a local
|
|
// of orderbooks
|
|
func (p *Poloniex) WsProcessOrderbookSnapshot(data []interface{}) error {
|
|
subDataMap, ok := data[1].(map[string]interface{})
|
|
if !ok {
|
|
return fmt.Errorf("%w subData element is not map[string]interface{}",
|
|
errTypeAssertionFailure)
|
|
}
|
|
|
|
pMap, ok := subDataMap["currencyPair"]
|
|
if !ok {
|
|
return errors.New("could not find currency pair in map")
|
|
}
|
|
|
|
pair, ok := pMap.(string)
|
|
if !ok {
|
|
return fmt.Errorf("%w subData element is not map[string]interface{}",
|
|
errTypeAssertionFailure)
|
|
}
|
|
|
|
if len(data) < 3 {
|
|
return fmt.Errorf("%w for pair %v", errNotEnoughData, pair)
|
|
}
|
|
|
|
ts, ok := data[2].(string)
|
|
if !ok {
|
|
return common.GetTypeAssertError("string", data[2], "timestamp string")
|
|
}
|
|
|
|
tsMilli, err := strconv.ParseInt(ts, 10, 64)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
oMap, ok := subDataMap["orderBook"]
|
|
if !ok {
|
|
return errors.New("could not find orderbook data in map")
|
|
}
|
|
|
|
ob, ok := oMap.([]interface{})
|
|
if !ok {
|
|
return fmt.Errorf("%w orderbook data is not []interface{}",
|
|
errTypeAssertionFailure)
|
|
}
|
|
|
|
if len(ob) != 2 {
|
|
return errNotEnoughData
|
|
}
|
|
|
|
askData, ok := ob[0].(map[string]interface{})
|
|
if !ok {
|
|
return fmt.Errorf("%w ask data is not map[string]interface{}",
|
|
errTypeAssertionFailure)
|
|
}
|
|
|
|
bidData, ok := ob[1].(map[string]interface{})
|
|
if !ok {
|
|
return fmt.Errorf("%w bid data is not map[string]interface{}",
|
|
errTypeAssertionFailure)
|
|
}
|
|
|
|
var book orderbook.Base
|
|
book.Asks = make(orderbook.Items, 0, len(askData))
|
|
for price, volume := range askData {
|
|
var p float64
|
|
p, err = strconv.ParseFloat(price, 64)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
v, ok := volume.(string)
|
|
if !ok {
|
|
return fmt.Errorf("%w ask volume data not string",
|
|
errTypeAssertionFailure)
|
|
}
|
|
var a float64
|
|
a, err = strconv.ParseFloat(v, 64)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
book.Asks = append(book.Asks, orderbook.Item{Price: p, Amount: a})
|
|
}
|
|
|
|
book.Bids = make(orderbook.Items, 0, len(bidData))
|
|
for price, volume := range bidData {
|
|
var p float64
|
|
p, err = strconv.ParseFloat(price, 64)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
v, ok := volume.(string)
|
|
if !ok {
|
|
return fmt.Errorf("%w bid volume data not string",
|
|
errTypeAssertionFailure)
|
|
}
|
|
var a float64
|
|
a, err = strconv.ParseFloat(v, 64)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
book.Bids = append(book.Bids, orderbook.Item{Price: p, Amount: a})
|
|
}
|
|
|
|
// Both sides are completely out of order - sort needs to be used
|
|
book.Asks.SortAsks()
|
|
book.Bids.SortBids()
|
|
book.Asset = asset.Spot
|
|
book.VerifyOrderbook = p.CanVerifyOrderbook
|
|
book.LastUpdated = time.UnixMilli(tsMilli)
|
|
book.Pair, err = currency.NewPairFromString(pair)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
book.Exchange = p.Name
|
|
|
|
return p.Websocket.Orderbook.LoadSnapshot(&book)
|
|
}
|
|
|
|
// WsProcessOrderbookUpdate processes new orderbook updates
|
|
func (p *Poloniex) WsProcessOrderbookUpdate(sequenceNumber float64, data []interface{}, pair currency.Pair) error {
|
|
if len(data) < 5 {
|
|
return errNotEnoughData
|
|
}
|
|
|
|
ps, ok := data[2].(string)
|
|
if !ok {
|
|
return fmt.Errorf("%w price not string", errTypeAssertionFailure)
|
|
}
|
|
price, err := strconv.ParseFloat(ps, 64)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
vs, ok := data[3].(string)
|
|
if !ok {
|
|
return fmt.Errorf("%w volume not string", errTypeAssertionFailure)
|
|
}
|
|
volume, err := strconv.ParseFloat(vs, 64)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
bs, ok := data[1].(float64)
|
|
if !ok {
|
|
return fmt.Errorf("%w buysell not float64", errTypeAssertionFailure)
|
|
}
|
|
|
|
ts, ok := data[4].(string)
|
|
if !ok {
|
|
return common.GetTypeAssertError("string", data[2], "timestamp string")
|
|
}
|
|
|
|
tsMilli, err := strconv.ParseInt(ts, 10, 64)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
update := &orderbook.Update{
|
|
Pair: pair,
|
|
Asset: asset.Spot,
|
|
UpdateID: int64(sequenceNumber),
|
|
UpdateTime: time.UnixMilli(tsMilli),
|
|
}
|
|
if bs == 1 {
|
|
update.Bids = []orderbook.Item{{Price: price, Amount: volume}}
|
|
} else {
|
|
update.Asks = []orderbook.Item{{Price: price, Amount: volume}}
|
|
}
|
|
return p.Websocket.Orderbook.Update(update)
|
|
}
|
|
|
|
// GenerateDefaultSubscriptions Adds default subscriptions to websocket to be handled by ManageSubscriptions()
|
|
func (p *Poloniex) GenerateDefaultSubscriptions() ([]subscription.Subscription, error) {
|
|
enabledPairs, err := p.GetEnabledPairs(asset.Spot)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
subscriptions := make([]subscription.Subscription, 0, len(enabledPairs))
|
|
subscriptions = append(subscriptions, subscription.Subscription{
|
|
Channel: strconv.FormatInt(wsTickerDataID, 10),
|
|
})
|
|
|
|
if p.IsWebsocketAuthenticationSupported() {
|
|
subscriptions = append(subscriptions, subscription.Subscription{
|
|
Channel: strconv.FormatInt(wsAccountNotificationID, 10),
|
|
})
|
|
}
|
|
|
|
for j := range enabledPairs {
|
|
enabledPairs[j].Delimiter = currency.UnderscoreDelimiter
|
|
subscriptions = append(subscriptions, subscription.Subscription{
|
|
Channel: "orderbook",
|
|
Pair: enabledPairs[j],
|
|
Asset: asset.Spot,
|
|
})
|
|
}
|
|
return subscriptions, nil
|
|
}
|
|
|
|
// Subscribe sends a websocket message to receive data from the channel
|
|
func (p *Poloniex) Subscribe(sub []subscription.Subscription) error {
|
|
var creds *account.Credentials
|
|
if p.IsWebsocketAuthenticationSupported() {
|
|
var err error
|
|
creds, err = p.GetCredentials(context.TODO())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
var errs error
|
|
channels:
|
|
for i := range sub {
|
|
subscriptionRequest := WsCommand{
|
|
Command: "subscribe",
|
|
}
|
|
switch {
|
|
case strings.EqualFold(strconv.FormatInt(wsAccountNotificationID, 10),
|
|
sub[i].Channel) && creds != nil:
|
|
err := p.wsSendAuthorisedCommand(creds.Secret, creds.Key, "subscribe")
|
|
if err != nil {
|
|
errs = common.AppendError(errs, err)
|
|
continue channels
|
|
}
|
|
p.Websocket.AddSuccessfulSubscriptions(sub[i])
|
|
continue channels
|
|
case strings.EqualFold(strconv.FormatInt(wsTickerDataID, 10),
|
|
sub[i].Channel):
|
|
subscriptionRequest.Channel = wsTickerDataID
|
|
default:
|
|
subscriptionRequest.Channel = sub[i].Pair.String()
|
|
}
|
|
|
|
err := p.Websocket.Conn.SendJSONMessage(subscriptionRequest)
|
|
if err != nil {
|
|
errs = common.AppendError(errs, err)
|
|
continue
|
|
}
|
|
|
|
p.Websocket.AddSuccessfulSubscriptions(sub[i])
|
|
}
|
|
if errs != nil {
|
|
return errs
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Unsubscribe sends a websocket message to stop receiving data from the channel
|
|
func (p *Poloniex) Unsubscribe(unsub []subscription.Subscription) error {
|
|
var creds *account.Credentials
|
|
if p.IsWebsocketAuthenticationSupported() {
|
|
var err error
|
|
creds, err = p.GetCredentials(context.TODO())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
var errs error
|
|
channels:
|
|
for i := range unsub {
|
|
unsubscriptionRequest := WsCommand{
|
|
Command: "unsubscribe",
|
|
}
|
|
switch {
|
|
case strings.EqualFold(strconv.FormatInt(wsAccountNotificationID, 10),
|
|
unsub[i].Channel) && creds != nil:
|
|
err := p.wsSendAuthorisedCommand(creds.Secret, creds.Key, "unsubscribe")
|
|
if err != nil {
|
|
errs = common.AppendError(errs, err)
|
|
continue channels
|
|
}
|
|
p.Websocket.RemoveSubscriptions(unsub[i])
|
|
continue channels
|
|
case strings.EqualFold(strconv.FormatInt(wsTickerDataID, 10),
|
|
unsub[i].Channel):
|
|
unsubscriptionRequest.Channel = wsTickerDataID
|
|
default:
|
|
unsubscriptionRequest.Channel = unsub[i].Pair.String()
|
|
}
|
|
err := p.Websocket.Conn.SendJSONMessage(unsubscriptionRequest)
|
|
if err != nil {
|
|
errs = common.AppendError(errs, err)
|
|
continue
|
|
}
|
|
p.Websocket.RemoveSubscriptions(unsub[i])
|
|
}
|
|
if errs != nil {
|
|
return errs
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (p *Poloniex) wsSendAuthorisedCommand(secret, key, command string) error {
|
|
nonce := fmt.Sprintf("nonce=%v", time.Now().UnixNano())
|
|
hmac, err := crypto.GetHMAC(crypto.HashSHA512,
|
|
[]byte(nonce),
|
|
[]byte(secret))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
request := WsAuthorisationRequest{
|
|
Command: command,
|
|
Channel: 1000,
|
|
Sign: crypto.HexEncodeToString(hmac),
|
|
Key: key,
|
|
Payload: nonce,
|
|
}
|
|
return p.Websocket.Conn.SendJSONMessage(request)
|
|
}
|
|
|
|
func (p *Poloniex) processAccountMarginPosition(notification []interface{}) error {
|
|
if len(notification) < 5 {
|
|
return errNotEnoughData
|
|
}
|
|
|
|
orderID, ok := notification[1].(float64)
|
|
if !ok {
|
|
return fmt.Errorf("%w order id not float64", errTypeAssertionFailure)
|
|
}
|
|
|
|
currencyID, ok := notification[2].(float64)
|
|
if !ok {
|
|
return fmt.Errorf("%w currency id not float64", errTypeAssertionFailure)
|
|
}
|
|
code, err := p.details.GetCode(currencyID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
a, ok := notification[3].(string)
|
|
if !ok {
|
|
return fmt.Errorf("%w amount not string", errTypeAssertionFailure)
|
|
}
|
|
|
|
amount, err := strconv.ParseFloat(a, 64)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// null returned so ok check is not needed
|
|
clientOrderID, _ := notification[4].(string)
|
|
|
|
// Temp struct for margin position changes
|
|
p.Websocket.DataHandler <- struct {
|
|
OrderID string
|
|
Code currency.Code
|
|
Amount float64
|
|
ClientOrderID string
|
|
}{
|
|
OrderID: strconv.FormatFloat(orderID, 'f', -1, 64),
|
|
Code: code,
|
|
Amount: amount,
|
|
ClientOrderID: clientOrderID,
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (p *Poloniex) processAccountPendingOrder(notification []interface{}) error {
|
|
if len(notification) < 7 {
|
|
return errNotEnoughData
|
|
}
|
|
|
|
orderID, ok := notification[1].(float64)
|
|
if !ok {
|
|
return fmt.Errorf("%w order id not float64", errTypeAssertionFailure)
|
|
}
|
|
|
|
currencyID, ok := notification[2].(float64)
|
|
if !ok {
|
|
return fmt.Errorf("%w currency id not float64", errTypeAssertionFailure)
|
|
}
|
|
pair, err := p.details.GetPair(currencyID)
|
|
if err != nil {
|
|
if !errors.Is(err, errIDNotFoundInPairMap) {
|
|
return err
|
|
}
|
|
log.Errorf(log.WebsocketMgr,
|
|
"%s - Unknown currency pair ID. Currency will appear as the pair ID: '%v'",
|
|
p.Name,
|
|
currencyID)
|
|
}
|
|
|
|
price, ok := notification[3].(string)
|
|
if !ok {
|
|
return fmt.Errorf("%w price not string", errTypeAssertionFailure)
|
|
}
|
|
orderPrice, err := strconv.ParseFloat(price, 64)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
amount, ok := notification[4].(string)
|
|
if !ok {
|
|
return fmt.Errorf("%w amount not string", errTypeAssertionFailure)
|
|
}
|
|
orderAmount, err := strconv.ParseFloat(amount, 64)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
side, ok := notification[5].(string)
|
|
if !ok {
|
|
return fmt.Errorf("%w order type not string", errTypeAssertionFailure)
|
|
}
|
|
orderSide := order.Buy
|
|
if side == "0" {
|
|
orderSide = order.Sell
|
|
}
|
|
|
|
// null returned so ok check is not needed
|
|
clientOrderID, _ := notification[6].(string)
|
|
|
|
p.Websocket.DataHandler <- &order.Detail{
|
|
Exchange: p.Name,
|
|
OrderID: strconv.FormatFloat(orderID, 'f', -1, 64),
|
|
Pair: pair,
|
|
AssetType: asset.Spot,
|
|
Side: orderSide,
|
|
Price: orderPrice,
|
|
Amount: orderAmount,
|
|
RemainingAmount: orderAmount,
|
|
ClientOrderID: clientOrderID,
|
|
Status: order.Pending,
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (p *Poloniex) processAccountOrderUpdate(notification []interface{}) error {
|
|
if len(notification) < 5 {
|
|
return errNotEnoughData
|
|
}
|
|
|
|
orderID, ok := notification[1].(float64)
|
|
if !ok {
|
|
return fmt.Errorf("%w order id not float64", errTypeAssertionFailure)
|
|
}
|
|
|
|
a, ok := notification[2].(string)
|
|
if !ok {
|
|
return fmt.Errorf("%w amount not string", errTypeAssertionFailure)
|
|
}
|
|
amount, err := strconv.ParseFloat(a, 64)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
oType, ok := notification[3].(string)
|
|
if !ok {
|
|
return fmt.Errorf("%w order type not string", errTypeAssertionFailure)
|
|
}
|
|
|
|
var oStatus order.Status
|
|
var cancelledAmount float64
|
|
if oType == "c" {
|
|
if len(notification) < 6 {
|
|
return errNotEnoughData
|
|
}
|
|
cancel, ok := notification[5].(string)
|
|
if !ok {
|
|
return fmt.Errorf("%w cancel amount not string", errTypeAssertionFailure)
|
|
}
|
|
|
|
cancelledAmount, err = strconv.ParseFloat(cancel, 64)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if amount > 0 {
|
|
oStatus = order.PartiallyCancelled
|
|
} else {
|
|
oStatus = order.Cancelled
|
|
}
|
|
} else {
|
|
if amount > 0 {
|
|
oStatus = order.PartiallyFilled
|
|
} else {
|
|
oStatus = order.Filled
|
|
}
|
|
}
|
|
|
|
// null returned so ok check is not needed
|
|
clientOrderID, _ := notification[4].(string)
|
|
|
|
p.Websocket.DataHandler <- &order.Detail{
|
|
Exchange: p.Name,
|
|
RemainingAmount: cancelledAmount,
|
|
Amount: amount + cancelledAmount,
|
|
ExecutedAmount: amount,
|
|
OrderID: strconv.FormatFloat(orderID, 'f', -1, 64),
|
|
Type: order.Limit,
|
|
Status: oStatus,
|
|
AssetType: asset.Spot,
|
|
ClientOrderID: clientOrderID,
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (p *Poloniex) processAccountOrderLimit(notification []interface{}) error {
|
|
if len(notification) != 9 {
|
|
return errNotEnoughData
|
|
}
|
|
|
|
currencyID, ok := notification[1].(float64)
|
|
if !ok {
|
|
return fmt.Errorf("%w currency ID not string", errTypeAssertionFailure)
|
|
}
|
|
pair, err := p.details.GetPair(currencyID)
|
|
if err != nil {
|
|
if !errors.Is(err, errIDNotFoundInPairMap) {
|
|
return err
|
|
}
|
|
log.Errorf(log.WebsocketMgr,
|
|
"%s - Unknown currency pair ID. Currency will appear as the pair ID: '%v'",
|
|
p.Name,
|
|
currencyID)
|
|
}
|
|
|
|
orderID, ok := notification[2].(float64)
|
|
if !ok {
|
|
return fmt.Errorf("%w order ID not float64", errTypeAssertionFailure)
|
|
}
|
|
|
|
side, ok := notification[3].(string)
|
|
if !ok {
|
|
return fmt.Errorf("%w order type not string", errTypeAssertionFailure)
|
|
}
|
|
orderSide := order.Buy
|
|
if side == "0" {
|
|
orderSide = order.Sell
|
|
}
|
|
|
|
rate, ok := notification[4].(string)
|
|
if !ok {
|
|
return fmt.Errorf("%w rate not string", errTypeAssertionFailure)
|
|
}
|
|
orderPrice, err := strconv.ParseFloat(rate, 64)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
amount, ok := notification[5].(string)
|
|
if !ok {
|
|
return fmt.Errorf("%w amount not string", errTypeAssertionFailure)
|
|
}
|
|
orderAmount, err := strconv.ParseFloat(amount, 64)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
ts, ok := notification[6].(string)
|
|
if !ok {
|
|
return fmt.Errorf("%w time not string", errTypeAssertionFailure)
|
|
}
|
|
|
|
var timeParse time.Time
|
|
timeParse, err = time.Parse(time.DateTime, ts)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
origAmount, ok := notification[7].(string)
|
|
if !ok {
|
|
return fmt.Errorf("%w original amount not string", errTypeAssertionFailure)
|
|
}
|
|
origOrderAmount, err := strconv.ParseFloat(origAmount, 64)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// null returned so ok check is not needed
|
|
clientOrderID, _ := notification[8].(string)
|
|
p.Websocket.DataHandler <- &order.Detail{
|
|
Exchange: p.Name,
|
|
Price: orderPrice,
|
|
RemainingAmount: orderAmount,
|
|
ExecutedAmount: origOrderAmount - orderAmount,
|
|
Amount: origOrderAmount,
|
|
OrderID: strconv.FormatFloat(orderID, 'f', -1, 64),
|
|
Type: order.Limit,
|
|
Side: orderSide,
|
|
Status: order.New,
|
|
AssetType: asset.Spot,
|
|
Date: timeParse,
|
|
Pair: pair,
|
|
ClientOrderID: clientOrderID,
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (p *Poloniex) processAccountBalanceUpdate(notification []interface{}) error {
|
|
if len(notification) < 4 {
|
|
return errNotEnoughData
|
|
}
|
|
|
|
currencyID, ok := notification[1].(float64)
|
|
if !ok {
|
|
return fmt.Errorf("%w currency ID not float64", errTypeAssertionFailure)
|
|
}
|
|
code, err := p.details.GetCode(currencyID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
walletType, ok := notification[2].(string)
|
|
if !ok {
|
|
return fmt.Errorf("%w wallet addr not string", errTypeAssertionFailure)
|
|
}
|
|
|
|
a, ok := notification[3].(string)
|
|
if !ok {
|
|
return fmt.Errorf("%w amount not string", errTypeAssertionFailure)
|
|
}
|
|
amount, err := strconv.ParseFloat(a, 64)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// TODO: Integrate with exchange account system
|
|
// NOTES: This will affect free amount, a rest call might be needed to get
|
|
// locked and total amounts periodically.
|
|
p.Websocket.DataHandler <- account.Change{
|
|
Exchange: p.Name,
|
|
Currency: code,
|
|
Asset: asset.Spot,
|
|
Account: deriveWalletType(walletType),
|
|
Amount: amount,
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func deriveWalletType(s string) string {
|
|
switch s {
|
|
case "e":
|
|
return "exchange"
|
|
case "m":
|
|
return "margin"
|
|
case "l":
|
|
return "lending"
|
|
default:
|
|
return "unknown"
|
|
}
|
|
}
|
|
|
|
func (p *Poloniex) processAccountTrades(notification []interface{}) error {
|
|
if len(notification) < 11 {
|
|
return errNotEnoughData
|
|
}
|
|
|
|
tradeID, ok := notification[1].(float64)
|
|
if !ok {
|
|
return fmt.Errorf("%w tradeID not float64", errTypeAssertionFailure)
|
|
}
|
|
|
|
r, ok := notification[2].(string)
|
|
if !ok {
|
|
return fmt.Errorf("%w rate not string", errTypeAssertionFailure)
|
|
}
|
|
rate, err := strconv.ParseFloat(r, 64)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
a, ok := notification[3].(string)
|
|
if !ok {
|
|
return fmt.Errorf("%w amount not string", errTypeAssertionFailure)
|
|
}
|
|
amount, err := strconv.ParseFloat(a, 64)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// notification[4].(string) is the fee multiplier
|
|
// notification[5].(string) is the funding type 0 (exchange wallet),
|
|
// 1 (borrowed funds), 2 (margin funds), or 3 (lending funds)
|
|
|
|
orderID, ok := notification[6].(float64)
|
|
if !ok {
|
|
return fmt.Errorf("%w orderID not float64", errTypeAssertionFailure)
|
|
}
|
|
|
|
fee, ok := notification[7].(string)
|
|
if !ok {
|
|
return fmt.Errorf("%w fee not string", errTypeAssertionFailure)
|
|
}
|
|
totalFee, err := strconv.ParseFloat(fee, 64)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
t, ok := notification[8].(string)
|
|
if !ok {
|
|
return fmt.Errorf("%w time not string", errTypeAssertionFailure)
|
|
}
|
|
timeParse, err := time.Parse(time.DateTime, t)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// null returned so ok check is not needed
|
|
clientOrderID, _ := notification[9].(string)
|
|
|
|
tt, ok := notification[10].(string)
|
|
if !ok {
|
|
return fmt.Errorf("%w time not string", errTypeAssertionFailure)
|
|
}
|
|
tradeTotal, err := strconv.ParseFloat(tt, 64)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
p.Websocket.DataHandler <- &order.Detail{
|
|
Exchange: p.Name,
|
|
OrderID: strconv.FormatFloat(orderID, 'f', -1, 64),
|
|
Fee: totalFee,
|
|
Trades: []order.TradeHistory{{
|
|
Price: rate,
|
|
Amount: amount,
|
|
Fee: totalFee,
|
|
Exchange: p.Name,
|
|
TID: strconv.FormatFloat(tradeID, 'f', -1, 64),
|
|
Timestamp: timeParse,
|
|
Total: tradeTotal,
|
|
}},
|
|
AssetType: asset.Spot,
|
|
ClientOrderID: clientOrderID,
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (p *Poloniex) processAccountKilledOrder(notification []interface{}) error {
|
|
if len(notification) < 3 {
|
|
return errNotEnoughData
|
|
}
|
|
|
|
orderID, ok := notification[1].(float64)
|
|
if !ok {
|
|
return fmt.Errorf("%w order ID not float64", errTypeAssertionFailure)
|
|
}
|
|
|
|
// null returned so ok check is not needed
|
|
clientOrderID, _ := notification[2].(string)
|
|
|
|
p.Websocket.DataHandler <- &order.Detail{
|
|
Exchange: p.Name,
|
|
OrderID: strconv.FormatFloat(orderID, 'f', -1, 64),
|
|
Status: order.Cancelled,
|
|
AssetType: asset.Spot,
|
|
ClientOrderID: clientOrderID,
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (p *Poloniex) processTrades(currencyID float64, subData []interface{}) error {
|
|
if !p.IsSaveTradeDataEnabled() {
|
|
return nil
|
|
}
|
|
pair, err := p.details.GetPair(currencyID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if len(subData) != 6 {
|
|
return errNotEnoughData
|
|
}
|
|
|
|
var tradeID string
|
|
switch tradeIDData := subData[1].(type) { // tradeID type intermittently changes
|
|
case string:
|
|
tradeID = tradeIDData
|
|
case float64:
|
|
tradeID = strconv.FormatFloat(tradeIDData, 'f', -1, 64)
|
|
default:
|
|
return fmt.Errorf("unhandled type for websocket trade update: %v",
|
|
tradeIDData)
|
|
}
|
|
|
|
orderSide, ok := subData[2].(float64)
|
|
if !ok {
|
|
return fmt.Errorf("%w order side not float64",
|
|
errTypeAssertionFailure)
|
|
}
|
|
|
|
side := order.Buy
|
|
if orderSide != 1 {
|
|
side = order.Sell
|
|
}
|
|
|
|
v, ok := subData[3].(string)
|
|
if !ok {
|
|
return fmt.Errorf("%w volume not string",
|
|
errTypeAssertionFailure)
|
|
}
|
|
volume, err := strconv.ParseFloat(v, 64)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rate, ok := subData[4].(string)
|
|
if !ok {
|
|
return fmt.Errorf("%w rate not string", errTypeAssertionFailure)
|
|
}
|
|
price, err := strconv.ParseFloat(rate, 64)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
timestamp, ok := subData[5].(float64)
|
|
if !ok {
|
|
return fmt.Errorf("%w time not float64", errTypeAssertionFailure)
|
|
}
|
|
|
|
return p.AddTradesToBuffer(trade.Data{
|
|
TID: tradeID,
|
|
Exchange: p.Name,
|
|
CurrencyPair: pair,
|
|
AssetType: asset.Spot,
|
|
Side: side,
|
|
Price: price,
|
|
Amount: volume,
|
|
Timestamp: time.Unix(int64(timestamp), 0),
|
|
})
|
|
}
|