Files
gocryptotrader/exchanges/btse/btse.go
Samuael A. fc0f262c42 exchanges: Limit mock test JSON data size by truncating slices and maps (#1968)
* set limiter to first level mock data list and updated unit tests

* address nested slices length limit

* minor fix recording file and update unit tests

* minor updates on unit tests

* re-record mock files and minor fix on the unit tests ti adapt the mock data change

* improve http recording limit value and fix issues with mock data in binance

* added MockDataSliceLimit in request items and resolve minor unit test issues

* resolve missed conflict

* rename mock variables, resolve unit test issues, and other updates

* minor fix to CheckJSON and update unit tests

* minor unit test fix

* further optimization on mock CheckJSON method, unit tests, and re-record poloniex

* common and recording unit tests fix

* minor linter issues fix

* unit tests format fix

* fix miscellaneous error

* unit tests fix and minor docs update

* re-record and reduce mock file size

* indentation fix

* minor assertion test fix

* reverted log.Printf line in live testing

* rename variables

* update NewVCRServer unit test

* replace string comparison with *net.OpError check

* restructur net error test

* exchanges/mock: Remove redundant error assertion message in TestNewVCRServer

---------

Co-authored-by: Adrian Gallagher <adrian.gallagher@thrasher.io>
2025-08-26 10:27:07 +10:00

624 lines
20 KiB
Go

package btse
import (
"bytes"
"context"
"encoding/hex"
"errors"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/thrasher-corp/gocryptotrader/common"
"github.com/thrasher-corp/gocryptotrader/common/crypto"
"github.com/thrasher-corp/gocryptotrader/currency"
"github.com/thrasher-corp/gocryptotrader/encoding/json"
exchange "github.com/thrasher-corp/gocryptotrader/exchanges"
"github.com/thrasher-corp/gocryptotrader/exchanges/asset"
"github.com/thrasher-corp/gocryptotrader/exchanges/request"
)
// Exchange implements exchange.IBotExchange and contains additional specific api methods for interacting with BTSE
type Exchange struct {
exchange.Base
}
const (
btseAPIURL = "https://api.btse.com"
btseSPOTPath = "/spot"
btseSPOTAPIPath = "/api/v3.2/"
btseFuturesPath = "/futures"
btseFuturesAPIPath = "/api/v2.1/"
tradeBaseURL = "https://www.btse.com/en/"
tradeSpot = "trading/"
tradeFutures = "futures/"
// Public endpoints
btseMarketOverview = "market_summary"
btseMarkets = "markets"
btseOrderbook = "orderbook"
btseTrades = "trades"
btseTime = "time"
btseOHLCV = "ohlcv"
btsePrice = "price"
btseFuturesFunding = "funding_history"
// Authenticated endpoints
btseWallet = "user/wallet"
btseWalletHistory = "user/wallet_history"
btseWalletAddress = "user/wallet/address"
btseWalletWithdrawal = "user/wallet/withdraw"
btseExchangeHistory = "user/trade_history"
btseUserFee = "user/fees"
btseOrder = "order"
btsePegOrder = "order/peg"
btsePendingOrders = "user/open_orders"
btseCancelAllAfter = "order/cancelAllAfter"
)
// FetchFundingHistory gets funding history
func (e *Exchange) FetchFundingHistory(ctx context.Context, symbol string) (map[string][]FundingHistoryData, error) {
var resp map[string][]FundingHistoryData
params := url.Values{}
if symbol != "" {
params.Set("symbol", symbol)
}
return resp, e.SendHTTPRequest(ctx, exchange.RestFutures, http.MethodGet, btseFuturesFunding+params.Encode(), &resp, false, queryFunc)
}
// GetRawMarketSummary returns an unfiltered list of market pairs
// Consider using the wrapper function GetMarketSummary instead
func (e *Exchange) GetRawMarketSummary(ctx context.Context, symbol string, spot bool) (MarketSummary, error) {
var m MarketSummary
path := btseMarketOverview
if symbol != "" {
path += "?symbol=" + url.QueryEscape(symbol)
}
return m, e.SendHTTPRequest(ctx, exchange.RestSpot, http.MethodGet, path, &m, spot, queryFunc)
}
// FetchOrderbook gets orderbook data for a given symbol
func (e *Exchange) FetchOrderbook(ctx context.Context, symbol string, group, limitBids, limitAsks int, spot bool) (*Orderbook, error) {
var o Orderbook
urlValues := url.Values{}
urlValues.Add("symbol", symbol)
if limitBids > 0 {
urlValues.Add("limit_bids", strconv.Itoa(limitBids))
}
if limitAsks > 0 {
urlValues.Add("limit_asks", strconv.Itoa(limitAsks))
}
if group > 0 {
urlValues.Add("group", strconv.Itoa(group))
}
return &o, e.SendHTTPRequest(ctx, exchange.RestSpot, http.MethodGet,
common.EncodeURLValues(btseOrderbook, urlValues), &o, spot, queryFunc)
}
// FetchOrderbookL2 retrieve level 2 orderbook for requested symbol and depth
func (e *Exchange) FetchOrderbookL2(ctx context.Context, symbol string, depth int) (*Orderbook, error) {
var o Orderbook
urlValues := url.Values{}
urlValues.Add("symbol", symbol)
urlValues.Add("depth", strconv.FormatInt(int64(depth), 10))
endpoint := common.EncodeURLValues(btseOrderbook+"/L2", urlValues)
return &o, e.SendHTTPRequest(ctx, exchange.RestSpot, http.MethodGet, endpoint, &o, true, queryFunc)
}
// GetTrades returns a list of trades for the specified symbol
func (e *Exchange) GetTrades(ctx context.Context, symbol string, start, end time.Time, beforeSerialID, afterSerialID, count int, includeOld, spot bool) ([]Trade, error) {
var t []Trade
urlValues := url.Values{}
urlValues.Add("symbol", symbol)
if count > 0 {
urlValues.Add("count", strconv.Itoa(count))
}
if !start.IsZero() {
urlValues.Add("start", strconv.FormatInt(start.Unix(), 10))
}
if !end.IsZero() {
urlValues.Add("end", strconv.FormatInt(end.Unix(), 10))
}
if !start.IsZero() && !end.IsZero() && start.After(end) {
return t, common.ErrStartAfterEnd
}
if beforeSerialID > 0 {
urlValues.Add("beforeSerialId", strconv.Itoa(beforeSerialID))
}
if afterSerialID > 0 {
urlValues.Add("afterSerialId", strconv.Itoa(afterSerialID))
}
if includeOld {
urlValues.Add("includeOld", "true")
}
return t, e.SendHTTPRequest(ctx, exchange.RestSpot, http.MethodGet,
common.EncodeURLValues(btseTrades, urlValues), &t, spot, queryFunc)
}
// GetOHLCV retrieve and return OHLCV candle data for requested symbol
func (e *Exchange) GetOHLCV(ctx context.Context, symbol string, start, end time.Time, resolution int, a asset.Item) (OHLCV, error) {
var o OHLCV
urlValues := url.Values{}
urlValues.Add("symbol", symbol)
if !start.IsZero() && !end.IsZero() {
if start.After(end) {
return o, common.ErrStartAfterEnd
}
urlValues.Add("start", strconv.FormatInt(start.Unix(), 10))
urlValues.Add("end", strconv.FormatInt(end.Unix(), 10))
}
res := 60
if resolution != 0 {
res = resolution
}
urlValues.Add("resolution", strconv.FormatInt(int64(res), 10))
endpoint := common.EncodeURLValues(btseOHLCV, urlValues)
return o, e.SendHTTPRequest(ctx, exchange.RestSpot, http.MethodGet, endpoint, &o, a == asset.Spot, queryFunc)
}
// GetPrice get current price for requested symbol
func (e *Exchange) GetPrice(ctx context.Context, symbol string) (Price, error) {
var p Price
path := btsePrice + "?symbol=" + url.QueryEscape(symbol)
return p, e.SendHTTPRequest(ctx, exchange.RestSpot, http.MethodGet, path, &p, true, queryFunc)
}
// GetCurrentServerTime returns the exchanges server time
func (e *Exchange) GetCurrentServerTime(ctx context.Context) (*ServerTime, error) {
var s ServerTime
return &s, e.SendHTTPRequest(ctx, exchange.RestSpot, http.MethodGet, btseTime, &s, true, queryFunc)
}
// GetWalletInformation returns the users account balance
func (e *Exchange) GetWalletInformation(ctx context.Context) ([]CurrencyBalance, error) {
var a []CurrencyBalance
return a, e.SendAuthenticatedHTTPRequest(ctx, exchange.RestSpot, http.MethodGet, btseWallet, true, nil, nil, &a, queryFunc)
}
// GetFeeInformation retrieve fee's (maker/taker) for requested symbol
func (e *Exchange) GetFeeInformation(ctx context.Context, symbol string) ([]AccountFees, error) {
var resp []AccountFees
urlValues := url.Values{}
if symbol != "" {
urlValues.Add("symbol", symbol)
}
return resp, e.SendAuthenticatedHTTPRequest(ctx, exchange.RestSpot, http.MethodGet, btseUserFee, true, urlValues, nil, &resp, queryFunc)
}
// GetWalletHistory returns the users account balance
func (e *Exchange) GetWalletHistory(ctx context.Context, symbol string, start, end time.Time, count int) (WalletHistory, error) {
var resp WalletHistory
urlValues := url.Values{}
if symbol != "" {
urlValues.Add("symbol", symbol)
}
if !start.IsZero() && !end.IsZero() {
if start.After(end) || end.Before(start) {
return resp, common.ErrStartAfterEnd
}
urlValues.Add("start", strconv.FormatInt(start.Unix(), 10))
urlValues.Add("end", strconv.FormatInt(end.Unix(), 10))
}
if count > 0 {
urlValues.Add("count", strconv.Itoa(count))
}
return resp, e.SendAuthenticatedHTTPRequest(ctx, exchange.RestSpot, http.MethodGet, btseWalletHistory, true, urlValues, nil, &resp, queryFunc)
}
// GetWalletAddress returns the users account balance
func (e *Exchange) GetWalletAddress(ctx context.Context, ccy string) (WalletAddress, error) {
var resp WalletAddress
urlValues := url.Values{}
if ccy != "" {
urlValues.Add("currency", ccy)
}
return resp, e.SendAuthenticatedHTTPRequest(ctx, exchange.RestSpot, http.MethodGet, btseWalletAddress, true, urlValues, nil, &resp, queryFunc)
}
// CreateWalletAddress create new deposit address for requested currency
func (e *Exchange) CreateWalletAddress(ctx context.Context, ccy string) (WalletAddress, error) {
var resp WalletAddress
req := make(map[string]any, 1)
req["currency"] = ccy
err := e.SendAuthenticatedHTTPRequest(ctx, exchange.RestSpot, http.MethodPost, btseWalletAddress, true, nil, req, &resp, queryFunc)
if err != nil {
errResp := ErrorResponse{}
errResponseStr := strings.Split(err.Error(), "raw response: ")
err := json.Unmarshal([]byte(errResponseStr[1]), &errResp)
if err != nil {
return resp, err
}
if errResp.ErrorCode == 3528 {
walletAddress := strings.Split(errResp.Message, "BADREQUEST: ")
return WalletAddress{
{
Address: walletAddress[1],
},
}, nil
}
return resp, err
}
return resp, nil
}
// WalletWithdrawal submit request to withdraw crypto currency
func (e *Exchange) WalletWithdrawal(ctx context.Context, ccy, address, tag, amount string) (WithdrawalResponse, error) {
var resp WithdrawalResponse
req := make(map[string]any, 4)
req["currency"] = ccy
req["address"] = address
req["tag"] = tag
req["amount"] = amount
return resp, e.SendAuthenticatedHTTPRequest(ctx, exchange.RestSpot, http.MethodPost, btseWalletWithdrawal, true, nil, req, &resp, queryFunc)
}
// CreateOrder creates an order
func (e *Exchange) CreateOrder(ctx context.Context, clOrderID string, deviation float64, postOnly bool, price float64, side string, size, stealth, stopPrice float64, symbol, timeInForce string, trailValue, triggerPrice float64, txType, orderType string) ([]Order, error) {
req := make(map[string]any)
if clOrderID != "" {
req["clOrderID"] = clOrderID
}
if deviation > 0.0 {
req["deviation"] = deviation
}
if postOnly {
req["postOnly"] = postOnly
}
if price > 0.0 {
req["price"] = price
}
if side != "" {
req["side"] = side
}
if size > 0.0 {
req["size"] = size
}
if stealth > 0.0 {
req["stealth"] = stealth
}
if stopPrice > 0.0 {
req["stopPrice"] = stopPrice
}
if symbol != "" {
req["symbol"] = symbol
}
if timeInForce != "" {
req["time_in_force"] = timeInForce
}
if trailValue > 0.0 {
req["trailValue"] = trailValue
}
if triggerPrice > 0.0 {
req["triggerPrice"] = triggerPrice
}
if txType != "" {
req["txType"] = txType
}
if orderType != "" {
req["type"] = orderType
}
var r []Order
return r, e.SendAuthenticatedHTTPRequest(ctx, exchange.RestSpot, http.MethodPost, btseOrder, true, url.Values{}, req, &r, orderFunc)
}
// GetOrders returns all pending orders
func (e *Exchange) GetOrders(ctx context.Context, symbol, orderID, clOrderID string) ([]OpenOrder, error) {
req := url.Values{}
if orderID != "" {
req.Add("orderID", orderID)
}
req.Add("symbol", symbol)
if clOrderID != "" {
req.Add("clOrderID", clOrderID)
}
var o []OpenOrder
return o, e.SendAuthenticatedHTTPRequest(ctx, exchange.RestSpot, http.MethodGet, btsePendingOrders, true, req, nil, &o, orderFunc)
}
// CancelExistingOrder cancels an order
func (e *Exchange) CancelExistingOrder(ctx context.Context, orderID, symbol, clOrderID string) (CancelOrder, error) {
var c CancelOrder
req := url.Values{}
if orderID != "" {
req.Add("orderID", orderID)
}
req.Add("symbol", symbol)
if clOrderID != "" {
req.Add("clOrderID", clOrderID)
}
return c, e.SendAuthenticatedHTTPRequest(ctx, exchange.RestSpot, http.MethodDelete, btseOrder, true, req, nil, &c, orderFunc)
}
// CancelAllAfter cancels all orders after timeout
func (e *Exchange) CancelAllAfter(ctx context.Context, timeout int) error {
req := make(map[string]any)
req["timeout"] = timeout
return e.SendAuthenticatedHTTPRequest(ctx, exchange.RestSpot, http.MethodPost, btseCancelAllAfter, true, url.Values{}, req, nil, orderFunc)
}
// IndexOrderPeg create peg order that will track a certain percentage above/below the index price
func (e *Exchange) IndexOrderPeg(ctx context.Context, clOrderID string, deviation float64, postOnly bool, price float64, side string, size, stealth, stopPrice float64, symbol, timeInForce string, trailValue, triggerPrice float64, txType, orderType string) ([]Order, error) {
var o []Order
req := make(map[string]any)
if clOrderID != "" {
req["clOrderID"] = clOrderID
}
if deviation > 0.0 {
req["deviation"] = deviation
}
if postOnly {
req["postOnly"] = postOnly
}
if price > 0.0 {
req["price"] = price
}
if side != "" {
req["side"] = side
}
if size > 0.0 {
req["size"] = size
}
if stealth > 0.0 {
req["stealth"] = stealth
}
if stopPrice > 0.0 {
req["stopPrice"] = stopPrice
}
if symbol != "" {
req["symbol"] = symbol
}
if timeInForce != "" {
req["time_in_force"] = timeInForce
}
if trailValue > 0.0 {
req["trailValue"] = trailValue
}
if triggerPrice > 0.0 {
req["triggerPrice"] = triggerPrice
}
if txType != "" {
req["txType"] = txType
}
if orderType != "" {
req["type"] = orderType
}
return o, e.SendAuthenticatedHTTPRequest(ctx, exchange.RestSpot, http.MethodPost, btsePegOrder, true, url.Values{}, req, nil, orderFunc)
}
// TradeHistory returns previous trades on exchange
func (e *Exchange) TradeHistory(ctx context.Context, symbol string, start, end time.Time, beforeSerialID, afterSerialID, count int, includeOld bool, clOrderID, orderID string) (TradeHistory, error) {
var resp TradeHistory
urlValues := url.Values{}
if symbol != "" {
urlValues.Add("symbol", symbol)
}
if !start.IsZero() && !end.IsZero() {
if start.After(end) || end.Before(start) {
return resp, errors.New("start and end must both be valid")
}
urlValues.Add("start", strconv.FormatInt(start.Unix(), 10))
urlValues.Add("end", strconv.FormatInt(end.Unix(), 10))
}
if beforeSerialID > 0 {
urlValues.Add("beforeSerialId", strconv.Itoa(beforeSerialID))
}
if afterSerialID > 0 {
urlValues.Add("afterSerialId", strconv.Itoa(afterSerialID))
}
if includeOld {
urlValues.Add("includeOld", "true")
}
if count > 0 {
urlValues.Add("count", strconv.Itoa(count))
}
if clOrderID != "" {
urlValues.Add("clOrderId", clOrderID)
}
if orderID != "" {
urlValues.Add("orderID", orderID)
}
return resp, e.SendAuthenticatedHTTPRequest(ctx, exchange.RestSpot, http.MethodGet, btseExchangeHistory, true, urlValues, nil, &resp, queryFunc)
}
// SendHTTPRequest sends an HTTP request to the desired endpoint
func (e *Exchange) SendHTTPRequest(ctx context.Context, ep exchange.URL, method, endpoint string, result any, spotEndpoint bool, f request.EndpointLimit) error {
ePoint, err := e.API.Endpoints.GetURL(ep)
if err != nil {
return err
}
p := btseSPOTPath + btseSPOTAPIPath
if !spotEndpoint {
p = btseFuturesPath + btseFuturesAPIPath
}
item := &request.Item{
Method: method,
Path: ePoint + p + endpoint,
Result: result,
Verbose: e.Verbose,
HTTPDebugging: e.HTTPDebugging,
HTTPRecording: e.HTTPRecording,
HTTPMockDataSliceLimit: e.HTTPMockDataSliceLimit,
}
return e.SendPayload(ctx, f, func() (*request.Item, error) {
return item, nil
}, request.UnauthenticatedRequest)
}
// SendAuthenticatedHTTPRequest sends an authenticated HTTP request to the desired endpoint
func (e *Exchange) SendAuthenticatedHTTPRequest(ctx context.Context, ep exchange.URL, method, endpoint string, isSpot bool, values url.Values, req map[string]any, result any, f request.EndpointLimit) error {
creds, err := e.GetCredentials(ctx)
if err != nil {
return err
}
ePoint, err := e.API.Endpoints.GetURL(ep)
if err != nil {
return err
}
newRequest := func() (*request.Item, error) {
// The concatenation is done this way because BTSE expect endpoint+nonce or endpoint+nonce+body
// when signing the data but the full path of the request is /spot/api/v3.2/<endpoint>
// its messy but it works and supports futures as well
host := ePoint
var expandedEndpoint string
if isSpot {
host += btseSPOTPath + btseSPOTAPIPath + endpoint
expandedEndpoint = btseSPOTAPIPath + endpoint
} else {
host += btseFuturesPath + btseFuturesAPIPath
expandedEndpoint = endpoint + btseFuturesAPIPath
}
var hmac []byte
var body io.Reader
nonce := strconv.FormatInt(time.Now().UnixMilli(), 10)
headers := map[string]string{
"btse-api": creds.Key,
"btse-nonce": nonce,
}
if req != nil {
var reqPayload []byte
reqPayload, err = json.Marshal(req)
if err != nil {
return nil, err
}
body = bytes.NewBuffer(reqPayload)
hmac, err = crypto.GetHMAC(
crypto.HashSHA512_384,
[]byte(expandedEndpoint+nonce+string(reqPayload)),
[]byte(creds.Secret),
)
if err != nil {
return nil, err
}
headers["Content-Type"] = "application/json"
} else {
hmac, err = crypto.GetHMAC(
crypto.HashSHA512_384,
[]byte(expandedEndpoint+nonce),
[]byte(creds.Secret),
)
if err != nil {
return nil, err
}
if len(values) > 0 {
host += "?" + values.Encode()
}
}
headers["btse-sign"] = hex.EncodeToString(hmac)
return &request.Item{
Method: method,
Path: host,
Headers: headers,
Body: body,
Result: result,
Verbose: e.Verbose,
HTTPDebugging: e.HTTPDebugging,
HTTPRecording: e.HTTPRecording,
HTTPMockDataSliceLimit: e.HTTPMockDataSliceLimit,
}, nil
}
return e.SendPayload(ctx, f, newRequest, request.AuthenticatedRequest)
}
// GetFee returns an estimate of fee based on type of transaction
func (e *Exchange) GetFee(ctx context.Context, feeBuilder *exchange.FeeBuilder) (float64, error) {
var fee float64
switch feeBuilder.FeeType {
case exchange.CryptocurrencyTradeFee:
fee = e.calculateTradingFee(ctx, feeBuilder) * feeBuilder.Amount * feeBuilder.PurchasePrice
case exchange.CryptocurrencyWithdrawalFee:
switch feeBuilder.Pair.Base {
case currency.USDT:
fee = 1.08
case currency.TUSD:
fee = 1.09
case currency.BTC:
fee = 0.0005
case currency.ETH:
fee = 0.01
case currency.LTC:
fee = 0.001
}
case exchange.InternationalBankDepositFee:
fee = getInternationalBankDepositFee(feeBuilder.Amount)
case exchange.InternationalBankWithdrawalFee:
fee = getInternationalBankWithdrawalFee(feeBuilder.Amount)
case exchange.OfflineTradeFee:
fee = getOfflineTradeFee(feeBuilder.PurchasePrice, feeBuilder.Amount)
}
return fee, nil
}
// getOfflineTradeFee calculates the worst case-scenario trading fee
func getOfflineTradeFee(price, amount float64) float64 {
return 0.001 * price * amount
}
// getInternationalBankDepositFee returns international deposit fee
// Only when the initial deposit amount is less than $1000 or equivalent,
// BTSE will charge a small fee (0.25% or $3 USD equivalent, whichever is greater).
// The small deposit fee is charged in whatever currency it comes in.
func getInternationalBankDepositFee(amount float64) float64 {
var fee float64
if amount <= 100 {
fee = amount * 0.0025
if fee < 3 {
return 3
}
}
return fee
}
// getInternationalBankWithdrawalFee returns international withdrawal fee
// 0.1% (min25 USD)
func getInternationalBankWithdrawalFee(amount float64) float64 {
fee := amount * 0.0009
if fee < 25 {
return 25
}
return fee
}
// calculateTradingFee return fee based on users current fee tier or default values
func (e *Exchange) calculateTradingFee(ctx context.Context, feeBuilder *exchange.FeeBuilder) float64 {
formattedPair, err := e.FormatExchangeCurrency(feeBuilder.Pair, asset.Spot)
if err != nil {
if feeBuilder.IsMaker {
return 0.001
}
return 0.002
}
feeTiers, err := e.GetFeeInformation(ctx, formattedPair.String())
if err != nil {
// TODO: Return actual error, we shouldn't pivot around errors.
if feeBuilder.IsMaker {
return 0.001
}
return 0.002
}
if feeBuilder.IsMaker {
return feeTiers[0].MakerFee
}
return feeTiers[0].TakerFee
}
// HasLiquidity returns if a market pair has a bid or ask != 0
func (m *MarketPair) HasLiquidity() bool {
return m.LowestAsk != 0 || m.HighestBid != 0
}