Files
gocryptotrader/common/common.go
Gareth Kirwan 37b1121bbd BTSE: Fix duplicate pair errors on Million pairs (M_*) (#1401)
* BTSE: Fix duplicate error on Million pairs (M_*)

BTSE has listed Pitbull token with two symbols:
PIT-USD and M_PIT-USD for millons of PIT / USD.
The native token is not tradable, so we ignore them and
get a base of M_PIT because that's what later APIs will accept

* BTSE: Fix test errors on locked market

* Common: Improve AppendError and ExcludeError

This change switches from a stateful multiError to caring more about the
Unwrap() []error interface, the same as [go standard
lib](https://github.com/golang/go/blob/go1.21.4/src/errors/wrap.go#L54-L68)

Notably, if we implement Unwrap() []error and do NOT implement Is() then
we get free compatibility with the core functions.

The only distateful thing here is needing to deeply unwrap fmt.Errorf
errors, since they don't flatten. I can't see any way around that

* Pairs: Fix exchange config Pairs loading

When a pair string contained two punctuation runes, the first one is used,
and the configFormat is ignored.

This fix checks the list and corrects any with the wrong delimiter, or
errors if the format is inconsistent.

* BTSE: Fix all tickers retrieved by GetTicker

PR #764 introduced GetTickers, but it wasn't rolled out to BTSE.
This fix ensures that when one ticker is a locked market, the rest continue to
function. Particularly important if the locked market wasn't even
enabled anyway.

* Kucoin: Fix test config future pairs

* BTSE: Remove PIT tests; Token removed

BTSE have removed the PIT token pairs

All these changes stand, and this just removes the test

* ITBit: Fix fatal error on second run

This fix removes incorrect config pair delimiter, because it would be
re-inserted into config the first run, and then error the second time.

This delimiter doesn't match the config we have.
There's no implementation of fetching pairs, so what's in config files
now is all that matters

* Engine: Fix TestConfigAllJsonResponse

* Clarity of non-matching json improved
* Handling for fixing pair delimiters
2023-12-19 14:40:13 +11:00

606 lines
17 KiB
Go

package common
import (
"context"
"crypto/rand"
"errors"
"fmt"
"io"
"math/big"
"net"
"net/http"
"net/url"
"os"
"os/user"
"path/filepath"
"reflect"
"regexp"
"strconv"
"strings"
"sync"
"time"
"unicode"
"github.com/thrasher-corp/gocryptotrader/common/file"
"github.com/thrasher-corp/gocryptotrader/log"
)
const (
// SimpleTimeFormatWithTimezone a common, but non-implemented time format in golang
SimpleTimeFormatWithTimezone = time.DateTime + " MST"
// GctExt is the extension for GCT Tengo script files
GctExt = ".gct"
defaultTimeout = time.Second * 15
)
// Strings representing the full lower, upper case English character alphabet and base-10 numbers for generating a random string.
const (
SmallLetters = "abcdefghijklmnopqrstuvwxyz"
CapitalLetters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
NumberCharacters = "0123456789"
)
var (
// emailRX represents email address matching pattern
emailRX = regexp.MustCompile("^[a-zA-Z0-9.!#$%&'*+\\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$")
)
// Vars for common.go operations
var (
_HTTPClient *http.Client
_HTTPUserAgent string
m sync.RWMutex
// ErrNotYetImplemented defines a common error across the code base that
// alerts of a function that has not been completed or tied into main code
ErrNotYetImplemented = errors.New("not yet implemented")
// ErrFunctionNotSupported defines a standardised error for an unsupported
// wrapper function by an API
ErrFunctionNotSupported = errors.New("unsupported wrapper function")
errInvalidCryptoCurrency = errors.New("invalid crypto currency")
// ErrDateUnset is an error for start end check calculations
ErrDateUnset = errors.New("date unset")
// ErrStartAfterEnd is an error for start end check calculations
ErrStartAfterEnd = errors.New("start date after end date")
// ErrStartEqualsEnd is an error for start end check calculations
ErrStartEqualsEnd = errors.New("start date equals end date")
// ErrStartAfterTimeNow is an error for start end check calculations
ErrStartAfterTimeNow = errors.New("start date is after current time")
// ErrNilPointer defines an error for a nil pointer
ErrNilPointer = errors.New("nil pointer")
// ErrCannotCalculateOffline is returned when a request wishes to calculate
// something offline, but has an online requirement
ErrCannotCalculateOffline = errors.New("cannot calculate offline, unsupported")
// ErrNoResponse is returned when a response has no entries/is empty
// when one is expected
ErrNoResponse = errors.New("no response")
errCannotSetInvalidTimeout = errors.New("cannot set new HTTP client with timeout that is equal or less than 0")
errUserAgentInvalid = errors.New("cannot set invalid user agent")
errHTTPClientInvalid = errors.New("custom http client cannot be nil")
zeroValueUnix = time.Unix(0, 0)
// ErrTypeAssertFailure defines an error when type assertion fails
ErrTypeAssertFailure = errors.New("type assert failure")
)
// MatchesEmailPattern ensures that the string is an email address by regexp check
func MatchesEmailPattern(value string) bool {
if len(value) < 3 || len(value) > 254 {
return false
}
return emailRX.MatchString(value)
}
// SetHTTPClientWithTimeout sets a new *http.Client with different timeout
// settings
func SetHTTPClientWithTimeout(t time.Duration) error {
if t <= 0 {
return errCannotSetInvalidTimeout
}
m.Lock()
_HTTPClient = NewHTTPClientWithTimeout(t)
m.Unlock()
return nil
}
// SetHTTPUserAgent sets the user agent which will be used for all common HTTP
// requests.
func SetHTTPUserAgent(agent string) error {
if agent == "" {
return errUserAgentInvalid
}
m.Lock()
_HTTPUserAgent = agent
m.Unlock()
return nil
}
// SetHTTPClient sets a custom HTTP client.
func SetHTTPClient(client *http.Client) error {
if client == nil {
return errHTTPClientInvalid
}
m.Lock()
_HTTPClient = client
m.Unlock()
return nil
}
// NewHTTPClientWithTimeout initialises a new HTTP client and its underlying
// transport IdleConnTimeout with the specified timeout duration
func NewHTTPClientWithTimeout(t time.Duration) *http.Client {
tr := &http.Transport{
// Added IdleConnTimeout to reduce the time of idle connections which
// could potentially slow macOS reconnection when there is a sudden
// network disconnection/issue
IdleConnTimeout: t,
Proxy: http.ProxyFromEnvironment,
}
h := &http.Client{
Transport: tr,
Timeout: t}
return h
}
// StringSliceDifference concatenates slices together based on its index and
// returns an individual string array
func StringSliceDifference(slice1, slice2 []string) []string {
var diff []string
for i := 0; i < 2; i++ {
for _, s1 := range slice1 {
found := false
for _, s2 := range slice2 {
if s1 == s2 {
found = true
break
}
}
if !found {
diff = append(diff, s1)
}
}
if i == 0 {
slice1, slice2 = slice2, slice1
}
}
return diff
}
// StringDataContains checks the substring array with an input and returns a bool
func StringDataContains(haystack []string, needle string) bool {
data := strings.Join(haystack, ",")
return strings.Contains(data, needle)
}
// StringDataCompare data checks the substring array with an input and returns a bool
func StringDataCompare(haystack []string, needle string) bool {
for x := range haystack {
if haystack[x] == needle {
return true
}
}
return false
}
// StringDataCompareInsensitive data checks the substring array with an input and returns
// a bool irrespective of lower or upper case strings
func StringDataCompareInsensitive(haystack []string, needle string) bool {
for x := range haystack {
if strings.EqualFold(haystack[x], needle) {
return true
}
}
return false
}
// StringDataContainsInsensitive checks the substring array with an input and returns
// a bool irrespective of lower or upper case strings
func StringDataContainsInsensitive(haystack []string, needle string) bool {
for _, data := range haystack {
if strings.Contains(strings.ToUpper(data), strings.ToUpper(needle)) {
return true
}
}
return false
}
// IsEnabled takes in a boolean param and returns a string if it is enabled
// or disabled
func IsEnabled(isEnabled bool) string {
if isEnabled {
return "Enabled"
}
return "Disabled"
}
// IsValidCryptoAddress validates your cryptocurrency address string using the
// regexp package // Validation issues occurring because "3" is contained in
// litecoin and Bitcoin addresses - non-fatal
func IsValidCryptoAddress(address, crypto string) (bool, error) {
switch strings.ToLower(crypto) {
case "btc":
return regexp.MatchString("^(bc1|[13])[a-zA-HJ-NP-Z0-9]{25,90}$", address)
case "ltc":
return regexp.MatchString("^[L3M][a-km-zA-HJ-NP-Z1-9]{25,34}$", address)
case "eth":
return regexp.MatchString("^0x[a-km-z0-9]{40}$", address)
default:
return false, fmt.Errorf("%w %s", errInvalidCryptoCurrency, crypto)
}
}
// YesOrNo returns a boolean variable to check if input is "y" or "yes"
func YesOrNo(input string) bool {
if strings.EqualFold(input, "y") || strings.EqualFold(input, "yes") {
return true
}
return false
}
// SendHTTPRequest sends a request using the http package and returns the body
// contents
func SendHTTPRequest(ctx context.Context, method, urlPath string, headers map[string]string, body io.Reader, verbose bool) ([]byte, error) {
method = strings.ToUpper(method)
if method != http.MethodOptions && method != http.MethodGet &&
method != http.MethodHead && method != http.MethodPost &&
method != http.MethodPut && method != http.MethodDelete &&
method != http.MethodTrace && method != http.MethodConnect {
return nil, errors.New("invalid HTTP method specified")
}
req, err := http.NewRequestWithContext(ctx, method, urlPath, body)
if err != nil {
return nil, err
}
for k, v := range headers {
req.Header.Add(k, v)
}
if verbose {
log.Debugf(log.Global, "Request path: %s", urlPath)
for k, d := range req.Header {
log.Debugf(log.Global, "Request header [%s]: %s", k, d)
}
log.Debugf(log.Global, "Request type: %s", method)
if body != nil {
log.Debugf(log.Global, "Request body: %v", body)
}
}
m.RLock()
if _HTTPUserAgent != "" && req.Header.Get("User-Agent") == "" {
req.Header.Add("User-Agent", _HTTPUserAgent)
}
if _HTTPClient == nil {
m.RUnlock()
m.Lock()
// Set *http.Client with default timeout if not populated.
_HTTPClient = NewHTTPClientWithTimeout(defaultTimeout)
m.Unlock()
m.RLock()
}
resp, err := _HTTPClient.Do(req)
m.RUnlock()
if err != nil {
return nil, err
}
defer resp.Body.Close()
contents, err := io.ReadAll(resp.Body)
if verbose {
log.Debugf(log.Global, "HTTP status: %s, Code: %v",
resp.Status,
resp.StatusCode)
log.Debugf(log.Global, "Raw response: %s", string(contents))
}
return contents, err
}
// EncodeURLValues concatenates url values onto a url string and returns a
// string
func EncodeURLValues(urlPath string, values url.Values) string {
u := urlPath
if len(values) > 0 {
u += "?" + values.Encode()
}
return u
}
// ExtractHost returns the hostname out of a string
func ExtractHost(address string) string {
host, _, _ := net.SplitHostPort(address)
if host == "" {
return "localhost"
}
return host
}
// ExtractPort returns the port name out of a string
func ExtractPort(host string) int {
_, port, _ := net.SplitHostPort(host)
if port == "" {
return 80
}
portInt, _ := strconv.Atoi(port)
return portInt
}
// GetURIPath returns the path of a URL given a URI
func GetURIPath(uri string) string {
urip, err := url.Parse(uri)
if err != nil {
return ""
}
if urip.RawQuery != "" {
return urip.Path + "?" + urip.RawQuery
}
return urip.Path
}
// GetExecutablePath returns the executables launch path
func GetExecutablePath() (string, error) {
ex, err := os.Executable()
if err != nil {
return "", err
}
return filepath.Dir(ex), nil
}
// GetDefaultDataDir returns the default data directory
// Windows - C:\Users\%USER%\AppData\Roaming\GoCryptoTrader
// Linux/Unix or OSX - $HOME/.gocryptotrader
func GetDefaultDataDir(env string) string {
if env == "windows" {
return filepath.Join(os.Getenv("APPDATA"), "GoCryptoTrader")
}
usr, err := user.Current()
if err == nil {
return filepath.Join(usr.HomeDir, ".gocryptotrader")
}
dir, err := os.UserHomeDir()
if err != nil {
log.Warnln(log.Global, "Environment variable unset, defaulting to current directory")
dir = "."
}
return filepath.Join(dir, ".gocryptotrader")
}
// CreateDir creates a directory based on the supplied parameter
func CreateDir(dir string) error {
_, err := os.Stat(dir)
if !os.IsNotExist(err) {
return nil
}
log.Warnf(log.Global, "Directory %s does not exist.. creating.\n", dir)
return os.MkdirAll(dir, file.DefaultPermissionOctal)
}
// ChangePermission lists all the directories and files in an array
func ChangePermission(directory string) error {
return filepath.Walk(directory, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.Mode().Perm() != file.DefaultPermissionOctal {
return os.Chmod(path, file.DefaultPermissionOctal)
}
return nil
})
}
// SplitStringSliceByLimit splits a slice of strings into slices by input limit and returns a slice of slice of strings
func SplitStringSliceByLimit(in []string, limit uint) [][]string {
var stringSlice []string
sliceSlice := make([][]string, 0, len(in)/int(limit)+1)
for len(in) >= int(limit) {
stringSlice, in = in[:limit], in[limit:]
sliceSlice = append(sliceSlice, stringSlice)
}
if len(in) > 0 {
sliceSlice = append(sliceSlice, in)
}
return sliceSlice
}
// AddPaddingOnUpperCase adds padding to a string when detecting an upper case letter. If
// there are multiple upper case items like `ThisIsHTTPExample`, it will only
// pad between like this `This Is HTTP Example`.
func AddPaddingOnUpperCase(s string) string {
if s == "" {
return ""
}
var result []string
left := 0
for x := 0; x < len(s); x++ {
if x == 0 {
continue
}
if unicode.IsUpper(rune(s[x])) {
if !unicode.IsUpper(rune(s[x-1])) {
result = append(result, s[left:x])
left = x
}
} else if x > 1 && unicode.IsUpper(rune(s[x-1])) {
if s[left:x-1] == "" {
continue
}
result = append(result, s[left:x-1])
left = x - 1
}
}
result = append(result, s[left:])
return strings.Join(result, " ")
}
// InArray checks if _val_ belongs to _array_
func InArray(val, array interface{}) (exists bool, index int) {
exists = false
index = -1
if array == nil {
return
}
switch reflect.TypeOf(array).Kind() {
case reflect.Array, reflect.Slice:
s := reflect.ValueOf(array)
for i := 0; i < s.Len(); i++ {
if reflect.DeepEqual(val, s.Index(i).Interface()) {
index = i
exists = true
return
}
}
}
return
}
// multiError holds errors as a slice
type multiError struct {
errs []error
}
// AppendError appends an error to a list of exesting errors
// Either argument may be:
// * A vanilla error
// * An error implementing Unwrap() []error e.g. fmt.Errorf("%w: %w")
// * nil
// The result will be an error which may be a multiError if multipleErrors were found
func AppendError(original, incoming error) error {
if incoming == nil {
return original
}
if original == nil {
return incoming
}
newErrs := []error{incoming}
if u, ok := incoming.(interface{ Unwrap() []error }); ok {
newErrs = u.Unwrap()
}
if u, ok := original.(interface{ Unwrap() []error }); ok {
return &multiError{
errs: append(u.Unwrap(), newErrs...),
}
}
return &multiError{
errs: append([]error{original}, newErrs...),
}
}
// Error displays all errors comma separated
func (e *multiError) Error() string {
allErrors := make([]string, len(e.errs))
for x := range e.errs {
allErrors[x] = e.errs[x].Error()
}
return strings.Join(allErrors, ", ")
}
// Unwrap returns all of the errors in the multiError
func (e *multiError) Unwrap() []error {
return e.errs
}
type unwrappable interface {
Unwrap() []error
}
// unwrapDeep walks down a stack of nested fmt.Errorf("%w: %w") errors
// This is necessary since fmt.wrapErrors doesn't flatten the error slices
func unwrapDeep(err unwrappable) []error {
var n []error
for _, e := range err.Unwrap() {
if u, ok := e.(unwrappable); ok {
n = append(n, u.Unwrap()...)
} else {
n = append(n, e)
}
}
return n
}
// ExcludeError returns a new error excluding any errors matching excl
// For a standard error it will either return the error unchanged or nil
// For an error which implements Unwrap() []error it will remove any errors matching excl and return the remaining errors or nil
// Any non-error messages and formatting from fmt.Errorf will be lost; This function is written for conditions
func ExcludeError(err, excl error) error {
if u, ok := err.(unwrappable); ok {
var n error
for _, e := range unwrapDeep(u) {
if !errors.Is(e, excl) {
n = AppendError(n, e)
}
}
return n
}
if errors.Is(err, excl) {
return nil
}
return err
}
// StartEndTimeCheck provides some basic checks which occur
// frequently in the codebase
func StartEndTimeCheck(start, end time.Time) error {
if start.IsZero() || start.Equal(zeroValueUnix) {
return fmt.Errorf("start %w", ErrDateUnset)
}
if end.IsZero() || end.Equal(zeroValueUnix) {
return fmt.Errorf("end %w", ErrDateUnset)
}
if start.After(end) {
return ErrStartAfterEnd
}
if start.Equal(end) {
return ErrStartEqualsEnd
}
if start.After(time.Now()) {
return ErrStartAfterTimeNow
}
return nil
}
// GenerateRandomString generates a random string provided a length and list of Character types { SmallLetters, CapitalLetters, NumberCharacters}.
// if no characters are provided, the function uses a NumberCharacters(string of numeric characters).
func GenerateRandomString(length uint, characters ...string) (string, error) {
if length == 0 {
return "", errors.New("invalid length, length must be non-zero positive integer")
}
b := make([]byte, length)
chars := strings.Replace(strings.Join(characters, ""), " ", "", -1)
if chars == "" && len(characters) != 0 {
return "", errors.New("invalid characters, character must not be empty")
} else if chars == "" {
chars = NumberCharacters
}
for i := range b {
nBig, err := rand.Int(rand.Reader, big.NewInt(int64(len(chars))))
if err != nil {
return "", err
}
n := nBig.Int64()
b[i] = chars[n]
}
return string(b), nil
}
// GetTypeAssertError returns additional information for when an assertion failure
// occurs.
// fieldDescription is an optional way to return what the affected field was for
func GetTypeAssertError(required string, received interface{}, fieldDescription ...string) error {
var description string
if len(fieldDescription) > 0 {
description = " for: " + strings.Join(fieldDescription, ", ")
}
return fmt.Errorf("%w from %T to %s%s", ErrTypeAssertFailure, received, required, description)
}