diff --git a/README.md b/README.md index 558d2fdc..bc395aa4 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ Join our slack to discuss all things related to GoCryptoTrader! [GoCryptoTrader | BTCMarkets | Yes | Yes | NA | | BTSE | Yes | Yes | NA | | Bybit | Yes | Yes | NA | -| CoinbasePro | Yes | Yes | No| +| Coinbase | Yes | Yes | No| | COINUT | Yes | Yes | NA | | Deribit | Yes | Yes | No | | Exmo | Yes | NA | NA | diff --git a/backtester/eventhandlers/exchange/exchange_test.go b/backtester/eventhandlers/exchange/exchange_test.go index 0078de66..a61d7fc9 100644 --- a/backtester/eventhandlers/exchange/exchange_test.go +++ b/backtester/eventhandlers/exchange/exchange_test.go @@ -202,7 +202,7 @@ func TestPlaceOrder(t *testing.T) { Base: &event.Base{}, } _, err = e.placeOrder(t.Context(), decimal.NewFromInt(1), decimal.NewFromInt(1), decimal.Zero, false, true, f, bot.OrderManager) - assert.ErrorIs(t, err, engine.ErrExchangeNameIsEmpty) + assert.ErrorIs(t, err, gctcommon.ErrExchangeNameNotSet) f.Exchange = testExchange require.NoError(t, exch.UpdateOrderExecutionLimits(t.Context(), asset.Spot), "UpdateOrderExecutionLimits must not error") diff --git a/backtester/funding/funding.go b/backtester/funding/funding.go index 5402b44b..12de275b 100644 --- a/backtester/funding/funding.go +++ b/backtester/funding/funding.go @@ -801,7 +801,7 @@ func (f *FundManager) HasExchangeBeenLiquidated(ev common.Event) bool { // help calculate collateral func (f *FundManager) SetFunding(exchName string, item asset.Item, balance *account.Balance, initialFundsSet bool) error { if exchName == "" { - return engine.ErrExchangeNameIsEmpty + return gctcommon.ErrExchangeNameNotSet } if !item.IsValid() { return asset.ErrNotSupported diff --git a/backtester/funding/funding_test.go b/backtester/funding/funding_test.go index df62f655..e5d7e7d4 100644 --- a/backtester/funding/funding_test.go +++ b/backtester/funding/funding_test.go @@ -709,7 +709,7 @@ func TestSetFunding(t *testing.T) { t.Parallel() f := &FundManager{} err := f.SetFunding("", 0, nil, false) - assert.ErrorIs(t, err, engine.ErrExchangeNameIsEmpty) + assert.ErrorIs(t, err, gctcommon.ErrExchangeNameNotSet) err = f.SetFunding(exchName, 0, nil, false) assert.ErrorIs(t, err, asset.ErrNotSupported) diff --git a/cmd/documentation/exchanges_templates/coinbasepro.tmpl b/cmd/documentation/exchanges_templates/coinbase.tmpl similarity index 95% rename from cmd/documentation/exchanges_templates/coinbasepro.tmpl rename to cmd/documentation/exchanges_templates/coinbase.tmpl index 0cfcee9b..aae8ccf9 100644 --- a/cmd/documentation/exchanges_templates/coinbasepro.tmpl +++ b/cmd/documentation/exchanges_templates/coinbase.tmpl @@ -1,6 +1,6 @@ -{{define "exchanges coinbasepro" -}} +{{define "exchanges coinbase" -}} {{template "header" .}} -## CoinbasePro Exchange +## Coinbase Exchange ### Current Features @@ -31,7 +31,7 @@ main.go var c exchange.IBotExchange for i := range bot.Exchanges { - if bot.Exchanges[i].GetName() == "CoinbasePro" { + if bot.Exchanges[i].GetName() == "Coinbase" { c = bot.Exchanges[i] } } diff --git a/cmd/documentation/exchanges_templates/exchanges_trade_readme.tmpl b/cmd/documentation/exchanges_templates/exchanges_trade_readme.tmpl index f8bc819a..6c6bd651 100644 --- a/cmd/documentation/exchanges_templates/exchanges_trade_readme.tmpl +++ b/cmd/documentation/exchanges_templates/exchanges_trade_readme.tmpl @@ -51,7 +51,7 @@ _b in this context is an `IBotExchange` implemented struct_ | BTCMarkets | Yes | Yes | No | | BTSE | Yes | Yes | No | | Bybit | Yes | Yes | Yes | -| CoinbasePro | Yes | Yes | No| +| Coinbase | Yes | Yes | No| | COINUT | Yes | Yes | No | | Deribit | Yes | Yes | Yes | | Exmo | Yes | NA | No | diff --git a/cmd/documentation/root_templates/root_readme.tmpl b/cmd/documentation/root_templates/root_readme.tmpl index 438e0307..443edfbc 100644 --- a/cmd/documentation/root_templates/root_readme.tmpl +++ b/cmd/documentation/root_templates/root_readme.tmpl @@ -29,7 +29,7 @@ Join our slack to discuss all things related to GoCryptoTrader! [GoCryptoTrader | BTCMarkets | Yes | Yes | NA | | BTSE | Yes | Yes | NA | | Bybit | Yes | Yes | NA | -| CoinbasePro | Yes | Yes | No| +| Coinbase | Yes | Yes | No| | COINUT | Yes | Yes | NA | | Deribit | Yes | Yes | No | | Exmo | Yes | NA | NA | diff --git a/cmd/exchange_wrapper_standards/exchange_wrapper_standards_test.go b/cmd/exchange_wrapper_standards/exchange_wrapper_standards_test.go index 08b4a794..69e7fcf7 100644 --- a/cmd/exchange_wrapper_standards/exchange_wrapper_standards_test.go +++ b/cmd/exchange_wrapper_standards/exchange_wrapper_standards_test.go @@ -92,22 +92,18 @@ func setupExchange(ctx context.Context, t *testing.T, name string, cfg *config.C exch.SetDefaults() exchCfg.API.AuthenticatedSupport = true exchCfg.API.Credentials = getExchangeCredentials(name) - err = exch.Setup(exchCfg) if err != nil { t.Fatalf("Cannot setup %v exchange Setup %v", name, err) } - err = exch.UpdateTradablePairs(ctx, true) require.Truef(t, errors.Is(err, context.DeadlineExceeded) || err == nil, "Exchange %s UpdateTradablePairs must not error: %s", name, err) b := exch.GetBase() - assets := b.CurrencyPairs.GetAssetTypes(false) require.NotEmptyf(t, assets, "Exchange %s must have assets", name) for _, a := range assets { require.NoErrorf(t, b.CurrencyPairs.SetAssetEnabled(a, true), "Exchange %s SetAssetEnabled must not error for asset %s: %s", name, a, err) } - // Add +1 to len to verify that exchanges can handle requests with unset pairs and assets assetPairs := make([]assetPair, 0, len(assets)+1) assets: @@ -147,7 +143,6 @@ assets: }) } assetPairs = append(assetPairs, assetPair{}) - return exch, assetPairs } @@ -191,7 +186,6 @@ func executeExchangeWrapperTests(ctx context.Context, t *testing.T, exch exchang continue } method := actualExchange.MethodByName(methodName) - var assetLen int for y := range method.Type().NumIn() { input := method.Type().In(y) @@ -391,6 +385,7 @@ func generateMethodArg(ctx context.Context, t *testing.T, argGenerator *MethodAr Description: "1337", Amount: 1, ClientOrderID: "1337", + WalletID: "7331", } if argGenerator.MethodName == "WithdrawCryptocurrencyFunds" { req.Type = withdraw.Crypto @@ -614,10 +609,9 @@ var unsupportedAssets = []asset.Item{ var unsupportedExchangeNames = []string{ "testexch", - "bitflyer", // Bitflyer has many "ErrNotYetImplemented, which is true, but not what we care to test for here - "btse", // TODO rm once timeout issues resolved - "poloniex", // outdated API // TODO rm once updated - "coinbasepro", // outdated API // TODO rm once updated + "bitflyer", // Bitflyer has many "ErrNotYetImplemented, which is true, but not what we care to test for here + "btse", // TODO rm once timeout issues resolved + "poloniex", // outdated API // TODO rm once updated } // cryptoChainPerExchange holds the deposit address chain per exchange diff --git a/common/common.go b/common/common.go index b1979a7b..1f4641a4 100644 --- a/common/common.go +++ b/common/common.go @@ -55,6 +55,7 @@ var ( // Public common Errors var ( + ErrExchangeNameNotSet = errors.New("exchange name not set") ErrNotYetImplemented = errors.New("not yet implemented") ErrFunctionNotSupported = errors.New("unsupported wrapper function") ErrAddressIsEmptyOrInvalid = errors.New("address is empty or invalid") diff --git a/config/config.go b/config/config.go index 2551022e..cfdab519 100644 --- a/config/config.go +++ b/config/config.go @@ -916,7 +916,7 @@ func (c *Config) CheckExchangeConfigValues() error { continue } if e.Name == "" { - log.Errorf(log.ConfigMgr, "%s: #%d", errExchangeNameEmpty, i) + log.Errorf(log.ConfigMgr, "%s: #%d", common.ErrExchangeNameNotSet, i) e.Enabled = false continue } diff --git a/config/config_types.go b/config/config_types.go index 63c4493f..2c8ee22c 100644 --- a/config/config_types.go +++ b/config/config_types.go @@ -83,7 +83,6 @@ var ( errNoEnabledExchanges = errors.New("no exchanges enabled") errCheckingConfigValues = errors.New("fatal error checking config values") - errExchangeNameEmpty = errors.New("exchange name is empty") ) // Config is the overarching object that holds all the information for diff --git a/config/versions/register.go b/config/versions/register.go index 1239a337..8af8927d 100644 --- a/config/versions/register.go +++ b/config/versions/register.go @@ -10,6 +10,7 @@ import ( v6 "github.com/thrasher-corp/gocryptotrader/config/versions/v6" v7 "github.com/thrasher-corp/gocryptotrader/config/versions/v7" v8 "github.com/thrasher-corp/gocryptotrader/config/versions/v8" + v9 "github.com/thrasher-corp/gocryptotrader/config/versions/v9" ) func init() { @@ -22,4 +23,5 @@ func init() { Manager.registerVersion(6, &v6.Version{}) Manager.registerVersion(7, &v7.Version{}) Manager.registerVersion(8, &v8.Version{}) + Manager.registerVersion(9, &v9.Version{}) } diff --git a/config/versions/v9/v9.go b/config/versions/v9/v9.go new file mode 100644 index 00000000..04a3e205 --- /dev/null +++ b/config/versions/v9/v9.go @@ -0,0 +1,29 @@ +package v9 + +import ( + "context" + + "github.com/buger/jsonparser" +) + +// Version is an ExchangeVersion to change the name of CoinbasePro to Coinbase +type Version struct{} + +// Exchanges returns just CoinbasePro and Coinbase +func (*Version) Exchanges() []string { return []string{"CoinbasePro", "Coinbase"} } + +// UpgradeExchange will change the exchange name from CoinbasePro to Coinbase +func (*Version) UpgradeExchange(_ context.Context, e []byte) ([]byte, error) { + if n, err := jsonparser.GetString(e, "name"); err == nil && n == "CoinbasePro" { + return jsonparser.Set(e, []byte(`"Coinbase"`), "name") + } + return e, nil +} + +// DowngradeExchange will change the exchange name from Coinbase to CoinbasePro +func (*Version) DowngradeExchange(_ context.Context, e []byte) ([]byte, error) { + if n, err := jsonparser.GetString(e, "name"); err == nil && n == "Coinbase" { + return jsonparser.Set(e, []byte(`"CoinbasePro"`), "name") + } + return e, nil +} diff --git a/config/versions/v9/v9_test.go b/config/versions/v9/v9_test.go new file mode 100644 index 00000000..384eb0dd --- /dev/null +++ b/config/versions/v9/v9_test.go @@ -0,0 +1,37 @@ +package v9_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + v9 "github.com/thrasher-corp/gocryptotrader/config/versions/v9" +) + +func TestUpgradeExchange(t *testing.T) { + t.Parallel() + for _, tt := range [][]string{ + {"CoinbasePro", "Coinbase"}, + {"Kraken", "Kraken"}, + {"Coinbase", "Coinbase"}, + } { + out, err := new(v9.Version).UpgradeExchange(t.Context(), []byte(`{"name":"`+tt[0]+`"}`)) + require.NoError(t, err) + require.NotEmpty(t, out) + assert.Equalf(t, `{"name":"`+tt[1]+`"}`, string(out), "Test exchange name %s", tt[0]) + } +} + +func TestDowngradeExchange(t *testing.T) { + t.Parallel() + for _, tt := range [][]string{ + {"Coinbase", "CoinbasePro"}, + {"Kraken", "Kraken"}, + {"CoinbasePro", "CoinbasePro"}, + } { + out, err := new(v9.Version).DowngradeExchange(t.Context(), []byte(`{"name":"`+tt[0]+`"}`)) + require.NoError(t, err) + require.NotEmpty(t, out) + assert.Equalf(t, `{"name":"`+tt[1]+`"}`, string(out), "Test exchange name %s", tt[0]) + } +} diff --git a/config_example.json b/config_example.json index b48c493a..4809156e 100644 --- a/config_example.json +++ b/config_example.json @@ -1222,16 +1222,17 @@ ] }, { - "name": "CoinbasePro", + "name": "Coinbase", "enabled": true, "verbose": false, "httpTimeout": 15000000000, "websocketResponseCheckTimeout": 30000000, "websocketResponseMaxLimit": 7000000000, "websocketTrafficTimeout": 30000000000, - "websocketOrderbookBufferLimit": 5, + "connectionMonitorDelay": 2000000000, "baseCurrencies": "USD,GBP,EUR", "currencyPairs": { + "bypassConfigFormatUpgrades": false, "requestFormat": { "uppercase": true, "delimiter": "-" @@ -1241,49 +1242,99 @@ "delimiter": "-" }, "useGlobalFormat": true, - "assetTypes": [ - "spot" - ], "pairs": { + "futures": { + "assetEnabled": true, + "enabled": "GOL-28JAN25-CDE", + "available": "BIT-27SEP24-CDE,ET-27SEP24-CDE,GOL-27NOV24-CDE,BIT-25OCT24-CDE,NOL-19SEP24-CDE,BCH-27SEP24-CDE,LC-27SEP24-CDE,AVA-27SEP24-CDE,LNK-27SEP24-CDE,LC-25OCT24-CDE,DOT-27SEP24-CDE,DOG-27SEP24-CDE,SHB-27SEP24-CDE,BIT-29NOV24-CDE,AVA-25OCT24-CDE,ET-25OCT24-CDE,DOT-25OCT24-CDE,ET-29NOV24-CDE,DOG-29NOV24-CDE,GOL-28JAN25-CDE,GOL-31MAR25-CDE,DOG-25OCT24-CDE,DOT-29NOV24-CDE,LC-29NOV24-CDE,LNK-25OCT24-CDE,BCH-29NOV24-CDE,LNK-29NOV24-CDE,NOL-19NOV24-CDE,BCH-25OCT24-CDE,NOL-21OCT24-CDE,SHB-25OCT24-CDE,AVA-29NOV24-CDE,SHB-29NOV24-CDE" + }, "spot": { - "enabled": "BTC-USD", - "available": "ETC-GBP,CVC-USDC,LINK-ETH,KNC-BTC,GNT-USDC,EOS-BTC,ETC-BTC,LTC-BTC,ZRX-USD,XRP-EUR,ZRX-EUR,ATOM-USD,BTC-USD,LTC-EUR,XRP-USD,MANA-USDC,XRP-BTC,LTC-GBP,DAI-USD,COMP-BTC,ETH-DAI,XTZ-USD,DASH-BTC,OMG-BTC,BTC-USDC,BCH-USD,DNT-USDC,COMP-USD,LOOM-USDC,OMG-GBP,BCH-GBP,ZRX-BTC,ATOM-BTC,EOS-EUR,ETH-USD,XLM-EUR,KNC-USD,OXT-USD,ETC-EUR,OMG-USD,BTC-GBP,OMG-EUR,DASH-USD,MKR-BTC,XTZ-BTC,BAT-ETH,REP-USD,XLM-BTC,ETH-USDC,REP-BTC,LTC-USD,ZEC-BTC,ZEC-USDC,EOS-USD,MKR-USD,ALGO-USD,LINK-USD,BCH-EUR,XLM-USD,ETH-GBP,ETC-USD,ETH-EUR,BCH-BTC,BTC-EUR,ETH-BTC,DAI-USDC,BAT-USDC" + "assetEnabled": true, + "enabled": "BTC-USD,BTC-USDC,USDT-USD,ETH-USD,ETH-USDC,SOL-USD", + "available": "BTC-USD,BTC-USDC,ETH-USDC,ETH-USD,USDT-USD,SOL-USDC,SOL-USD,USDT-USDC,FET-USDC,FET-USD,BTC-USDT,XRP-USD,XRP-USDC,DOGE-USDC,DOGE-USD,BONK-USDC,BONK-USD,USDT-EUR,ETH-USDT,BTC-EUR,USDC-EUR,AAVE-USDC,AAVE-USD,SUI-USDC,SUI-USD,LINK-USD,LINK-USDC,LTC-USD,LTC-USDC,SHIB-USDC,SHIB-USD,BTC-GBP,JASMY-USD,JASMY-USDC,ETH-EUR,AVAX-USDC,AVAX-USD,NEAR-USD,NEAR-USDC,ONDO-USD,ONDO-USDC,ADA-USD,ADA-USDC,SUPER-USDC,SUPER-USD,UNI-USD,UNI-USDC,RARE-USD,RARE-USDC,RNDR-USD,RNDR-USDC,INJ-USDC,INJ-USD,SEI-USDC,SEI-USD,HBAR-USDC,HBAR-USD,XLM-USDC,XLM-USD,SOL-EUR,HNT-USD,HNT-USDC,BCH-USD,BCH-USDC,STX-USDC,STX-USD,TRB-USDC,TRB-USD,PNG-USD,PNG-USDC,MATIC-USDC,MATIC-USD,MKR-USD,MKR-USDC,SOL-USDT,APT-USDC,APT-USD,EURC-USDC,ICP-USD,ICP-USDC,IDEX-USDC,IDEX-USD,AERO-USDC,AERO-USD,ETH-GBP,DAI-USD,DAI-USDC,00-USD,00-USDC,USDC-GBP,MASK-USD,MASK-USDC,USDT-GBP,RENDER-USD,RENDER-USDC,ZRO-USDC,ZRO-USD,CRV-USDC,CRV-USD,DOT-USDC,DOT-USD,TIA-USDC,TIA-USD,GRT-USD,GRT-USDC,FIL-USDC,FIL-USD,IMX-USDC,IMX-USD,OP-USD,OP-USDC,JTO-USD,JTO-USDC,FET-USDT,LDO-USD,LDO-USDC,SOL-GBP,ARB-USD,ARB-USDC,MSOL-USDC,MSOL-USD,GFI-USD,GFI-USDC,MINA-USD,MINA-USDC,ATOM-USD,ATOM-USDC,QNT-USDC,QNT-USD,NEAR-USDT,PRO-USD,PRO-USDC,FORT-USDC,FORT-USD,PRIME-USD,PRIME-USDC,ZEC-USDC,ZEC-USD,TVK-USD,TVK-USDC,XRP-USDT,DOGE-USDT,1INCH-USDC,1INCH-USD,AIOZ-USD,AIOZ-USDC,ABT-USD,ABT-USDC,ROSE-USDC,ROSE-USD,SKL-USDC,SKL-USD,COMP-USDC,COMP-USD,ZETA-USD,ZETA-USDC,TRU-USD,TRU-USDC,AXL-USDC,AXL-USD,AKT-USDC,AKT-USD,XRP-EUR,KARRAT-USD,KARRAT-USDC,CBETH-USD,CBETH-USDC,BICO-USDT,VARA-USD,VARA-USDC,AAVE-EUR,SUSHI-USD,SUSHI-USDC,ALGO-USDC,ALGO-USD,BICO-USDC,BICO-USD,VELO-USDC,VELO-USD,UMA-USD,UMA-USDC,ETC-USDC,ETC-USD,LRDS-USDC,LRDS-USD,ENS-USD,ENS-USDC,LTC-EUR,VET-USDC,VET-USD,SYN-USD,SYN-USDC,LPT-USDC,LPT-USD,OCEAN-USDC,OCEAN-USD,LQTY-USDC,LQTY-USD,AMP-USDC,AMP-USD,BIGTIME-USD,BIGTIME-USDC,LRC-USD,LRC-USDC,ACH-USD,ACH-USDC,VOXEL-USDC,VOXEL-USD,SAND-USDC,SAND-USD,MPL-USD,MPL-USDC,SHIB-EUR,GTC-USDC,GTC-USD,ARKM-USD,ARKM-USDC,COTI-USD,COTI-USDC,ILV-USD,ILV-USDC,FLR-USD,FLR-USDC,ICP-USDT,LCX-USD,LCX-USDC,MOBILE-USD,MOBILE-USDC,DRIFT-USDC,DRIFT-USD,DOGE-EUR,SHIB-USDT,APE-USD,APE-USDC,GODS-USDC,GODS-USD,EOS-USD,EOS-USDC,XTZ-USDC,XTZ-USD,BLUR-USD,BLUR-USDC,ZRX-USDC,ZRX-USD,AUDIO-USD,AUDIO-USDC,ANKR-USD,ANKR-USDC,UNI-EUR,GST-USD,GST-USDC,ORCA-USD,ORCA-USDC,PRQ-USDC,PRQ-USD,AVAX-EUR,SHPING-USDC,SHPING-USD,LOKA-USDC,LOKA-USD,NEON-USD,NEON-USDC,API3-USDC,API3-USD,STRK-USD,STRK-USDC,XCN-USD,XCN-USDC,AVAX-USDT,ADA-EUR,LTC-GBP,WBTC-USD,WBTC-USDC,METIS-USDC,METIS-USD,HIGH-USDC,HIGH-USD,EGLD-USD,EGLD-USDC,ETH-DAI,DESO-USDC,DESO-USD,BIT-USD,BIT-USDC,YFI-USDC,YFI-USD,MASK-EUR,CRO-USDC,CRO-USD,HOPR-USD,HOPR-USDC,CHZ-USD,CHZ-USDC,MATIC-EUR,JASMY-USDT,RBN-USDC,RBN-USD,ADA-USDT,RARI-USDC,RARI-USD,OP-USDT,POLS-USD,POLS-USDC,DIMO-USD,DIMO-USDC,OGN-USD,OGN-USDC,BAL-USD,BAL-USDC,SNX-USD,SNX-USDC,TRAC-USD,TRAC-USDC,QI-USD,QI-USDC,IOTX-USDC,IOTX-USD,MNDE-USD,MNDE-USDC,ACX-USDC,ACX-USD,LINK-EUR,MAGIC-USDC,MAGIC-USD,ALEPH-USDC,ALEPH-USD,T-USDC,T-USD,RONIN-USD,RONIN-USDC,RPL-USD,RPL-USDC,AAVE-GBP,CLV-USDC,CLV-USD,MANA-USDC,MANA-USD,BLZ-USDC,BLZ-USD,AVT-USD,AVT-USDC,PYR-USDC,PYR-USD,DYP-USDC,DYP-USD,OXT-USDC,OXT-USD,A8-USD,A8-USDC,SWFTC-USDC,SWFTC-USD,MATIC-USDT,KAVA-USD,KAVA-USDC,HONEY-USD,HONEY-USDC,ICP-EUR,NCT-USD,NCT-USDC,BLAST-USDC,BLAST-USD,NMR-USD,NMR-USDC,DASH-USDC,DASH-USD,DAR-USD,DAR-USDC,BTRST-USD,BTRST-USDC,CGLD-USDC,CGLD-USD,SPA-USDC,SPA-USD,RAD-USDC,RAD-USD,DNT-USD,DNT-USDC,PIRATE-USDC,PIRATE-USD,MDT-USD,MDT-USDC,CVX-USD,CVX-USDC,APE-EUR,BCH-EUR,AUCTION-USD,AUCTION-USDC,RNDR-USDT,WCFG-USDC,WCFG-USD,APT-USDT,MASK-USDT,CTSI-USD,CTSI-USDC,POND-USDC,POND-USD,DOT-EUR,ADA-GBP,INDEX-USD,INDEX-USDC,SPELL-USDC,SPELL-USD,BAT-USD,BAT-USDC,SUKU-USDC,SUKU-USD,ALGO-EUR,SEAM-USD,SEAM-USDC,BOBA-USDC,BOBA-USD,ALGO-GBP,ACS-USDC,ACS-USD,MANA-EUR,MLN-USD,MLN-USDC,TNSR-USDC,TNSR-USD,FIL-EUR,FLOW-USDC,FLOW-USD,XYO-USDC,XYO-USD,GUSD-USD,GUSD-USDC,VTHO-USD,VTHO-USDC,RLC-USDC,RLC-USD,1INCH-EUR,ALCX-USDC,ALCX-USD,CORECHAIN-USD,CORECHAIN-USDC,SD-USD,SD-USDC,STX-USDT,FX-USDC,FX-USD,XLM-EUR,SHDW-USDC,SHDW-USD,HFT-USD,HFT-USDC,STORJ-USDC,STORJ-USD,SAFE-USDC,SAFE-USD,FIDA-USD,FIDA-USDC,MATH-USD,MATH-USDC,NKN-USDC,NKN-USD,PYUSD-USDC,PYUSD-USD,CELR-USDC,CELR-USD,AXS-USD,AXS-USDC,ALICE-USDC,ALICE-USD,ICP-GBP,GLM-USDC,GLM-USD,FOX-USDC,FOX-USD,AURORA-USDC,AURORA-USD,FARM-USD,FARM-USDC,EOS-EUR,DOT-USDT,HBAR-USDT,GMT-USDC,GMT-USD,GRT-EUR,CTX-USDC,CTX-USD,CHZ-EUR,ETC-EUR,ORN-USDC,ORN-USD,MEDIA-USD,MEDIA-USDC,ATOM-EUR,ASM-USD,ASM-USDC,AGLD-USDC,AGLD-USD,LINK-USDT,KNC-USD,KNC-USDC,DOGE-GBP,SAND-USDT,INV-USDC,INV-USD,GAL-USD,GAL-USDC,PERP-USD,PERP-USDC,POWR-USDC,POWR-USD,KSM-USD,KSM-USDC,BAND-USD,BAND-USDC,ELA-USDC,ELA-USD,G-USD,G-USDC,PLU-USDC,PLU-USD,ROSE-USDT,QNT-USDT,APE-USDT,1INCH-GBP,REQ-USD,REQ-USDC,AXS-USDT,WAXL-USD,WAXL-USDC,ZEN-USD,ZEN-USDC,BCH-GBP,BADGER-USDC,BADGER-USD,FORTH-USD,FORTH-USDC,FIL-GBP,CVC-USDC,CVC-USD,DOT-GBP,LIT-USDC,LIT-USD,CRV-EUR,LINK-GBP,AST-USDC,AST-USD,FIS-USDC,FIS-USD,ENS-EUR,ATOM-USDT,IMX-USDT,SHIB-GBP,MATIC-GBP,DIA-USD,DIA-USDC,ERN-USDC,ERN-USD,OSMO-USD,OSMO-USDC,CHZ-USDT,ARPA-USD,ARPA-USDC,RNDR-EUR,TIME-USD,TIME-USDC,ENS-USDT,OMNI-USD,OMNI-USDC,ATOM-GBP,C98-USD,C98-USDC,PUNDIX-USD,PUNDIX-USDC,MUSE-USD,MUSE-USDC,LRC-USDT,MINA-EUR,GHST-USD,GHST-USDC,ETC-GBP,AXS-EUR,LSETH-USDC,LSETH-USD,GNO-USD,GNO-USDC,BNT-USDC,BNT-USD,AERGO-USD,AERGO-USDC,SNX-EUR,CHZ-GBP,UNI-GBP,FLOW-USDT,BAT-EUR,MINA-USDT,DEXT-USDC,DEXT-USD,XLM-USDT,GYEN-USDC,GYEN-USD,BICO-EUR,CRV-GBP,ANKR-EUR,CGLD-EUR,XTZ-GBP,MASK-GBP,KRL-USDC,KRL-USD,CRO-EUR,ANKR-GBP,XTZ-EUR,GRT-GBP,GMT-USDT,SNX-GBP,STG-USD,STG-USDC,CRO-USDT,PAX-USD,PAX-USDC,CGLD-GBP,SOL-ETH,ETH-BTC,ADA-ETH,SOL-BTC,AAVE-BTC,WBTC-BTC,ZEC-BTC,CBETH-ETH,LINK-ETH,LTC-BTC,UNI-BTC,BCH-BTC,DOGE-BTC,AVAX-BTC,ADA-BTC,LSETH-ETH,MKR-BTC,GRT-BTC,MATIC-BTC,COMP-BTC,LRC-BTC,DOT-BTC,XLM-BTC,FIL-BTC,ICP-BTC,EOS-BTC,BAT-ETH,LINK-BTC,CRV-BTC,ATOM-BTC,BAL-BTC,ALGO-BTC,1INCH-BTC,BAT-BTC,MANA-ETH,ETC-BTC,DASH-BTC,SNX-BTC,YFI-BTC,XTZ-BTC,AXS-BTC,ANKR-BTC,MANA-BTC,CGLD-BTC" } } }, "api": { "authenticatedSupport": false, "authenticatedWebsocketApiSupport": false, - "endpoints": { - "url": "NON_DEFAULT_HTTP_LINK_TO_EXCHANGE_API", - "urlSecondary": "NON_DEFAULT_HTTP_LINK_TO_EXCHANGE_API", - "websocketURL": "NON_DEFAULT_HTTP_LINK_TO_WEBSOCKET_EXCHANGE_API" - }, "credentials": { - "key": "Key", - "secret": "Secret", - "clientID": "ClientID" + "key": "", + "secret": "", + "clientID": "" }, "credentialsValidator": { "requiresKey": true, "requiresSecret": true, "requiresClientID": true, "requiresBase64DecodeSecret": true + }, + "urlEndpoints": { + "RestSandboxURL": "https://api-sandbox.coinbase.com", + "RestSpotURL": "https://api.coinbase.com", + "WebsocketSpotURL": "wss://advanced-trade-ws.coinbase.com" } }, "features": { "supports": { "restAPI": true, "restCapabilities": { - "autoPairUpdates": true + "autoPairUpdates": true, + "fundingRateFetching": false }, "websocketAPI": true, - "websocketCapabilities": {} + "websocketCapabilities": { + "fundingRateFetching": false + } }, "enabled": { "autoPairUpdates": true, - "websocketAPI": false - } + "websocketAPI": true, + "saveTradeData": false, + "tradeFeed": false, + "fillsFeed": false + }, + "subscriptions": [ + { + "enabled": true, + "channel": "heartbeat" + }, + { + "enabled": false, + "channel": "status", + "authenticated": true + }, + { + "enabled": true, + "channel": "ticker", + "asset": "spot" + }, + { + "enabled": true, + "channel": "candles", + "asset": "spot" + }, + { + "enabled": true, + "channel": "allTrades", + "asset": "spot" + }, + { + "enabled": true, + "channel": "orderbook", + "asset": "spot" + }, + { + "enabled": true, + "channel": "account", + "authenticated": true + }, + { + "enabled": false, + "channel": "ticker_batch", + "asset": "spot" + } + ] }, "bankAccounts": [ { @@ -1299,7 +1350,13 @@ "iban": "", "supportedCurrencies": "" } - ] + ], + "orderbook": { + "verificationBypass": false, + "websocketBufferLimit": 5, + "websocketBufferEnabled": false, + "publishPeriod": 10000000000 + } }, { "name": "Deribit", diff --git a/currency/code_types.go b/currency/code_types.go index b77ae41a..11476df7 100644 --- a/currency/code_types.go +++ b/currency/code_types.go @@ -3021,12 +3021,15 @@ var ( FI = NewCode("FI") USDM = NewCode("USDM") USDTM = NewCode("USDTM") + CBETH = NewCode("CBETH") + PYUSD = NewCode("PYUSD") + EUROC = NewCode("EUROC") + LSETH = NewCode("LSETH") LEVER = NewCode("LEVER") NESS = NewCode("NESS") KAS = NewCode("KAS") NEXT = NewCode("NEXT") VEXT = NewCode("VEXT") - PYUSD = NewCode("PYUSD") SAIL = NewCode("SAIL") VV = NewCode("VV") ORDI = NewCode("ORDI") diff --git a/currency/forexprovider/base/base_interface.go b/currency/forexprovider/base/base_interface.go index 724b27bc..39bbf0d3 100644 --- a/currency/forexprovider/base/base_interface.go +++ b/currency/forexprovider/base/base_interface.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "maps" + "slices" "strings" "sync" @@ -11,6 +12,11 @@ import ( "github.com/thrasher-corp/gocryptotrader/log" ) +var ( + errNoProvider = errors.New("no supporting foreign exchange providers set") + errUnsupportedCurrencies = errors.New("currencies not supported by provider") +) + // IFXProvider enforces standard functions for all foreign exchange providers // supported in GoCryptoTrader type IFXProvider interface { @@ -106,47 +112,27 @@ func (f *FXHandler) GetCurrencyData(baseCurrency string, currencies []string) (m return rates, nil } -// backupGetRate uses the currencies that are supported and falls through, and -// errors when unsupported currency found +// backupGetRate uses the currencies that are supported and falls through, and errors when unsupported currency found func (f *FXHandler) backupGetRate(base string, currencies []string) (map[string]float64, error) { if f.Support == nil { - return nil, errors.New("no supporting foreign exchange providers set") + return nil, errNoProvider } - - var shunt []string rate := make(map[string]float64) - for i := range f.Support { - if len(shunt) != 0 { - shunt = f.Support[i].CheckCurrencies(shunt) - newRate, err := f.Support[i].GetNewRate(base, shunt) - if err != nil { - continue - } - - maps.Copy(rate, newRate) - - if len(shunt) != 0 { - continue - } - - return rate, nil - } - - shunt = f.Support[i].CheckCurrencies(currencies) - newRate, err := f.Support[i].GetNewRate(base, currencies) + missed := f.Support[i].CheckCurrencies(currencies) + toGet := slices.DeleteFunc(currencies, func(s string) bool { + return common.StringSliceContains(missed, s) + }) + newRate, err := f.Support[i].GetNewRate(base, toGet) if err != nil { - continue + return nil, err } - maps.Copy(rate, newRate) - - if len(shunt) != 0 { + if len(missed) != 0 { + currencies = missed continue } - return rate, nil } - - return nil, fmt.Errorf("currencies %s not supported", shunt) + return nil, fmt.Errorf("%w: %s", errUnsupportedCurrencies, currencies) } diff --git a/currency/forexprovider/base/base_interface_test.go b/currency/forexprovider/base/base_interface_test.go new file mode 100644 index 00000000..6e75c601 --- /dev/null +++ b/currency/forexprovider/base/base_interface_test.go @@ -0,0 +1,66 @@ +package base + +import ( + "errors" + "math/rand/v2" + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +var errCurrencyNotSupported = errors.New("currency not supported") + +type MockProvider struct { + IFXProvider + value float64 +} + +func (m *MockProvider) IsEnabled() bool { + return true +} + +func (m *MockProvider) GetName() string { + return "" +} + +func (m *MockProvider) GetRates(baseCurrency, symbols string) (map[string]float64, error) { + c := map[string]float64{} + for s := range strings.SplitSeq(symbols, ",") { + if s == "XRP" && m.value == 1.5 { + return nil, errCurrencyNotSupported + } + if s == "BTC" { + c[baseCurrency+s] = m.value + continue + } + c[baseCurrency+s] = 1 / (1 + rand.Float64()) //nolint:gosec // Doesn't need to be a strong random number + } + return c, nil +} + +func TestBackupGetRate(t *testing.T) { + var f FXHandler + _, err := f.backupGetRate("", nil) + assert.ErrorIs(t, err, errNoProvider) + f.Support = append(f.Support, Provider{ + SupportedCurrencies: []string{"BTC", "ETH", "XRP"}, + Provider: &MockProvider{ + value: 1.5, + }, + }, Provider{ + SupportedCurrencies: []string{"BTC", "LTC", "XRP"}, + Provider: &MockProvider{ + value: 2.5, + }, + }) + _, err = f.backupGetRate("", []string{"XRP"}) + assert.ErrorIs(t, err, errCurrencyNotSupported) + _, err = f.backupGetRate("", []string{"NOTREALCURRENCY"}) + assert.ErrorIs(t, err, errUnsupportedCurrencies) + f.Support[0].SupportedCurrencies = []string{"BTC", "ETH"} + r, err := f.backupGetRate("USD", []string{"BTC", "ETH", "LTC", "XRP"}) + assert.NoError(t, err) + assert.Len(t, r, 4) + assert.Equal(t, 1.5, r["USDBTC"]) +} diff --git a/currency/pairs.go b/currency/pairs.go index 9c130467..60b0016a 100644 --- a/currency/pairs.go +++ b/currency/pairs.go @@ -145,7 +145,6 @@ list: if p.Contains(check[x], exact) { return fmt.Errorf("%s %w", check[x], ErrPairDuplication) } - return fmt.Errorf("%s %w", check[x], ErrPairNotContainedInAvailablePairs) } return nil diff --git a/docs/ADD_NEW_EXCHANGE.md b/docs/ADD_NEW_EXCHANGE.md index 580cc048..059259f0 100644 --- a/docs/ADD_NEW_EXCHANGE.md +++ b/docs/ADD_NEW_EXCHANGE.md @@ -145,7 +145,7 @@ Similar to the configs, spot support is inbuilt but other asset types will need | COINUT | Yes | Yes | NA | | Deribit | Yes | Yes | NA | | Exmo | Yes | NA | NA | -| CoinbasePro | Yes | Yes | No| +| Coinbase | Yes | Yes | No| | GateIO | Yes | Yes | NA | | Gemini | Yes | Yes | No | | HitBTC | Yes | Yes | No | @@ -171,7 +171,7 @@ var Exchanges = []string{ "btc markets", "btse", "bybit", - "coinbasepro", + "coinbase", "coinut", "deribit", "exmo", diff --git a/docs/MULTICHAIN_TRANSFER_SUPPORT.md b/docs/MULTICHAIN_TRANSFER_SUPPORT.md index 44f9e9c8..d2b8fbe2 100644 --- a/docs/MULTICHAIN_TRANSFER_SUPPORT.md +++ b/docs/MULTICHAIN_TRANSFER_SUPPORT.md @@ -52,7 +52,7 @@ $ ./gctcli withdrawcryptofunds --exchange=binance --currency=USDT --address=TJU9 | BTCMarkets | No | No| NA | | BTSE | No | No | Only through website | | Bybit | Yes | Yes | | -| CoinbasePro | No | No | No| +| Coinbase | No | No | No| | COINUT | No | No | NA | | Deribit | Yes | Yes | | | Exmo | Yes | Yes | Addresses must be created via their website first | diff --git a/encoding/json/common.go b/encoding/json/common.go index 01e85788..e454e2e0 100644 --- a/encoding/json/common.go +++ b/encoding/json/common.go @@ -13,4 +13,6 @@ type ( // An UnmarshalTypeError describes a JSON value that was // not appropriate for a value of a specific Go type. UnmarshalTypeError = json.UnmarshalTypeError + // A SyntaxError describes improper JSON + SyntaxError = json.SyntaxError ) diff --git a/engine/datahistory_manager.go b/engine/datahistory_manager.go index 181a0bed..3d68505b 100644 --- a/engine/datahistory_manager.go +++ b/engine/datahistory_manager.go @@ -1195,11 +1195,11 @@ func (m *DataHistoryManager) validateJob(job *DataHistoryJob) error { exchangeName := job.Exchange if job.DataType == dataHistoryCandleValidationSecondarySourceType { if job.SecondaryExchangeSource == "" { - return fmt.Errorf("job %s %w, secondary exchange name required to lookup existing results", job.Nickname, errExchangeNameUnset) + return fmt.Errorf("job %s %w, secondary exchange name required to lookup existing results", job.Nickname, common.ErrExchangeNameNotSet) } exchangeName = job.SecondaryExchangeSource if job.Exchange == "" { - return fmt.Errorf("job %s %w, exchange name required to lookup existing results", job.Nickname, errExchangeNameUnset) + return fmt.Errorf("job %s %w, exchange name required to lookup existing results", job.Nickname, common.ErrExchangeNameNotSet) } } exch, err := m.exchangeManager.GetExchangeByName(exchangeName) diff --git a/engine/datahistory_manager_test.go b/engine/datahistory_manager_test.go index 84d0f573..81fe57bb 100644 --- a/engine/datahistory_manager_test.go +++ b/engine/datahistory_manager_test.go @@ -414,12 +414,12 @@ func TestValidateJob(t *testing.T) { dhj.DataType = dataHistoryCandleValidationSecondarySourceType err = m.validateJob(dhj) - assert.ErrorIs(t, err, errExchangeNameUnset) + assert.ErrorIs(t, err, common.ErrExchangeNameNotSet) dhj.SecondaryExchangeSource = "lol" dhj.Exchange = "" err = m.validateJob(dhj) - assert.ErrorIs(t, err, errExchangeNameUnset) + assert.ErrorIs(t, err, common.ErrExchangeNameNotSet) } func TestGetAllJobStatusBetween(t *testing.T) { diff --git a/engine/depositaddress.go b/engine/depositaddress.go index 3f25d258..6c771188 100644 --- a/engine/depositaddress.go +++ b/engine/depositaddress.go @@ -3,7 +3,6 @@ package engine import ( "errors" "fmt" - "maps" "slices" "strings" "sync" @@ -23,9 +22,12 @@ var ( // DepositAddressManager manages the exchange deposit address store type DepositAddressManager struct { m sync.RWMutex - store map[string]map[string][]deposit.Address + store map[string]ExchangeDepositAddresses } +// ExchangeDepositAddresses is a map of currencies to their deposit addresses +type ExchangeDepositAddresses map[string][]deposit.Address + // IsSynced returns whether or not the deposit address store has synced its data func (m *DepositAddressManager) IsSynced() bool { if m.store == nil { @@ -39,7 +41,7 @@ func (m *DepositAddressManager) IsSynced() bool { // SetupDepositAddressManager returns a DepositAddressManager func SetupDepositAddressManager() *DepositAddressManager { return &DepositAddressManager{ - store: make(map[string]map[string][]deposit.Address), + store: make(map[string]ExchangeDepositAddresses), } } @@ -86,7 +88,7 @@ func (m *DepositAddressManager) GetDepositAddressByExchangeAndCurrency(exchName, // GetDepositAddressesByExchange returns a list of cryptocurrency addresses for the specified // exchange if they exist -func (m *DepositAddressManager) GetDepositAddressesByExchange(exchName string) (map[string][]deposit.Address, error) { +func (m *DepositAddressManager) GetDepositAddressesByExchange(exchName string) (ExchangeDepositAddresses, error) { m.m.RLock() defer m.m.RUnlock() @@ -99,15 +101,15 @@ func (m *DepositAddressManager) GetDepositAddressesByExchange(exchName string) ( return nil, ErrDepositAddressNotFound } - cpy := maps.Clone(r) - for k, v := range cpy { + cpy := make(ExchangeDepositAddresses, len(r)) + for k, v := range r { cpy[k] = slices.Clone(v) } return cpy, nil } // Sync synchronises all deposit addresses -func (m *DepositAddressManager) Sync(addresses map[string]map[string][]deposit.Address) error { +func (m *DepositAddressManager) Sync(addresses map[string]ExchangeDepositAddresses) error { if m == nil { return fmt.Errorf("deposit address manager %w", ErrNilSubsystem) } @@ -118,7 +120,7 @@ func (m *DepositAddressManager) Sync(addresses map[string]map[string][]deposit.A } for k, v := range addresses { - r := make(map[string][]deposit.Address) + r := make(ExchangeDepositAddresses) for w, x := range v { r[strings.ToUpper(w)] = x } diff --git a/engine/depositaddress_test.go b/engine/depositaddress_test.go index 00ec7a0f..f15b1c9d 100644 --- a/engine/depositaddress_test.go +++ b/engine/depositaddress_test.go @@ -21,7 +21,7 @@ func TestIsSynced(t *testing.T) { t.Error("should be false") } m := SetupDepositAddressManager() - err := m.Sync(map[string]map[string][]deposit.Address{ + err := m.Sync(map[string]ExchangeDepositAddresses{ bitStamp: { btc: []deposit.Address{ { @@ -49,7 +49,7 @@ func TestSetupDepositAddressManager(t *testing.T) { func TestSync(t *testing.T) { t.Parallel() m := SetupDepositAddressManager() - err := m.Sync(map[string]map[string][]deposit.Address{ + err := m.Sync(map[string]ExchangeDepositAddresses{ bitStamp: { btc: []deposit.Address{ { @@ -70,7 +70,7 @@ func TestSync(t *testing.T) { } m.store = nil - err = m.Sync(map[string]map[string][]deposit.Address{ + err = m.Sync(map[string]ExchangeDepositAddresses{ bitStamp: { btc: []deposit.Address{ { @@ -82,7 +82,7 @@ func TestSync(t *testing.T) { assert.ErrorIs(t, err, ErrDepositAddressStoreIsNil) m = nil - err = m.Sync(map[string]map[string][]deposit.Address{ + err = m.Sync(map[string]ExchangeDepositAddresses{ bitStamp: { btc: []deposit.Address{ { @@ -100,7 +100,7 @@ func TestGetDepositAddressByExchangeAndCurrency(t *testing.T) { _, err := m.GetDepositAddressByExchangeAndCurrency("", "", currency.BTC) assert.ErrorIs(t, err, ErrDepositAddressStoreIsNil) - m.store = map[string]map[string][]deposit.Address{ + m.store = map[string]ExchangeDepositAddresses{ bitStamp: { btc: []deposit.Address{ { @@ -155,7 +155,7 @@ func TestGetDepositAddressesByExchange(t *testing.T) { _, err := m.GetDepositAddressesByExchange("") assert.ErrorIs(t, err, ErrDepositAddressStoreIsNil) - m.store = map[string]map[string][]deposit.Address{ + m.store = map[string]ExchangeDepositAddresses{ bitStamp: { btc: []deposit.Address{ { diff --git a/engine/engine_test.go b/engine/engine_test.go index cbb41cea..1e8861ae 100644 --- a/engine/engine_test.go +++ b/engine/engine_test.go @@ -336,8 +336,7 @@ func TestSettingsPrint(t *testing.T) { } var unsupportedDefaultConfigExchanges = []string{ - "poloniex", // poloniex has dropped support for the API GCT has implemented //TODO: drop this when supported - "coinbasepro", // deprecated API. TODO: Remove this when the Coinbase update is merged + "poloniex", // poloniex has dropped support for the API GCT has implemented //TODO: drop this when supported } func TestGetDefaultConfigurations(t *testing.T) { diff --git a/engine/exchange_manager.go b/engine/exchange_manager.go index df2ba7f8..b0c31600 100644 --- a/engine/exchange_manager.go +++ b/engine/exchange_manager.go @@ -7,6 +7,7 @@ import ( "sync" "time" + "github.com/thrasher-corp/gocryptotrader/common" exchange "github.com/thrasher-corp/gocryptotrader/exchanges" "github.com/thrasher-corp/gocryptotrader/log" ) @@ -17,7 +18,6 @@ var ( ErrExchangeNotFound = errors.New("exchange not found") ErrExchangeAlreadyLoaded = errors.New("exchange already loaded") ErrExchangeFailedToLoad = errors.New("exchange failed to load") - ErrExchangeNameIsEmpty = errors.New("exchange name is empty") errExchangeIsNil = errors.New("exchange is nil") ) @@ -81,7 +81,7 @@ func (m *ExchangeManager) RemoveExchange(exchangeName string) error { } if exchangeName == "" { - return fmt.Errorf("exchange manager: %w", ErrExchangeNameIsEmpty) + return fmt.Errorf("exchange manager: %w", common.ErrExchangeNameNotSet) } m.mtx.Lock() @@ -105,7 +105,7 @@ func (m *ExchangeManager) GetExchangeByName(exchangeName string) (exchange.IBotE return nil, fmt.Errorf("exchange manager: %w", ErrNilSubsystem) } if exchangeName == "" { - return nil, fmt.Errorf("exchange manager: %w", ErrExchangeNameIsEmpty) + return nil, fmt.Errorf("exchange manager: %w", common.ErrExchangeNameNotSet) } m.mtx.Lock() defer m.mtx.Unlock() diff --git a/engine/exchange_manager_test.go b/engine/exchange_manager_test.go index b594f909..3236e512 100644 --- a/engine/exchange_manager_test.go +++ b/engine/exchange_manager_test.go @@ -7,6 +7,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/thrasher-corp/gocryptotrader/common" exchange "github.com/thrasher-corp/gocryptotrader/exchanges" "github.com/thrasher-corp/gocryptotrader/exchanges/bitfinex" "github.com/thrasher-corp/gocryptotrader/exchanges/sharedtestvalues" @@ -93,7 +94,7 @@ func TestExchangeManagerRemoveExchange(t *testing.T) { m = NewExchangeManager() err = m.RemoveExchange("") - require.ErrorIs(t, err, ErrExchangeNameIsEmpty) + require.ErrorIs(t, err, common.ErrExchangeNameNotSet) err = m.RemoveExchange("Bitfinex") require.ErrorIs(t, err, ErrExchangeNotFound) @@ -130,7 +131,7 @@ func TestNewExchangeByName(t *testing.T) { m = NewExchangeManager() _, err = m.NewExchangeByName("") - require.ErrorIs(t, err, ErrExchangeNameIsEmpty) + require.ErrorIs(t, err, common.ErrExchangeNameNotSet) exchanges := exchange.Exchanges exchanges = append(exchanges, "fake") diff --git a/engine/helpers.go b/engine/helpers.go index 3bfde4f6..f076d09a 100644 --- a/engine/helpers.go +++ b/engine/helpers.go @@ -37,7 +37,7 @@ import ( "github.com/thrasher-corp/gocryptotrader/exchanges/btcmarkets" "github.com/thrasher-corp/gocryptotrader/exchanges/btse" "github.com/thrasher-corp/gocryptotrader/exchanges/bybit" - "github.com/thrasher-corp/gocryptotrader/exchanges/coinbasepro" + "github.com/thrasher-corp/gocryptotrader/exchanges/coinbase" "github.com/thrasher-corp/gocryptotrader/exchanges/coinut" "github.com/thrasher-corp/gocryptotrader/exchanges/deposit" "github.com/thrasher-corp/gocryptotrader/exchanges/deribit" @@ -694,8 +694,8 @@ func (bot *Engine) GetExchangeCryptocurrencyDepositAddress(ctx context.Context, } // GetAllExchangeCryptocurrencyDepositAddresses obtains an exchanges deposit cryptocurrency list -func (bot *Engine) GetAllExchangeCryptocurrencyDepositAddresses() map[string]map[string][]deposit.Address { - result := make(map[string]map[string][]deposit.Address) +func (bot *Engine) GetAllExchangeCryptocurrencyDepositAddresses() map[string]ExchangeDepositAddresses { + result := make(map[string]ExchangeDepositAddresses) exchanges := bot.GetExchanges() var depositSyncer sync.WaitGroup depositSyncer.Add(len(exchanges)) @@ -992,8 +992,8 @@ func NewSupportedExchangeByName(name string) (exchange.IBotExchange, error) { return new(deribit.Exchange), nil case "exmo": return new(exmo.Exchange), nil - case "coinbasepro": - return new(coinbasepro.Exchange), nil + case "coinbase": + return new(coinbase.Exchange), nil case "gateio": return new(gateio.Exchange), nil case "gemini": diff --git a/engine/order_manager.go b/engine/order_manager.go index d7673592..40f32d6d 100644 --- a/engine/order_manager.go +++ b/engine/order_manager.go @@ -349,7 +349,7 @@ func (m *OrderManager) validate(exch exchange.IBotExchange, newOrder *order.Subm } if newOrder.Exchange == "" { - return ErrExchangeNameIsEmpty + return common.ErrExchangeNameNotSet } if err := newOrder.Validate(exch.GetTradingRequirements()); err != nil { diff --git a/engine/order_manager_test.go b/engine/order_manager_test.go index f56d33e3..145f8479 100644 --- a/engine/order_manager_test.go +++ b/engine/order_manager_test.go @@ -1276,7 +1276,7 @@ func TestSubmitFakeOrder(t *testing.T) { ord := &order.Submit{} _, err = o.SubmitFakeOrder(ord, resp, false) - assert.ErrorIs(t, err, ErrExchangeNameIsEmpty) + assert.ErrorIs(t, err, common.ErrExchangeNameNotSet) ord.Exchange = testExchange ord.AssetType = asset.Spot diff --git a/engine/rpcserver.go b/engine/rpcserver.go index b0e9525f..cb9a479d 100644 --- a/engine/rpcserver.go +++ b/engine/rpcserver.go @@ -64,7 +64,6 @@ var ( errExchangeNotEnabled = errors.New("exchange is not enabled") errExchangeBaseNotFound = errors.New("cannot get exchange base") errInvalidArguments = errors.New("invalid arguments received") - errExchangeNameUnset = errors.New("exchange name unset") errCurrencyPairUnset = errors.New("currency pair unset") errInvalidTimes = errors.New("invalid start and end times") errAssetTypeUnset = errors.New("asset type unset") @@ -2102,7 +2101,7 @@ func (s *RPCServer) GetOrderbookStream(r *gctrpc.GetOrderbookStreamRequest, stre // GetExchangeOrderbookStream streams all orderbooks associated with an exchange func (s *RPCServer) GetExchangeOrderbookStream(r *gctrpc.GetExchangeOrderbookStreamRequest, stream gctrpc.GoCryptoTraderService_GetExchangeOrderbookStreamServer) error { if r.Exchange == "" { - return errExchangeNameUnset + return common.ErrExchangeNameNotSet } if _, err := s.GetExchangeByName(r.Exchange); err != nil { @@ -2172,7 +2171,7 @@ func (s *RPCServer) GetExchangeOrderbookStream(r *gctrpc.GetExchangeOrderbookStr // GetTickerStream streams the requested updated ticker func (s *RPCServer) GetTickerStream(r *gctrpc.GetTickerStreamRequest, stream gctrpc.GoCryptoTraderService_GetTickerStreamServer) error { if r.Exchange == "" { - return errExchangeNameUnset + return common.ErrExchangeNameNotSet } if _, err := s.GetExchangeByName(r.Exchange); err != nil { @@ -2244,7 +2243,7 @@ func (s *RPCServer) GetTickerStream(r *gctrpc.GetTickerStreamRequest, stream gct // GetExchangeTickerStream streams all tickers associated with an exchange func (s *RPCServer) GetExchangeTickerStream(r *gctrpc.GetExchangeTickerStreamRequest, stream gctrpc.GoCryptoTraderService_GetExchangeTickerStreamServer) error { if r.Exchange == "" { - return errExchangeNameUnset + return common.ErrExchangeNameNotSet } if _, err := s.GetExchangeByName(r.Exchange); err != nil { diff --git a/engine/rpcserver_test.go b/engine/rpcserver_test.go index 823bf9a8..22bce155 100644 --- a/engine/rpcserver_test.go +++ b/engine/rpcserver_test.go @@ -769,7 +769,7 @@ func TestGetHistoricCandles(t *testing.T) { End: defaultEnd.Format(common.SimpleTimeFormatWithTimezone), AssetType: asset.Spot.String(), }) - assert.ErrorIs(t, err, ErrExchangeNameIsEmpty) + assert.ErrorIs(t, err, common.ErrExchangeNameNotSet) _, err = s.GetHistoricCandles(t.Context(), &gctrpc.GetHistoricCandlesRequest{ Exchange: "bruh", @@ -1319,7 +1319,7 @@ func TestGetOrders(t *testing.T) { AssetType: asset.Spot.String(), Pair: p, }) - assert.ErrorIs(t, err, ErrExchangeNameIsEmpty) + assert.ErrorIs(t, err, common.ErrExchangeNameNotSet) _, err = s.GetOrders(t.Context(), &gctrpc.GetOrdersRequest{ Exchange: "bruh", @@ -1838,7 +1838,7 @@ func TestGetManagedOrders(t *testing.T) { AssetType: asset.Spot.String(), Pair: p, }) - assert.ErrorIs(t, err, ErrExchangeNameIsEmpty) + assert.ErrorIs(t, err, common.ErrExchangeNameNotSet) _, err = s.GetManagedOrders(t.Context(), &gctrpc.GetOrdersRequest{ Exchange: "bruh", @@ -2336,7 +2336,7 @@ func TestGetTechnicalAnalysis(t *testing.T) { } _, err = s.GetTechnicalAnalysis(t.Context(), &gctrpc.GetTechnicalAnalysisRequest{}) - require.ErrorIs(t, err, ErrExchangeNameIsEmpty) + require.ErrorIs(t, err, common.ErrExchangeNameNotSet) _, err = s.GetTechnicalAnalysis(t.Context(), &gctrpc.GetTechnicalAnalysisRequest{ Exchange: fakeExchangeName, @@ -2572,7 +2572,7 @@ func TestGetMarginRatesHistory(t *testing.T) { request := &gctrpc.GetMarginRatesHistoryRequest{} _, err = s.GetMarginRatesHistory(t.Context(), request) - assert.ErrorIs(t, err, ErrExchangeNameIsEmpty) + assert.ErrorIs(t, err, common.ErrExchangeNameNotSet) request.Exchange = fakeExchangeName _, err = s.GetMarginRatesHistory(t.Context(), request) @@ -2725,7 +2725,7 @@ func TestGetFundingRates(t *testing.T) { IncludePayments: false, } _, err = s.GetFundingRates(t.Context(), request) - assert.ErrorIs(t, err, ErrExchangeNameIsEmpty) + assert.ErrorIs(t, err, common.ErrExchangeNameNotSet) request.Exchange = exch.GetName() _, err = s.GetFundingRates(t.Context(), request) @@ -2818,7 +2818,7 @@ func TestGetLatestFundingRate(t *testing.T) { IncludePredicted: false, } _, err = s.GetLatestFundingRate(t.Context(), request) - assert.ErrorIs(t, err, ErrExchangeNameIsEmpty) + assert.ErrorIs(t, err, common.ErrExchangeNameNotSet) request.Exchange = exch.GetName() _, err = s.GetLatestFundingRate(t.Context(), request) @@ -2910,7 +2910,7 @@ func TestGetManagedPosition(t *testing.T) { Quote: "USD", } _, err = s.GetManagedPosition(t.Context(), request) - assert.ErrorIs(t, err, ErrExchangeNameIsEmpty) + assert.ErrorIs(t, err, common.ErrExchangeNameNotSet) request.Exchange = fakeExchangeName _, err = s.GetManagedPosition(t.Context(), request) @@ -3096,7 +3096,7 @@ func TestGetOrderbookMovement(t *testing.T) { req := &gctrpc.GetOrderbookMovementRequest{} _, err = s.GetOrderbookMovement(t.Context(), req) - require.ErrorIs(t, err, ErrExchangeNameIsEmpty) + require.ErrorIs(t, err, common.ErrExchangeNameNotSet) req.Exchange = "fake" _, err = s.GetOrderbookMovement(t.Context(), req) @@ -3189,7 +3189,7 @@ func TestGetOrderbookAmountByNominal(t *testing.T) { req := &gctrpc.GetOrderbookAmountByNominalRequest{} _, err = s.GetOrderbookAmountByNominal(t.Context(), req) - require.ErrorIs(t, err, ErrExchangeNameIsEmpty) + require.ErrorIs(t, err, common.ErrExchangeNameNotSet) req.Exchange = "fake" _, err = s.GetOrderbookAmountByNominal(t.Context(), req) @@ -3275,7 +3275,7 @@ func TestGetOrderbookAmountByImpact(t *testing.T) { req := &gctrpc.GetOrderbookAmountByImpactRequest{} _, err = s.GetOrderbookAmountByImpact(t.Context(), req) - require.ErrorIs(t, err, ErrExchangeNameIsEmpty) + require.ErrorIs(t, err, common.ErrExchangeNameNotSet) req.Exchange = "fake" _, err = s.GetOrderbookAmountByImpact(t.Context(), req) @@ -3611,7 +3611,7 @@ func TestSetCollateralMode(t *testing.T) { req := &gctrpc.SetCollateralModeRequest{} _, err = s.SetCollateralMode(t.Context(), req) - assert.ErrorIs(t, err, ErrExchangeNameIsEmpty) + assert.ErrorIs(t, err, common.ErrExchangeNameNotSet) req.Exchange = fakeExchangeName req.Asset = asset.USDTMarginedFutures.String() @@ -3648,7 +3648,7 @@ func TestGetCollateralMode(t *testing.T) { req := &gctrpc.GetCollateralModeRequest{} _, err = s.GetCollateralMode(t.Context(), req) - assert.ErrorIs(t, err, ErrExchangeNameIsEmpty) + assert.ErrorIs(t, err, common.ErrExchangeNameNotSet) req.Exchange = fakeExchangeName req.Asset = asset.USDTMarginedFutures.String() @@ -3683,7 +3683,7 @@ func TestGetOpenInterest(t *testing.T) { req := &gctrpc.GetOpenInterestRequest{} _, err = s.GetOpenInterest(t.Context(), req) - assert.ErrorIs(t, err, ErrExchangeNameIsEmpty) + assert.ErrorIs(t, err, common.ErrExchangeNameNotSet) req.Exchange = fakeExchangeName _, err = s.GetOpenInterest(t.Context(), req) @@ -3869,7 +3869,7 @@ func TestGetCurrencyTradeURL(t *testing.T) { req := &gctrpc.GetCurrencyTradeURLRequest{} _, err = s.GetCurrencyTradeURL(t.Context(), req) - assert.ErrorIs(t, err, ErrExchangeNameIsEmpty) + assert.ErrorIs(t, err, common.ErrExchangeNameNotSet) req.Exchange = fakeExchangeName _, err = s.GetCurrencyTradeURL(t.Context(), req) diff --git a/exchange/websocket/buffer/buffer_test.go b/exchange/websocket/buffer/buffer_test.go index d6a0390c..dda797d0 100644 --- a/exchange/websocket/buffer/buffer_test.go +++ b/exchange/websocket/buffer/buffer_test.go @@ -456,7 +456,7 @@ func TestLoadSnapshot(t *testing.T) { require.ErrorIs(t, err, orderbook.ErrPriceZero) err = obl.LoadSnapshot(&orderbook.Book{Asks: orderbook.Levels{{Amount: 1}}}) - require.ErrorIs(t, err, orderbook.ErrExchangeNameEmpty) + require.ErrorIs(t, err, common.ErrExchangeNameNotSet) err = obl.LoadSnapshot(&orderbook.Book{Asks: orderbook.Levels{{Amount: 1}}, Exchange: "test", Pair: cp, Asset: asset.Spot}) require.ErrorIs(t, err, orderbook.ErrLastUpdatedNotSet) diff --git a/exchange/websocket/manager.go b/exchange/websocket/manager.go index 69bc6a7f..7d6c67d5 100644 --- a/exchange/websocket/manager.go +++ b/exchange/websocket/manager.go @@ -23,18 +23,18 @@ import ( // Public websocket errors var ( - ErrWebsocketNotEnabled = errors.New("websocket not enabled") - ErrAlreadyDisabled = errors.New("websocket already disabled") - ErrNotConnected = errors.New("websocket is not connected") - ErrSignatureTimeout = errors.New("websocket timeout waiting for response with signature") - ErrRequestRouteNotFound = errors.New("request route not found") - ErrSignatureNotSet = errors.New("signature not set") + ErrWebsocketNotEnabled = errors.New("websocket not enabled") + ErrAlreadyDisabled = errors.New("websocket already disabled") + ErrWebsocketAlreadyEnabled = errors.New("websocket already enabled") + ErrNotConnected = errors.New("websocket is not connected") + ErrSignatureTimeout = errors.New("websocket timeout waiting for response with signature") + ErrRequestRouteNotFound = errors.New("request route not found") + ErrSignatureNotSet = errors.New("signature not set") ) // Private websocket errors var ( errWebsocketAlreadyInitialised = errors.New("websocket already initialised") - errWebsocketAlreadyEnabled = errors.New("websocket already enabled") errDefaultURLIsEmpty = errors.New("default url is empty") errRunningURLIsEmpty = errors.New("running url cannot be empty") errInvalidWebsocketURL = errors.New("invalid websocket url") @@ -612,7 +612,7 @@ func (m *Manager) Disable() error { // Enable enables the exchange websocket protocol func (m *Manager) Enable() error { if m.IsConnected() || m.IsEnabled() { - return fmt.Errorf("%s %w", m.exchangeName, errWebsocketAlreadyEnabled) + return fmt.Errorf("%s %w", m.exchangeName, ErrWebsocketAlreadyEnabled) } m.setEnabled(true) diff --git a/exchange/websocket/manager_test.go b/exchange/websocket/manager_test.go index c70ff718..557a001a 100644 --- a/exchange/websocket/manager_test.go +++ b/exchange/websocket/manager_test.go @@ -1001,7 +1001,7 @@ func TestEnable(t *testing.T) { w.Unsubscriber = func(subscription.List) error { return nil } w.GenerateSubs = func() (subscription.List, error) { return nil, nil } require.NoError(t, w.Enable(), "Enable must not error") - assert.ErrorIs(t, w.Enable(), errWebsocketAlreadyEnabled, "Enable should error correctly") + assert.ErrorIs(t, w.Enable(), ErrWebsocketAlreadyEnabled, "Enable should error correctly") } func TestSetupNewConnection(t *testing.T) { diff --git a/exchanges/account/account.go b/exchanges/account/account.go index 29cd890c..203f57b9 100644 --- a/exchanges/account/account.go +++ b/exchanges/account/account.go @@ -25,7 +25,6 @@ var ( var ( errHoldingsIsNil = errors.New("holdings cannot be nil") - errExchangeNameUnset = errors.New("exchange name unset") errNoExchangeSubAccountBalances = errors.New("no exchange sub account balances") errBalanceIsNil = errors.New("balance is nil") errNoCredentialBalances = errors.New("no balances associated with credentials") @@ -107,7 +106,7 @@ func ProcessChange(exch string, changes []Change, c *Credentials) error { // TODO: Add jurisdiction and differentiation between APIKEY holdings. func GetHoldings(exch string, creds *Credentials, assetType asset.Item) (Holdings, error) { if exch == "" { - return Holdings{}, errExchangeNameUnset + return Holdings{}, common.ErrExchangeNameNotSet } if creds.IsEmpty() { @@ -171,7 +170,7 @@ func GetHoldings(exch string, creds *Credentials, assetType asset.Item) (Holding // GetBalance returns the internal balance for that asset item. func GetBalance(exch, subAccount string, creds *Credentials, a asset.Item, c currency.Code) (*ProtectedBalance, error) { if exch == "" { - return nil, fmt.Errorf("cannot get balance: %w", errExchangeNameUnset) + return nil, fmt.Errorf("cannot get balance: %w", common.ErrExchangeNameNotSet) } if !a.IsValid() { @@ -222,7 +221,7 @@ func (s *Service) Save(incoming *Holdings, creds *Credentials) error { } if incoming.Exchange == "" { - return fmt.Errorf("cannot save holdings: %w", errExchangeNameUnset) + return fmt.Errorf("cannot save holdings: %w", common.ErrExchangeNameNotSet) } if creds.IsEmpty() { @@ -316,7 +315,7 @@ func (s *Service) Save(incoming *Holdings, creds *Credentials) error { // Update updates the balance for a specific exchange and credentials func (s *Service) Update(exch string, changes []Change, creds *Credentials) error { if exch == "" { - return fmt.Errorf("%w: %w", errCannotUpdateBalance, errExchangeNameUnset) + return fmt.Errorf("%w: %w", errCannotUpdateBalance, common.ErrExchangeNameNotSet) } if creds.IsEmpty() { diff --git a/exchanges/account/account_test.go b/exchanges/account/account_test.go index c42d3fd0..9e2abe16 100644 --- a/exchanges/account/account_test.go +++ b/exchanges/account/account_test.go @@ -68,7 +68,7 @@ func TestGetHoldings(t *testing.T) { assert.ErrorIs(t, err, errHoldingsIsNil) err = Process(&Holdings{}, nil) - assert.ErrorIs(t, err, errExchangeNameUnset) + assert.ErrorIs(t, err, common.ErrExchangeNameNotSet) holdings := Holdings{Exchange: "Test"} @@ -111,7 +111,7 @@ func TestGetHoldings(t *testing.T) { assert.NoError(t, err) _, err = GetHoldings("", nil, asset.Spot) - assert.ErrorIs(t, err, errExchangeNameUnset) + assert.ErrorIs(t, err, common.ErrExchangeNameNotSet) _, err = GetHoldings("bla", nil, asset.Spot) assert.ErrorIs(t, err, errCredentialsAreNil) @@ -182,7 +182,7 @@ func TestGetBalance(t *testing.T) { t.Parallel() _, err := GetBalance("", "", nil, asset.Empty, currency.Code{}) - assert.ErrorIs(t, err, errExchangeNameUnset) + assert.ErrorIs(t, err, common.ErrExchangeNameNotSet) _, err = GetBalance("bruh", "", nil, asset.Empty, currency.Code{}) assert.ErrorIs(t, err, asset.ErrNotSupported) @@ -320,7 +320,7 @@ func TestSave(t *testing.T) { assert.ErrorIs(t, err, errHoldingsIsNil) err = s.Save(&Holdings{}, nil) - assert.ErrorIs(t, err, errExchangeNameUnset) + assert.ErrorIs(t, err, common.ErrExchangeNameNotSet) err = s.Save(&Holdings{ Exchange: "TeSt", @@ -410,7 +410,7 @@ func TestUpdate(t *testing.T) { t.Parallel() s := &Service{exchangeAccounts: make(map[string]*Accounts), mux: dispatch.GetNewMux(nil)} err := s.Update("", nil, nil) - assert.ErrorIs(t, err, errExchangeNameUnset) + assert.ErrorIs(t, err, common.ErrExchangeNameNotSet) err = s.Update("test", nil, nil) assert.ErrorIs(t, err, errCredentialsAreNil) diff --git a/exchanges/asset/asset.go b/exchanges/asset/asset.go index 7aa6a891..7e35dc79 100644 --- a/exchanges/asset/asset.go +++ b/exchanges/asset/asset.go @@ -133,6 +133,11 @@ func (a Item) String() string { } } +// Upper returns the item's upper case string +func (a Item) Upper() string { + return strings.ToUpper(a.String()) +} + // Strings converts an asset type array to a string array func (a Items) Strings() []string { assets := make([]string, len(a)) diff --git a/exchanges/asset/asset_test.go b/exchanges/asset/asset_test.go index e98edf70..8627fc46 100644 --- a/exchanges/asset/asset_test.go +++ b/exchanges/asset/asset_test.go @@ -20,6 +20,14 @@ func TestString(t *testing.T) { } } +func TestUpper(t *testing.T) { + t.Parallel() + a := Spot + require.Equal(t, "SPOT", a.Upper()) + a = 0 + require.Empty(t, a.Upper()) +} + func TestStrings(t *testing.T) { t.Parallel() assert.ElementsMatch(t, Items{Spot, Futures}.Strings(), []string{"spot", "futures"}) diff --git a/exchanges/bitmex/bitmex_test.go b/exchanges/bitmex/bitmex_test.go index 3317ad93..74059a89 100644 --- a/exchanges/bitmex/bitmex_test.go +++ b/exchanges/bitmex/bitmex_test.go @@ -934,7 +934,7 @@ func TestWsTrades(t *testing.T) { require.NoError(t, e.wsHandleData(msg), "Must not error handling a standard stream of trades") msg = []byte(`{"table":"trade","action":"insert","data":[{"timestamp":"2020-02-17T01:35:36.442Z","symbol":".BGCT","size":14,"price":258.2,"side":"sell"}]}`) - require.ErrorIs(t, e.wsHandleData(msg), exchange.ErrSymbolCannotBeMatched, "Must error correctly with an unknown symbol") + require.ErrorIs(t, e.wsHandleData(msg), exchange.ErrSymbolNotMatched, "Must error correctly with an unknown symbol") msg = []byte(`{"table":"trade","action":"insert","data":[{"timestamp":"2020-02-17T01:35:36.442Z","symbol":".BGCT","size":0,"price":258.2,"side":"sell"}]}`) require.NoError(t, e.wsHandleData(msg), "Must not error that symbol is unknown when index trade is ignored due to zero size") diff --git a/exchanges/coinbasepro/README.md b/exchanges/coinbase/README.md similarity index 94% rename from exchanges/coinbasepro/README.md rename to exchanges/coinbase/README.md index d810c868..839a87b7 100644 --- a/exchanges/coinbasepro/README.md +++ b/exchanges/coinbase/README.md @@ -1,16 +1,16 @@ -# GoCryptoTrader package Coinbasepro +# GoCryptoTrader package Coinbase [![Build Status](https://github.com/thrasher-corp/gocryptotrader/actions/workflows/tests.yml/badge.svg?branch=master)](https://github.com/thrasher-corp/gocryptotrader/actions/workflows/tests.yml) [![Software License](https://img.shields.io/badge/License-MIT-orange.svg?style=flat-square)](https://github.com/thrasher-corp/gocryptotrader/blob/master/LICENSE) -[![GoDoc](https://godoc.org/github.com/thrasher-corp/gocryptotrader?status.svg)](https://godoc.org/github.com/thrasher-corp/gocryptotrader/exchanges/coinbasepro) +[![GoDoc](https://godoc.org/github.com/thrasher-corp/gocryptotrader?status.svg)](https://godoc.org/github.com/thrasher-corp/gocryptotrader/exchanges/Coinbase) [![Coverage Status](https://codecov.io/gh/thrasher-corp/gocryptotrader/graph/badge.svg?token=41784B23TS)](https://codecov.io/gh/thrasher-corp/gocryptotrader) [![Go Report Card](https://goreportcard.com/badge/github.com/thrasher-corp/gocryptotrader)](https://goreportcard.com/report/github.com/thrasher-corp/gocryptotrader) -This coinbasepro package is part of the GoCryptoTrader codebase. +This coinbase package is part of the GoCryptoTrader codebase. ## This is still in active development @@ -18,7 +18,7 @@ You can track ideas, planned features and what's in progress on our [GoCryptoTra Join our slack to discuss all things related to GoCryptoTrader! [GoCryptoTrader Slack](https://join.slack.com/t/gocryptotrader/shared_invite/zt-38z8abs3l-gH8AAOk8XND6DP5NfCiG_g) -## CoinbasePro Exchange +## Coinbase Exchange ### Current Features @@ -49,7 +49,7 @@ main.go var c exchange.IBotExchange for i := range bot.Exchanges { - if bot.Exchanges[i].GetName() == "CoinbasePro" { + if bot.Exchanges[i].GetName() == "Coinbase" { c = bot.Exchanges[i] } } diff --git a/exchanges/coinbase/coinbase.go b/exchanges/coinbase/coinbase.go new file mode 100644 index 00000000..cb9f90f7 --- /dev/null +++ b/exchanges/coinbase/coinbase.go @@ -0,0 +1,1735 @@ +package coinbase + +import ( + "bytes" + "context" + "crypto/ecdsa" + "crypto/rand" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/pem" + "errors" + "fmt" + "math/big" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "github.com/gofrs/uuid" + "github.com/thrasher-corp/gocryptotrader/common" + "github.com/thrasher-corp/gocryptotrader/common/key" + "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/kline" + "github.com/thrasher-corp/gocryptotrader/exchanges/order" + "github.com/thrasher-corp/gocryptotrader/exchanges/request" + "github.com/thrasher-corp/gocryptotrader/types" +) + +const ( + apiURL = "https://api.coinbase.com" + v1APIURL = "https://api.exchange.coinbase.com/" + sandboxAPIURL = "https://api-sandbox.coinbase.com" + tradeBaseURL = "https://www.coinbase.com/advanced-trade/spot/" + v3Path = "/api/v3/brokerage/" + accountsPath = "accounts" + convertPath = "convert" + tradePath = "trade" + quotePath = "quote" + keyPermissionsPath = "key_permissions" + transactionSummaryPath = "transaction_summary" + futuresPath = "cfm" // Coinbase Financial Markets is the legal name for the Coinbase futures company + sweepsPath = "sweeps" + intradayPath = "intraday" + currentMarginWindowPath = "current_margin_window" + balanceSummaryPath = "balance_summary" + positionsPath = "positions" + marginSettingPath = "margin_setting" + schedulePath = "schedule" + ordersPath = "orders" + batchCancelpath = "batch_cancel" + closePositionPath = "close_position" + editPath = "edit" + editPreviewPath = "edit_preview" + historicalPath = "historical" + fillsPath = "fills" + batchPath = "batch" + bestBidAskPath = "best_bid_ask" + productBookPath = "product_book" + productsPath = "products" + candlesPath = "candles" + tickerPath = "ticker" + previewPath = "preview" + portfoliosPath = "portfolios" + moveFundsPath = "move_funds" + intxPath = "intx" + balancesPath = "balances" + multiAssetCollateralPath = "multi_asset_collateral" + allocatePath = "allocate" + portfolioPath = "portfolio" + paymentMethodsPath = "payment_methods" + v2Path = "/v2/" + userPath = "user" + addressesPath = "addresses" + transactionsPath = "transactions" + depositsPath = "deposits" + commitPath = "commit" + withdrawalsPath = "withdrawals" + currenciesPath = "currencies" + cryptoPath = "crypto" + exchangeRatesPath = "exchange-rates" + pricesPath = "prices" + timePath = "time" + volumeSummaryPath = "volume-summary" + bookPath = "book" + statsPath = "stats" + tradesPath = "trades" + wrappedAssetsPath = "wrapped-assets" + conversionRatePath = "conversion-rate" + marketPath = "market" + + startDateString = "start_date" + endDateString = "end_date" + + defaultOrderFillCount = 3000 // Largest number of fills the exchange will let one retrieve in a request, found through experimentation + defaultOrderCount = 2147483647 // int32 limit, largest number of orders the exchange will let one retrieve in a request, found through experimentation +) + +// Constants defining whether a transfer is a deposit or withdrawal, used to simplify interactions with a few endpoints +const ( + FiatDeposit FiatTransferType = false + FiatWithdrawal FiatTransferType = true +) + +// While the exchange's fee pages say the worst taker/maker fees are lower than the ones listed here, the data returned by the GetTransactionsSummary endpoint are consistent with these worst case scenarios. The best case scenarios are untested, and assumed to be in line with the fee pages +const ( + WorstCaseTakerFee = 0.012 + WorstCaseMakerFee = 0.006 + BestCaseTakerFee = 0.0005 + BestCaseMakerFee = 0 + StablePairMakerFee = 0 + WorstCaseStablePairTakerFee = 0.000045 +) + +var ( + errAccountIDEmpty = errors.New("account id cannot be empty") + errProductIDEmpty = errors.New("product id cannot be empty") + errCancelLimitExceeded = errors.New("100 order cancel limit exceeded") + errSizeAndPriceZero = errors.New("size and price cannot both be 0") + errCurrWalletConflict = errors.New("exactly one of walletID and currency must be specified") + errWalletIDEmpty = errors.New("wallet id cannot be empty") + errAddressIDEmpty = errors.New("address id cannot be empty") + errTransactionTypeEmpty = errors.New("transaction type cannot be empty") + errToEmpty = errors.New("to cannot be empty") + errTransactionIDEmpty = errors.New("transaction id cannot be empty") + errPaymentMethodEmpty = errors.New("payment method cannot be empty") + errDepositIDEmpty = errors.New("deposit id cannot be empty") + errInvalidPriceType = errors.New("price type must be spot, buy, or sell") + errInvalidOrderType = errors.New("order type must be market, limit, or stop") + errEndTimeInPast = errors.New("end time cannot be in the past") + errNoMatchingWallets = errors.New("no matching wallets returned") + errOrderModFailNoRet = errors.New("order modification failed but no error returned") + errNameEmpty = errors.New("name cannot be empty") + errPortfolioIDEmpty = errors.New("portfolio id cannot be empty") + errFeeTypeNotSupported = errors.New("fee type not supported") + errDecodingPrivateKey = errors.New("error decoding private key") + errNoWalletForCurrency = errors.New("no wallet found for currency, address creation impossible") + errChannelNameUnknown = errors.New("unknown channel name") + errNoWalletsReturned = errors.New("no wallets returned") + errPayMethodNotFound = errors.New("payment method not found") + errUnknownL2DataType = errors.New("unknown l2update data type") + errOrderFailedToCancel = errors.New("failed to cancel order") + errWrappedAssetEmpty = errors.New("wrapped asset cannot be empty") + errUnrecognisedStrategyType = errors.New("unrecognised strategy type") + errEndpointPathInvalid = errors.New("endpoint path invalid, should start with https://") + errPairsDisabledOrErrored = errors.New("pairs are either disabled or errored") + errDateLabelEmpty = errors.New("date label cannot be empty") + errMarginProfileTypeEmpty = errors.New("margin profile type cannot be empty") + errSettingEmpty = errors.New("setting cannot be empty") + errUnknownTransferType = errors.New("unknown transfer type") + errOutOfSequence = errors.New("out of order sequence number") + + closedStatuses = []string{"FILLED", "CANCELLED", "EXPIRED", "FAILED"} + openStatus = []string{"OPEN"} + + allowedGranularities = map[kline.Interval]string{ + kline.OneMin: "ONE_MINUTE", + kline.FiveMin: "FIVE_MINUTE", + kline.FifteenMin: "FIFTEEN_MINUTE", + kline.ThirtyMin: "THIRTY_MINUTE", + kline.OneHour: "ONE_HOUR", + kline.TwoHour: "TWO_HOUR", + kline.SixHour: "SIX_HOUR", + kline.OneDay: "ONE_DAY", + } +) + +// GetAccountByID returns information for a single account +func (e *Exchange) GetAccountByID(ctx context.Context, accountID string) (*Account, error) { + if accountID == "" { + return nil, errAccountIDEmpty + } + path := v3Path + accountsPath + "/" + accountID + resp := struct { + Account Account `json:"account"` + }{} + return &resp.Account, e.SendAuthenticatedHTTPRequest(ctx, exchange.RestSpot, http.MethodGet, path, nil, nil, true, &resp) +} + +// ListAccounts returns information on all trading accounts associated with the API key +func (e *Exchange) ListAccounts(ctx context.Context, limit uint8, cursor int64) (*AllAccountsResponse, error) { + vals := url.Values{} + if limit != 0 { + vals.Set("limit", strconv.FormatUint(uint64(limit), 10)) + } + if cursor != 0 { + vals.Set("cursor", strconv.FormatInt(cursor, 10)) + } + var resp *AllAccountsResponse + return resp, e.SendAuthenticatedHTTPRequest(ctx, exchange.RestSpot, http.MethodGet, v3Path+accountsPath, vals, nil, true, &resp) +} + +// CommitConvertTrade commits a conversion between two currencies, using the trade_id returned from CreateConvertQuote +func (e *Exchange) CommitConvertTrade(ctx context.Context, tradeID, from, to string) (*ConvertResponse, error) { + if tradeID == "" { + return nil, errTransactionIDEmpty + } + if from == "" || to == "" { + return nil, errAccountIDEmpty + } + path := v3Path + convertPath + "/" + tradePath + "/" + tradeID + var resp ConvertWrapper + return &resp.Trade, e.SendAuthenticatedHTTPRequest(ctx, exchange.RestSpot, http.MethodPost, path, nil, convertTradeReqBase{FromAccount: from, ToAccount: to}, true, &resp) +} + +// CreateConvertQuote creates a quote for a conversion between two currencies. The trade_id returned can be used to commit the trade, but that must be done within 10 minutes of the quote's creation +func (e *Exchange) CreateConvertQuote(ctx context.Context, from, to, userIncentiveID, codeVal string, amount float64) (*ConvertResponse, error) { + if from == "" || to == "" { + return nil, errAccountIDEmpty + } + if amount <= 0 { + return nil, order.ErrAmountIsInvalid + } + path := v3Path + convertPath + "/" + quotePath + req := convertQuoteReqBase{ + FromAccount: from, + ToAccount: to, + Amount: amount, + Metadata: tradeIncentiveMetadata{ + UserIncentiveID: userIncentiveID, + CodeVal: codeVal, + }, + } + var resp ConvertWrapper + return &resp.Trade, e.SendAuthenticatedHTTPRequest(ctx, exchange.RestSpot, http.MethodPost, path, nil, req, true, &resp) +} + +// GetConvertTradeByID returns information on a conversion between two currencies +func (e *Exchange) GetConvertTradeByID(ctx context.Context, tradeID, from, to string) (*ConvertResponse, error) { + if tradeID == "" { + return nil, errTransactionIDEmpty + } + if from == "" || to == "" { + return nil, errAccountIDEmpty + } + path := v3Path + convertPath + "/" + tradePath + "/" + tradeID + var resp ConvertWrapper + return &resp.Trade, e.SendAuthenticatedHTTPRequest(ctx, exchange.RestSpot, http.MethodGet, path, nil, convertTradeReqBase{FromAccount: from, ToAccount: to}, true, &resp) +} + +// GetPermissions returns the permissions associated with the API key +func (e *Exchange) GetPermissions(ctx context.Context) (*PermissionsResponse, error) { + var resp PermissionsResponse + return &resp, e.SendAuthenticatedHTTPRequest(ctx, exchange.RestSpot, http.MethodGet, v3Path+keyPermissionsPath, nil, nil, true, &resp) +} + +// GetTransactionSummary returns a summary of transactions with fee tiers, total volume, and fees +func (e *Exchange) GetTransactionSummary(ctx context.Context, startDate, endDate time.Time, productVenue, productType, contractExpiryType string) (*TransactionSummary, error) { + vals, err := urlValsFromDateRange(startDate, endDate, startDateString, endDateString) + if err != nil { + return nil, err + } + if contractExpiryType != "" { + vals.Set("contract_expiry_type", contractExpiryType) + } + if productType != "" { + vals.Set("product_type", productType) + } + if productVenue != "" { + vals.Set("product_venue", productVenue) + } + var resp *TransactionSummary + return resp, e.SendAuthenticatedHTTPRequest(ctx, exchange.RestSpot, http.MethodGet, v3Path+transactionSummaryPath, vals, nil, true, &resp) +} + +// CancelPendingFuturesSweep cancels a pending sweep request +func (e *Exchange) CancelPendingFuturesSweep(ctx context.Context) (bool, error) { + path := v3Path + futuresPath + "/" + sweepsPath + var resp SuccessResp + return resp.Success, e.SendAuthenticatedHTTPRequest(ctx, exchange.RestSpot, http.MethodDelete, path, nil, nil, true, &resp) +} + +// GetCurrentMarginWindow returns the futures current margin window +func (e *Exchange) GetCurrentMarginWindow(ctx context.Context, marginProfileType string) (*CurrentMarginWindow, error) { + if marginProfileType == "" { + return nil, errMarginProfileTypeEmpty + } + vals := url.Values{} + if marginProfileType != "" { + vals.Set("margin_profile_type", marginProfileType) + } + path := v3Path + futuresPath + "/" + intradayPath + "/" + currentMarginWindowPath + var resp *CurrentMarginWindow + return resp, e.SendAuthenticatedHTTPRequest(ctx, exchange.RestSpot, http.MethodGet, path, vals, nil, true, &resp) +} + +// GetFuturesBalanceSummary returns information on balances related to Coinbase Financial Markets futures trading +func (e *Exchange) GetFuturesBalanceSummary(ctx context.Context) (*FuturesBalanceSummary, error) { + resp := struct { + BalanceSummary FuturesBalanceSummary `json:"balance_summary"` + }{} + path := v3Path + futuresPath + "/" + balanceSummaryPath + return &resp.BalanceSummary, e.SendAuthenticatedHTTPRequest(ctx, exchange.RestSpot, http.MethodGet, path, nil, nil, true, &resp) +} + +// GetFuturesPositionByID returns information on an open futures position +func (e *Exchange) GetFuturesPositionByID(ctx context.Context, productID currency.Pair) (*FuturesPosition, error) { + if productID.IsEmpty() { + return nil, errProductIDEmpty + } + path := v3Path + futuresPath + "/" + positionsPath + "/" + productID.String() + resp := struct { + Position FuturesPosition `json:"position"` + }{} + return &resp.Position, e.SendAuthenticatedHTTPRequest(ctx, exchange.RestSpot, http.MethodGet, path, nil, nil, true, &resp) +} + +// GetIntradayMarginSetting returns the futures intraday margin setting +func (e *Exchange) GetIntradayMarginSetting(ctx context.Context) (string, error) { + resp := struct { + Setting string `json:"setting"` + }{} + path := v3Path + futuresPath + "/" + intradayPath + "/" + marginSettingPath + return resp.Setting, e.SendAuthenticatedHTTPRequest(ctx, exchange.RestSpot, http.MethodGet, path, nil, nil, true, &resp) +} + +// ListFuturesPositions returns a list of all open futures positions +func (e *Exchange) ListFuturesPositions(ctx context.Context) ([]FuturesPosition, error) { + resp := struct { + Positions []FuturesPosition `json:"positions"` + }{} + path := v3Path + futuresPath + "/" + positionsPath + return resp.Positions, e.SendAuthenticatedHTTPRequest(ctx, exchange.RestSpot, http.MethodGet, path, nil, nil, true, &resp) +} + +// ListFuturesSweeps returns information on pending and/or processing requests to sweep funds +func (e *Exchange) ListFuturesSweeps(ctx context.Context) ([]SweepData, error) { + resp := struct { + Sweeps []SweepData `json:"sweeps"` + }{} + path := v3Path + futuresPath + "/" + sweepsPath + return resp.Sweeps, e.SendAuthenticatedHTTPRequest(ctx, exchange.RestSpot, http.MethodGet, path, nil, nil, true, &resp) +} + +// ScheduleFuturesSweep schedules a sweep of funds from a CFTC-regulated futures account to a Coinbase USD Spot wallet. Request submitted before 5 pm ET are processed the following business day, requests submitted after are processed in 2 business days. Only one sweep request can be pending at a time. Funds transferred depend on the excess available in the futures account. An amount of 0 will sweep all available excess funds +func (e *Exchange) ScheduleFuturesSweep(ctx context.Context, amount float64) (bool, error) { + path := v3Path + futuresPath + "/" + sweepsPath + "/" + schedulePath + var resp SuccessResp + return resp.Success, e.SendAuthenticatedHTTPRequest(ctx, exchange.RestSpot, http.MethodPost, path, nil, futuresSweepReqBase{USDAmount: amount}, true, &resp) +} + +// SetIntradayMarginSetting sets the futures intraday margin setting +func (e *Exchange) SetIntradayMarginSetting(ctx context.Context, setting string) error { + if setting == "" { + return errSettingEmpty + } + path := v3Path + futuresPath + "/" + intradayPath + "/" + marginSettingPath + return e.SendAuthenticatedHTTPRequest(ctx, exchange.RestSpot, http.MethodPost, path, nil, marginSettingReqBase{Setting: setting}, true, nil) +} + +// CancelOrders cancels orders by orderID. Can only cancel 100 orders per request +func (e *Exchange) CancelOrders(ctx context.Context, orderIDs []string) ([]OrderCancelDetail, error) { + if len(orderIDs) == 0 { + return nil, order.ErrOrderIDNotSet + } + if len(orderIDs) > 100 { + return nil, errCancelLimitExceeded + } + path := v3Path + ordersPath + "/" + batchCancelpath + resp := struct { + Results []OrderCancelDetail `json:"results"` + }{} + return resp.Results, e.SendAuthenticatedHTTPRequest(ctx, exchange.RestSpot, http.MethodPost, path, nil, cancelOrdersReqBase{OrderIDs: orderIDs}, true, &resp) +} + +// ClosePosition closes a position by client order ID, product ID, and size +func (e *Exchange) ClosePosition(ctx context.Context, clientOrderID string, productID currency.Pair, size float64) (*SuccessFailureConfig, error) { + if clientOrderID == "" { + return nil, order.ErrClientOrderIDMustBeSet + } + if productID.IsEmpty() { + return nil, errProductIDEmpty + } + if size <= 0 { + return nil, order.ErrAmountIsInvalid + } + path := v3Path + ordersPath + "/" + closePositionPath + req := closePositionReqBase{ + ClientOrderID: clientOrderID, + ProductID: productID, + Size: size, + } + var resp *SuccessFailureConfig + return resp, e.SendAuthenticatedHTTPRequest(ctx, exchange.RestSpot, http.MethodPost, path, nil, req, true, &resp) +} + +// PlaceOrder places either a limit, market, or stop order +func (e *Exchange) PlaceOrder(ctx context.Context, ord *PlaceOrderInfo) (*SuccessFailureConfig, error) { + if ord.ClientOID == "" { + return nil, order.ErrClientOrderIDMustBeSet + } + if ord.ProductID == "" { + return nil, errProductIDEmpty + } + if ord.BaseAmount <= 0 { + return nil, order.ErrAmountIsInvalid + } + orderConfig, err := createOrderConfig(&ord.OrderInfo) + if err != nil { + return nil, err + } + req := placeOrderReqbase{ + ClientOID: ord.ClientOID, + ProductID: ord.ProductID, + Side: ord.Side, + OrderConfiguration: &orderConfig, + RetailPortfolioID: ord.RetailPortfolioID, + PreviewID: ord.PreviewID, + AttachedOrderConfiguration: &ord.AttachedOrderConfiguration, + MarginType: FormatMarginType(ord.MarginType), + } + if ord.Leverage != 1 { + req.Leverage = ord.Leverage + } + var resp *SuccessFailureConfig + return resp, e.SendAuthenticatedHTTPRequest(ctx, exchange.RestSpot, http.MethodPost, v3Path+ordersPath, nil, req, true, &resp) +} + +// EditOrder edits an order to a new size or price. Only limit orders with a good-till-cancelled time in force can be edited +func (e *Exchange) EditOrder(ctx context.Context, orderID string, size, price float64) (bool, error) { + if orderID == "" { + return false, order.ErrOrderIDNotSet + } + if size <= 0 && price <= 0 { + return false, errSizeAndPriceZero + } + path := v3Path + ordersPath + "/" + editPath + req := editOrderReqBase{ + OrderID: orderID, + Size: size, + Price: price, + } + var resp SuccessResp + return resp.Success, e.SendAuthenticatedHTTPRequest(ctx, exchange.RestSpot, http.MethodPost, path, nil, req, true, &resp) +} + +// EditOrderPreview simulates an edit order request, to preview the result. Only limit orders with a good-till-cancelled time in force can be edited. +func (e *Exchange) EditOrderPreview(ctx context.Context, orderID string, size, price float64) (*EditOrderPreviewResp, error) { + if orderID == "" { + return nil, order.ErrOrderIDNotSet + } + if size <= 0 && price <= 0 { + return nil, errSizeAndPriceZero + } + path := v3Path + ordersPath + "/" + editPreviewPath + req := editOrderReqBase{ + OrderID: orderID, + Size: size, + Price: price, + } + var resp *EditOrderPreviewResp + return resp, e.SendAuthenticatedHTTPRequest(ctx, exchange.RestSpot, http.MethodPost, path, nil, req, true, &resp) +} + +// GetOrderByID returns a single order by order id. +func (e *Exchange) GetOrderByID(ctx context.Context, orderID, clientOID string, userNativeCurrency currency.Code) (*GetOrderResponse, error) { + if orderID == "" { + return nil, order.ErrOrderIDNotSet + } + vals := url.Values{} + if clientOID != "" { + vals.Set("client_order_id", clientOID) + } + if !userNativeCurrency.IsEmpty() { + vals.Set("user_native_currency", userNativeCurrency.String()) + } + path := v3Path + ordersPath + "/" + historicalPath + "/" + orderID + resp := struct { + Order GetOrderResponse `json:"order"` + }{} + return &resp.Order, e.SendAuthenticatedHTTPRequest(ctx, exchange.RestSpot, http.MethodGet, path, vals, nil, true, &resp) +} + +// ListFills returns information on recent order fills +func (e *Exchange) ListFills(ctx context.Context, orderIDs, tradeIDs []string, productIDs currency.Pairs, cursor int64, sortBy string, startDate, endDate time.Time, limit uint16) (*FillResponse, error) { + vals, err := urlValsFromDateRange(startDate, endDate, "start_sequence_timestamp", "end_sequence_timestamp") + if err != nil { + return nil, err + } + for i := range orderIDs { + vals.Add("order_ids", orderIDs[i]) + } + for i := range tradeIDs { + vals.Add("trade_ids", tradeIDs[i]) + } + for i := range productIDs { + vals.Add("product_ids", productIDs[i].String()) + } + vals.Set("limit", strconv.FormatInt(int64(limit), 10)) + vals.Set("cursor", strconv.FormatInt(cursor, 10)) + if sortBy != "" { + vals.Set("sort_by", sortBy) + } + path := v3Path + ordersPath + "/" + historicalPath + "/" + fillsPath + var resp *FillResponse + return resp, e.SendAuthenticatedHTTPRequest(ctx, exchange.RestSpot, http.MethodGet, path, vals, nil, true, &resp) +} + +// ListOrders lists orders, filtered by their status +func (e *Exchange) ListOrders(ctx context.Context, req *ListOrdersReq) (*ListOrdersResp, error) { + vals, err := urlValsFromDateRange(req.StartDate, req.EndDate, startDateString, endDateString) + if err != nil { + return nil, err + } + for x := range req.OrderStatus { + vals.Add("order_status", req.OrderStatus[x]) + } + for x := range req.OrderIDs { + vals.Add("order_ids", req.OrderIDs[x]) + } + for x := range req.TimeInForces { + vals.Add("time_in_forces", req.TimeInForces[x]) + } + for x := range req.OrderTypes { + vals.Add("order_types", req.OrderTypes[x]) + } + for x := range req.AssetFilters { + vals.Add("asset_filters", req.AssetFilters[x]) + } + for x := range req.ProductIDs { + vals.Add("product_ids", req.ProductIDs[x].String()) + } + if req.ProductType != "" { + vals.Set("product_type", req.ProductType) + } + if req.OrderSide != "" { + vals.Set("order_side", req.OrderSide) + } + if req.OrderPlacementSource != "" { + vals.Set("order_placement_source", req.OrderPlacementSource) + } + if req.ContractExpiryType != "" { + vals.Set("contract_expiry_type", req.ContractExpiryType) + } + if req.SortBy != "" { + vals.Set("sort_by", req.SortBy) + } + vals.Set("cursor", strconv.FormatInt(req.Cursor, 10)) + vals.Set("limit", strconv.FormatInt(int64(req.Limit), 10)) + vals.Set("user_native_currency", req.UserNativeCurrency.String()) + vals.Set("retail_portfolio_id", req.RetailPortfolioID) // deprecated and only works for legacy API keys + path := v3Path + ordersPath + "/" + historicalPath + "/" + batchPath + var resp *ListOrdersResp + return resp, e.SendAuthenticatedHTTPRequest(ctx, exchange.RestSpot, http.MethodGet, path, vals, nil, true, &resp) +} + +// PreviewOrder simulates the results of an order request +func (e *Exchange) PreviewOrder(ctx context.Context, inf *PreviewOrderInfo) (*PreviewOrderResp, error) { + if inf.BaseAmount <= 0 && inf.QuoteAmount <= 0 { + return nil, order.ErrAmountIsInvalid + } + orderConfig, err := createOrderConfig(&inf.OrderInfo) + if err != nil { + return nil, err + } + req := previewOrderReqBase{ + ProductID: inf.ProductID, + Side: inf.Side, + OrderConfiguration: &orderConfig, + RetailPortfolioID: inf.RetailPortfolioID, + Leverage: inf.Leverage, + AttachedOrderConfiguration: &inf.AttachedOrderConfiguration, + MarginType: FormatMarginType(inf.MarginType), + } + var resp *PreviewOrderResp + path := v3Path + ordersPath + "/" + previewPath + return resp, e.SendAuthenticatedHTTPRequest(ctx, exchange.RestSpot, http.MethodPost, path, nil, req, true, &resp) +} + +// GetPaymentMethodByID returns information on a single payment method associated with the user's account +func (e *Exchange) GetPaymentMethodByID(ctx context.Context, paymentMethodID string) (*PaymentMethodData, error) { + if paymentMethodID == "" { + return nil, errPaymentMethodEmpty + } + path := v3Path + paymentMethodsPath + "/" + paymentMethodID + resp := struct { + PaymentMethod PaymentMethodData `json:"payment_method"` + }{} + return &resp.PaymentMethod, e.SendAuthenticatedHTTPRequest(ctx, exchange.RestSpot, http.MethodGet, path, nil, nil, true, &resp) +} + +// ListPaymentMethods returns a list of all payment methods associated with the user's account +func (e *Exchange) ListPaymentMethods(ctx context.Context) ([]PaymentMethodData, error) { + resp := struct { + PaymentMethods []PaymentMethodData `json:"payment_methods"` + }{} + path := v3Path + paymentMethodsPath + return resp.PaymentMethods, e.SendAuthenticatedHTTPRequest(ctx, exchange.RestSpot, http.MethodGet, path, nil, paymentMethodReqBase{Currency: currency.BTC}, true, &resp) +} + +// AllocatePortfolio allocates funds to a position in your perpetuals portfolio +func (e *Exchange) AllocatePortfolio(ctx context.Context, portfolioID, productID, cur string, amount float64) error { + if portfolioID == "" { + return errPortfolioIDEmpty + } + if productID == "" { + return errProductIDEmpty + } + if cur == "" { + return currency.ErrCurrencyCodeEmpty + } + if amount <= 0 { + return order.ErrAmountIsInvalid + } + req := allocatePortfolioReqBase{ + PortfolioUUID: portfolioID, + Symbol: productID, + Currency: cur, + Amount: amount, + } + path := v3Path + intxPath + "/" + allocatePath + return e.SendAuthenticatedHTTPRequest(ctx, exchange.RestSpot, http.MethodPost, path, nil, req, true, nil) +} + +// GetPerpetualsPortfolioSummary returns a summary of your perpetuals portfolio +func (e *Exchange) GetPerpetualsPortfolioSummary(ctx context.Context, portfolioID string) (*PerpetualPortfolioResponse, error) { + if portfolioID == "" { + return nil, errPortfolioIDEmpty + } + path := v3Path + intxPath + "/" + portfolioPath + "/" + portfolioID + resp := struct { + Summary PerpetualPortfolioResponse `json:"summary"` + }{} + return &resp.Summary, e.SendAuthenticatedHTTPRequest(ctx, exchange.RestSpot, http.MethodGet, path, nil, nil, true, &resp) +} + +// GetPerpetualsPositionByID returns information on a single open position in your perpetuals portfolio +func (e *Exchange) GetPerpetualsPositionByID(ctx context.Context, portfolioID string, productID currency.Pair) (*PerpPositionDetail, error) { + if portfolioID == "" { + return nil, errPortfolioIDEmpty + } + if productID.IsEmpty() { + return nil, errProductIDEmpty + } + path := v3Path + intxPath + "/" + positionsPath + "/" + portfolioID + "/" + productID.String() + resp := struct { + Position PerpPositionDetail `json:"position"` + }{} + return &resp.Position, e.SendAuthenticatedHTTPRequest(ctx, exchange.RestSpot, http.MethodGet, path, nil, nil, true, &resp) +} + +// GetPortfolioBalances returns the current balances for all assets in your portfolio +func (e *Exchange) GetPortfolioBalances(ctx context.Context, portfolioID string) ([]PortfolioBalancesResponse, error) { + if portfolioID == "" { + return nil, errPortfolioIDEmpty + } + path := v3Path + intxPath + "/" + balancesPath + "/" + portfolioID + resp := struct { + PortfolioBalances []PortfolioBalancesResponse `json:"portfolio_balances"` + }{} + return resp.PortfolioBalances, e.SendAuthenticatedHTTPRequest(ctx, exchange.RestSpot, http.MethodGet, path, nil, nil, true, &resp) +} + +// GetAllPerpetualsPositions returns a list of all open positions in your perpetuals portfolio +func (e *Exchange) GetAllPerpetualsPositions(ctx context.Context, portfolioID string) (*AllPerpPosResponse, error) { + if portfolioID == "" { + return nil, errPortfolioIDEmpty + } + path := v3Path + intxPath + "/" + positionsPath + "/" + portfolioID + var resp *AllPerpPosResponse + return resp, e.SendAuthenticatedHTTPRequest(ctx, exchange.RestSpot, http.MethodGet, path, nil, nil, true, &resp) +} + +// MultiAssetCollateralToggle allows for the toggling of multi-asset collateral on a portfolio +func (e *Exchange) MultiAssetCollateralToggle(ctx context.Context, portfolioID string, enabled bool) (bool, error) { + if portfolioID == "" { + return false, errPortfolioIDEmpty + } + path := v3Path + intxPath + "/" + multiAssetCollateralPath + var resp struct { + Enabled bool `json:"multi_asset_collateral_enabled"` + } + return resp.Enabled, e.SendAuthenticatedHTTPRequest(ctx, exchange.RestSpot, http.MethodPost, path, nil, assetCollateralToggleReqBase{PortfolioUUID: portfolioID, Enabled: enabled}, true, &resp) +} + +// CreatePortfolio creates a new portfolio +func (e *Exchange) CreatePortfolio(ctx context.Context, name string) (*SimplePortfolioData, error) { + if name == "" { + return nil, errNameEmpty + } + resp := struct { + Portfolio SimplePortfolioData `json:"portfolio"` + }{} + return &resp.Portfolio, e.SendAuthenticatedHTTPRequest(ctx, exchange.RestSpot, http.MethodPost, v3Path+portfoliosPath, nil, nameReqBase{Name: name}, true, &resp) +} + +// DeletePortfolio deletes a portfolio +func (e *Exchange) DeletePortfolio(ctx context.Context, portfolioID string) error { + if portfolioID == "" { + return errPortfolioIDEmpty + } + path := v3Path + portfoliosPath + "/" + portfolioID + return e.SendAuthenticatedHTTPRequest(ctx, exchange.RestSpot, http.MethodDelete, path, nil, nil, true, nil) +} + +// EditPortfolio edits the name of a portfolio +func (e *Exchange) EditPortfolio(ctx context.Context, portfolioID, name string) (*SimplePortfolioData, error) { + if portfolioID == "" { + return nil, errPortfolioIDEmpty + } + if name == "" { + return nil, errNameEmpty + } + path := v3Path + portfoliosPath + "/" + portfolioID + resp := struct { + Portfolio SimplePortfolioData `json:"portfolio"` + }{} + return &resp.Portfolio, e.SendAuthenticatedHTTPRequest(ctx, exchange.RestSpot, http.MethodPut, path, nil, nameReqBase{Name: name}, true, &resp) +} + +// GetPortfolioByID provides detailed information on a single portfolio +func (e *Exchange) GetPortfolioByID(ctx context.Context, portfolioID string) (*DetailedPortfolioResponse, error) { + if portfolioID == "" { + return nil, errPortfolioIDEmpty + } + path := v3Path + portfoliosPath + "/" + portfolioID + resp := struct { + Breakdown DetailedPortfolioResponse `json:"breakdown"` + }{} + return &resp.Breakdown, e.SendAuthenticatedHTTPRequest(ctx, exchange.RestSpot, http.MethodGet, path, nil, nil, true, &resp) +} + +// GetAllPortfolios returns a list of portfolios associated with the user +func (e *Exchange) GetAllPortfolios(ctx context.Context, portfolioType string) ([]SimplePortfolioData, error) { + resp := struct { + Portfolios []SimplePortfolioData `json:"portfolios"` + }{} + vals := url.Values{} + if portfolioType != "" { + vals.Set("portfolio_type", portfolioType) + } + return resp.Portfolios, e.SendAuthenticatedHTTPRequest(ctx, exchange.RestSpot, http.MethodGet, v3Path+portfoliosPath, vals, nil, true, &resp) +} + +// MovePortfolioFunds transfers funds between portfolios +func (e *Exchange) MovePortfolioFunds(ctx context.Context, cur currency.Code, from, to string, amount float64) (*MovePortfolioFundsResponse, error) { + if from == "" || to == "" { + return nil, errPortfolioIDEmpty + } + if cur.IsEmpty() { + return nil, currency.ErrCurrencyCodeEmpty + } + if amount <= 0 { + return nil, order.ErrAmountIsInvalid + } + req := movePortfolioFundsReqBase{ + SourcePortfolioUUID: from, + TargetPortfolioUUID: to, + Funds: fundsData{ + Value: amount, + Currency: cur, + }, + } + path := v3Path + portfoliosPath + "/" + moveFundsPath + var resp *MovePortfolioFundsResponse + return resp, e.SendAuthenticatedHTTPRequest(ctx, exchange.RestSpot, http.MethodPost, path, nil, req, true, &resp) +} + +// GetBestBidAsk returns the best bid/ask for all products. Can be filtered to certain products by passing through additional strings +func (e *Exchange) GetBestBidAsk(ctx context.Context, products []string) ([]ProductBook, error) { + vals := url.Values{} + for x := range products { + vals.Add("product_ids", products[x]) + } + resp := struct { + Pricebooks []ProductBook `json:"pricebooks"` + }{} + return resp.Pricebooks, e.SendAuthenticatedHTTPRequest(ctx, exchange.RestSpot, http.MethodGet, v3Path+bestBidAskPath, vals, nil, true, &resp) +} + +// GetTicker returns snapshot information about the last trades (ticks) and best bid/ask. Contrary to documentation, this does not tell you the 24h volume +func (e *Exchange) GetTicker(ctx context.Context, productID currency.Pair, limit uint16, startDate, endDate time.Time, authenticated bool) (*Ticker, error) { + if productID.IsEmpty() { + return nil, errProductIDEmpty + } + vals := url.Values{} + vals.Set("limit", strconv.FormatInt(int64(limit), 10)) + if !startDate.IsZero() && !startDate.Equal(time.Time{}) { + vals.Set("start", strconv.FormatInt(startDate.Unix(), 10)) + } + if !endDate.IsZero() && !endDate.Equal(time.Time{}) { + vals.Set("end", strconv.FormatInt(endDate.Unix(), 10)) + } + var resp *Ticker + if authenticated { + path := v3Path + productsPath + "/" + productID.String() + "/" + tickerPath + return resp, e.SendAuthenticatedHTTPRequest(ctx, exchange.RestSpot, http.MethodGet, path, vals, nil, true, &resp) + } + path := v3Path + marketPath + "/" + productsPath + "/" + productID.String() + "/" + tickerPath + return resp, e.SendHTTPRequest(ctx, exchange.RestSpot, path, vals, &resp) +} + +// GetProductByID returns information on a single specified currency pair +func (e *Exchange) GetProductByID(ctx context.Context, productID currency.Pair, authenticated bool) (*Product, error) { + if productID.IsEmpty() { + return nil, errProductIDEmpty + } + var resp *Product + if authenticated { + path := v3Path + productsPath + "/" + productID.String() + return resp, e.SendAuthenticatedHTTPRequest(ctx, exchange.RestSpot, http.MethodGet, path, nil, nil, true, &resp) + } + path := v3Path + marketPath + "/" + productsPath + "/" + productID.String() + return resp, e.SendHTTPRequest(ctx, exchange.RestSpot, path, nil, &resp) +} + +// GetProductBookV3 returns a list of bids/asks for a single product +func (e *Exchange) GetProductBookV3(ctx context.Context, productID currency.Pair, limit uint16, aggregationIncrement float64, authenticated bool) (*ProductBookResp, error) { + if productID.IsEmpty() { + return nil, errProductIDEmpty + } + vals := url.Values{} + vals.Set("product_id", productID.String()) + if limit != 0 { + vals.Set("limit", strconv.FormatInt(int64(limit), 10)) + } + if aggregationIncrement != 0 { + vals.Set("aggregation_price_increment", strconv.FormatFloat(aggregationIncrement, 'f', -1, 64)) + } + var resp *ProductBookResp + if authenticated { + return resp, e.SendAuthenticatedHTTPRequest(ctx, exchange.RestSpot, http.MethodGet, v3Path+productBookPath, vals, nil, true, &resp) + } + path := v3Path + marketPath + "/" + productBookPath + return resp, e.SendHTTPRequest(ctx, exchange.RestSpot, path, vals, &resp) +} + +// GetHistoricKlines returns historic candles for a product. Candles are returned in grouped buckets based on requested granularity. Requests that return more than 300 data points are rejected +func (e *Exchange) GetHistoricKlines(ctx context.Context, productID string, granularity kline.Interval, startDate, endDate time.Time, authenticated bool) ([]kline.Candle, error) { + if productID == "" { + return nil, errProductIDEmpty + } + gran, ok := allowedGranularities[granularity] + if !ok { + return nil, fmt.Errorf("%w %v, allowed granularities are: %+v", kline.ErrUnsupportedInterval, granularity, allowedGranularities) + } + vals := url.Values{} + vals.Set("start", strconv.FormatInt(startDate.Unix(), 10)) + vals.Set("end", strconv.FormatInt(endDate.Unix(), 10)) + vals.Set("granularity", gran) + resp := struct { + Candles []Klines `json:"candles"` + }{} + var err error + if authenticated { + path := v3Path + productsPath + "/" + productID + "/" + candlesPath + err = e.SendAuthenticatedHTTPRequest(ctx, exchange.RestSpot, http.MethodGet, path, vals, nil, true, &resp) + } else { + path := v3Path + marketPath + "/" + productsPath + "/" + productID + "/" + candlesPath + err = e.SendHTTPRequest(ctx, exchange.RestSpot, path, vals, &resp) + } + if err != nil { + return nil, err + } + timeSeries := make([]kline.Candle, len(resp.Candles)) + for x := range resp.Candles { + timeSeries[x] = kline.Candle{ + Time: resp.Candles[x].Start.Time(), + Low: resp.Candles[x].Low.Float64(), + High: resp.Candles[x].High.Float64(), + Open: resp.Candles[x].Open.Float64(), + Close: resp.Candles[x].Close.Float64(), + Volume: resp.Candles[x].Volume.Float64(), + } + } + return timeSeries, nil +} + +// GetAllProducts returns information on all currency pairs that are available for trading +// The getTradabilityStatus parameter is only used for authenticated requests, and will return the tradability status of SPOT products in their view_only field +// The getAllProducts parameter overrides the set productType; with it set to true, it will return both SPOT and Futures products +func (e *Exchange) GetAllProducts(ctx context.Context, limit, offset int32, productType, contractExpiryType, expiringContractStatus, productsSortOrder string, productIDs []string, getTradabilityStatus, getAllProducts, authenticated bool) (*AllProducts, error) { + vals := url.Values{} + vals.Set("limit", strconv.FormatInt(int64(limit), 10)) + if offset != 0 { + vals.Set("offset", strconv.FormatInt(int64(offset), 10)) + } + if productType != "" { + vals.Set("product_type", productType) + } + if contractExpiryType != "" { + vals.Set("contract_expiry_type", contractExpiryType) + } + if expiringContractStatus != "" { + vals.Set("expiring_contract_status", expiringContractStatus) + } + if productsSortOrder != "" { + vals.Set("products_sort_order", productsSortOrder) + } + for x := range productIDs { + vals.Add("product_ids", productIDs[x]) + } + vals.Set("get_tradability_status", strconv.FormatBool(getTradabilityStatus)) + vals.Set("get_all_products", strconv.FormatBool(getAllProducts)) + var resp *AllProducts + if authenticated { + return resp, e.SendAuthenticatedHTTPRequest(ctx, exchange.RestSpot, http.MethodGet, v3Path+productsPath, vals, nil, true, &resp) + } + path := v3Path + marketPath + "/" + productsPath + return resp, e.SendHTTPRequest(ctx, exchange.RestSpot, path, vals, &resp) +} + +// GetV3Time returns the current server time, calling V3 of the API +func (e *Exchange) GetV3Time(ctx context.Context) (*ServerTimeV3, error) { + var resp *ServerTimeV3 + return resp, e.SendHTTPRequest(ctx, exchange.RestSpot, v3Path+timePath, nil, &resp) +} + +// SendMoney can send funds to an email or cryptocurrency address (if "traType" is set to "send"), or to another one of the user's wallets or vaults (if "traType" is set to "transfer"). Coinbase may delay or cancel the transaction at their discretion. The "idem" parameter is an optional string for idempotency; a token with a max length of 100 characters, if a previous transaction included the same token as a parameter, the new transaction won't be processed, and information on the previous transaction will be returned instead +func (e *Exchange) SendMoney(ctx context.Context, traType, walletID, to, description, idem, destinationTag, network string, cur currency.Code, amount float64, skipNotifications bool, travelRuleData *TravelRule) (*TransactionData, error) { + if traType == "" { + return nil, errTransactionTypeEmpty + } + if walletID == "" { + return nil, errWalletIDEmpty + } + if to == "" { + return nil, errToEmpty + } + if amount <= 0 { + return nil, order.ErrAmountIsInvalid + } + if cur.IsEmpty() { + return nil, currency.ErrCurrencyCodeEmpty + } + path := v2Path + accountsPath + "/" + walletID + "/" + transactionsPath + req := sendMoneyReqBase{ + Type: traType, + To: to, + Amount: amount, + Currency: cur, + Description: description, + SkipNotifications: skipNotifications, + Idem: idem, + DestinationTag: destinationTag, + Network: network, + TravelRuleData: travelRuleData, + } + resp := struct { + Data TransactionData `json:"data"` + }{} + return &resp.Data, e.SendAuthenticatedHTTPRequest(ctx, exchange.RestSpot, http.MethodPost, path, nil, req, false, &resp) +} + +// CreateAddress generates a crypto address for depositing to the specified wallet +func (e *Exchange) CreateAddress(ctx context.Context, walletID, name string) (*AddressData, error) { + if walletID == "" { + return nil, errWalletIDEmpty + } + path := v2Path + accountsPath + "/" + walletID + "/" + addressesPath + resp := struct { + Data AddressData `json:"data"` + }{} + return &resp.Data, e.SendAuthenticatedHTTPRequest(ctx, exchange.RestSpot, http.MethodPost, path, nil, nameReqBase{Name: name}, false, &resp) +} + +// GetAllAddresses returns information on all addresses associated with a wallet +func (e *Exchange) GetAllAddresses(ctx context.Context, walletID string, pag PaginationInp) (*GetAllAddrResponse, error) { + if walletID == "" { + return nil, errWalletIDEmpty + } + path := v2Path + accountsPath + "/" + walletID + "/" + addressesPath + vals := urlValsFromPagination(pag) + var resp *GetAllAddrResponse + return resp, e.SendAuthenticatedHTTPRequest(ctx, exchange.RestSpot, http.MethodGet, path, vals, nil, false, &resp) +} + +// GetAddressByID returns information on a single address associated with the specified wallet +func (e *Exchange) GetAddressByID(ctx context.Context, walletID, addressID string) (*AddressData, error) { + if walletID == "" { + return nil, errWalletIDEmpty + } + if addressID == "" { + return nil, errAddressIDEmpty + } + path := v2Path + accountsPath + "/" + walletID + "/" + addressesPath + "/" + addressID + resp := struct { + Data AddressData `json:"data"` + }{} + return &resp.Data, e.SendAuthenticatedHTTPRequest(ctx, exchange.RestSpot, http.MethodGet, path, nil, nil, false, &resp) +} + +// GetAddressTransactions returns a list of transactions associated with the specified address +func (e *Exchange) GetAddressTransactions(ctx context.Context, walletID, addressID string, pag PaginationInp) (*ManyTransactionsResp, error) { + if walletID == "" { + return nil, errWalletIDEmpty + } + if addressID == "" { + return nil, errAddressIDEmpty + } + path := v2Path + accountsPath + "/" + walletID + "/" + addressesPath + "/" + addressID + "/" + transactionsPath + vals := urlValsFromPagination(pag) + var resp *ManyTransactionsResp + return resp, e.SendAuthenticatedHTTPRequest(ctx, exchange.RestSpot, http.MethodGet, path, vals, nil, false, &resp) +} + +// FiatTransfer prepares and optionally processes a transfer of funds between the exchange and a fiat payment method. "Deposit" signifies funds going from exchange to bank, "withdraw" signifies funds going from bank to exchange +func (e *Exchange) FiatTransfer(ctx context.Context, walletID, cur, paymentMethod string, amount float64, commit bool, transferType FiatTransferType) (*DeposWithdrData, error) { + if walletID == "" { + return nil, errWalletIDEmpty + } + if amount <= 0 { + return nil, order.ErrAmountIsInvalid + } + if cur == "" { + return nil, currency.ErrCurrencyCodeEmpty + } + if paymentMethod == "" { + return nil, errPaymentMethodEmpty + } + var path string + switch transferType { + case FiatDeposit: + path = v2Path + accountsPath + "/" + walletID + "/" + depositsPath + case FiatWithdrawal: + path = v2Path + accountsPath + "/" + walletID + "/" + withdrawalsPath + } + req := fiatTransferReqBase{ + Currency: cur, + PaymentMethod: paymentMethod, + Amount: amount, + Commit: commit, + } + resp := struct { + Data DeposWithdrData `json:"transfer"` + }{} + return &resp.Data, e.SendAuthenticatedHTTPRequest(ctx, exchange.RestSpot, http.MethodPost, path, nil, req, false, &resp) +} + +// CommitTransfer processes a deposit/withdrawal that was created with the "commit" parameter set to false +func (e *Exchange) CommitTransfer(ctx context.Context, walletID, depositID string, transferType FiatTransferType) (*DeposWithdrData, error) { + if walletID == "" { + return nil, errWalletIDEmpty + } + if depositID == "" { + return nil, errDepositIDEmpty + } + var path string + switch transferType { + case FiatDeposit: + path = v2Path + accountsPath + "/" + walletID + "/" + depositsPath + "/" + depositID + "/" + commitPath + case FiatWithdrawal: + path = v2Path + accountsPath + "/" + walletID + "/" + withdrawalsPath + "/" + depositID + "/" + commitPath + } + resp := struct { + Data DeposWithdrData `json:"data"` + }{} + return &resp.Data, e.SendAuthenticatedHTTPRequest(ctx, exchange.RestSpot, http.MethodPost, path, nil, nil, false, &resp) +} + +// GetAllFiatTransfers returns a list of transfers either to or from fiat payment methods and the specified wallet +func (e *Exchange) GetAllFiatTransfers(ctx context.Context, walletID string, pag PaginationInp, transferType FiatTransferType) (*ManyDeposWithdrResp, error) { + if walletID == "" { + return nil, errWalletIDEmpty + } + var path string + switch transferType { + case FiatDeposit: + path = v2Path + accountsPath + "/" + walletID + "/" + depositsPath + case FiatWithdrawal: + path = v2Path + accountsPath + "/" + walletID + "/" + withdrawalsPath + } + vals := urlValsFromPagination(pag) + var resp *ManyDeposWithdrResp + return resp, e.SendAuthenticatedHTTPRequest(ctx, exchange.RestSpot, http.MethodGet, path, vals, nil, false, &resp) +} + +// GetFiatTransferByID returns information on a single deposit/withdrawal associated with the specified wallet +func (e *Exchange) GetFiatTransferByID(ctx context.Context, walletID, depositID string, transferType FiatTransferType) (*DeposWithdrData, error) { + if walletID == "" { + return nil, errWalletIDEmpty + } + if depositID == "" { + return nil, errDepositIDEmpty + } + var path string + switch transferType { + case FiatDeposit: + path = v2Path + accountsPath + "/" + walletID + "/" + depositsPath + "/" + depositID + case FiatWithdrawal: + path = v2Path + accountsPath + "/" + walletID + "/" + withdrawalsPath + "/" + depositID + } + resp := struct { + Data DeposWithdrData `json:"data"` + }{} + return &resp.Data, e.SendAuthenticatedHTTPRequest(ctx, exchange.RestSpot, http.MethodGet, path, nil, nil, false, &resp) +} + +// GetAllWallets lists all accounts associated with the API key +func (e *Exchange) GetAllWallets(ctx context.Context, pag PaginationInp) (*GetAllWalletsResponse, error) { + vals := urlValsFromPagination(pag) + var resp *GetAllWalletsResponse + return resp, e.SendAuthenticatedHTTPRequest(ctx, exchange.RestSpot, http.MethodGet, v2Path+accountsPath, vals, nil, false, &resp) +} + +// GetWalletByID returns information about a single wallet. In lieu of a wallet ID, a currency can be provided to get the primary account for that currency +func (e *Exchange) GetWalletByID(ctx context.Context, walletID string, cur currency.Code) (*WalletData, error) { + if (walletID == "" && cur.IsEmpty()) || (walletID != "" && !cur.IsEmpty()) { + return nil, errCurrWalletConflict + } + var path string + if walletID != "" { + path = v2Path + accountsPath + "/" + walletID + } + if !cur.IsEmpty() { + path = v2Path + accountsPath + "/" + cur.String() + } + resp := struct { + Data WalletData `json:"data"` + }{} + return &resp.Data, e.SendAuthenticatedHTTPRequest(ctx, exchange.RestSpot, http.MethodGet, path, nil, nil, false, &resp) +} + +// GetAllTransactions returns a list of transactions associated with the specified wallet +func (e *Exchange) GetAllTransactions(ctx context.Context, walletID string, pag PaginationInp) (*ManyTransactionsResp, error) { + if walletID == "" { + return nil, errWalletIDEmpty + } + vals := urlValsFromPagination(pag) + path := v2Path + accountsPath + "/" + walletID + "/" + transactionsPath + var resp *ManyTransactionsResp + return resp, e.SendAuthenticatedHTTPRequest(ctx, exchange.RestSpot, http.MethodGet, path, vals, nil, false, &resp) +} + +// GetTransactionByID returns information on a single transaction associated with the specified wallet +func (e *Exchange) GetTransactionByID(ctx context.Context, walletID, transactionID string) (*TransactionData, error) { + if walletID == "" { + return nil, errWalletIDEmpty + } + if transactionID == "" { + return nil, errTransactionIDEmpty + } + path := v2Path + accountsPath + "/" + walletID + "/" + transactionsPath + "/" + transactionID + resp := struct { + Data TransactionData `json:"data"` + }{} + return &resp.Data, e.SendAuthenticatedHTTPRequest(ctx, exchange.RestSpot, http.MethodGet, path, nil, nil, false, &resp) +} + +// GetFiatCurrencies lists currencies that Coinbase knows about +func (e *Exchange) GetFiatCurrencies(ctx context.Context) ([]FiatData, error) { + resp := struct { + Data []FiatData `json:"data"` + }{} + return resp.Data, e.SendHTTPRequest(ctx, exchange.RestSpot, v2Path+currenciesPath, nil, &resp) +} + +// GetCryptocurrencies lists cryptocurrencies that Coinbase knows about +func (e *Exchange) GetCryptocurrencies(ctx context.Context) ([]CryptoData, error) { + resp := struct { + Data []CryptoData `json:"data"` + }{} + path := v2Path + currenciesPath + "/" + cryptoPath + return resp.Data, e.SendHTTPRequest(ctx, exchange.RestSpot, path, nil, &resp) +} + +// GetExchangeRates returns exchange rates for the specified currency. If none is specified, it defaults to USD +func (e *Exchange) GetExchangeRates(ctx context.Context, cur string) (*GetExchangeRatesResp, error) { + resp := struct { + Data GetExchangeRatesResp `json:"data"` + }{} + vals := url.Values{} + vals.Set("currency", cur) + return &resp.Data, e.SendHTTPRequest(ctx, exchange.RestSpot, v2Path+exchangeRatesPath, vals, &resp) +} + +// GetPrice returns the price the spot/buy/sell price for the specified currency pair, including the standard Coinbase fee of 1%, but excluding any other fees +func (e *Exchange) GetPrice(ctx context.Context, currencyPair, priceType string) (*GetPriceResp, error) { + var path string + switch priceType { + case "spot", "buy", "sell": + path = v2Path + pricesPath + "/" + currencyPair + "/" + priceType + default: + return nil, errInvalidPriceType + } + resp := struct { + Data GetPriceResp `json:"data"` + }{} + return &resp.Data, e.SendHTTPRequest(ctx, exchange.RestSpot, path, nil, &resp) +} + +// GetV2Time returns the current server time, calling V2 of the API +func (e *Exchange) GetV2Time(ctx context.Context) (*ServerTimeV2, error) { + resp := struct { + Data ServerTimeV2 `json:"data"` + }{} + return &resp.Data, e.SendHTTPRequest(ctx, exchange.RestSpot, v2Path+timePath, nil, &resp) +} + +// GetCurrentUser returns information about the user associated with the API key +func (e *Exchange) GetCurrentUser(ctx context.Context) (*UserResponse, error) { + resp := struct { + Data UserResponse `json:"data"` + }{} + return &resp.Data, e.SendAuthenticatedHTTPRequest(ctx, exchange.RestSpot, http.MethodGet, v2Path+userPath, nil, nil, false, &resp) +} + +// GetAllCurrencies returns a list of all currencies that Coinbase knows about. These aren't necessarily tradable +func (e *Exchange) GetAllCurrencies(ctx context.Context) ([]CurrencyData, error) { + var resp []CurrencyData + return resp, e.SendHTTPRequest(ctx, exchange.RestSpotSupplementary, currenciesPath, nil, &resp) +} + +// GetACurrency returns information on a single currency specified by the user +func (e *Exchange) GetACurrency(ctx context.Context, cur string) (*CurrencyData, error) { + if cur == "" { + return nil, currency.ErrCurrencyCodeEmpty + } + var resp *CurrencyData + path := currenciesPath + "/" + cur + return resp, e.SendHTTPRequest(ctx, exchange.RestSpotSupplementary, path, nil, &resp) +} + +// GetAllTradingPairs returns a list of currency pairs which are available for trading +func (e *Exchange) GetAllTradingPairs(ctx context.Context, pairType string) ([]PairData, error) { + var resp []PairData + vals := url.Values{} + if pairType != "" { + vals.Set("type", pairType) + } + return resp, e.SendHTTPRequest(ctx, exchange.RestSpotSupplementary, productsPath, vals, &resp) +} + +// GetAllPairVolumes returns a list of currency pairs and their associated volumes +func (e *Exchange) GetAllPairVolumes(ctx context.Context) ([]PairVolumeData, error) { + var resp []PairVolumeData + path := productsPath + "/" + volumeSummaryPath + return resp, e.SendHTTPRequest(ctx, exchange.RestSpotSupplementary, path, nil, &resp) +} + +// GetPairDetails returns information on a single currency pair +func (e *Exchange) GetPairDetails(ctx context.Context, pair string) (*PairData, error) { + if pair == "" { + return nil, currency.ErrCurrencyPairEmpty + } + var resp *PairData + path := productsPath + "/" + pair + return resp, e.SendHTTPRequest(ctx, exchange.RestSpotSupplementary, path, nil, &resp) +} + +// GetProductBookV1 returns the order book for the specified currency pair. Level 1 only returns the best bids and asks, Level 2 returns the full order book with orders at the same price aggregated, Level 3 returns the full non-aggregated order book. +func (e *Exchange) GetProductBookV1(ctx context.Context, pair string, level uint8) (*OrderBookResp, error) { + if pair == "" { + return nil, currency.ErrCurrencyPairEmpty + } + var resp *OrderBookResp + vals := url.Values{} + vals.Set("level", strconv.FormatUint(uint64(level), 10)) + path := productsPath + "/" + pair + "/" + bookPath + return resp, e.SendHTTPRequest(ctx, exchange.RestSpotSupplementary, path, vals, &resp) +} + +// GetProductCandles returns historical market data for the specified currency pair. +func (e *Exchange) GetProductCandles(ctx context.Context, pair string, granularity uint32, startTime, endTime time.Time) ([]Candle, error) { + if pair == "" { + return nil, currency.ErrCurrencyPairEmpty + } + vals, err := urlValsFromDateRange(startTime, endTime, "start", "end") + if err != nil { + return nil, err + } + if granularity != 0 { + vals.Set("granularity", strconv.FormatUint(uint64(granularity), 10)) + } + path := productsPath + "/" + pair + "/" + candlesPath + var resp []Candle + return resp, e.SendHTTPRequest(ctx, exchange.RestSpotSupplementary, path, vals, &resp) +} + +// GetProductStats returns information on a specific pair's price and volume +func (e *Exchange) GetProductStats(ctx context.Context, pair string) (*ProductStats, error) { + if pair == "" { + return nil, currency.ErrCurrencyPairEmpty + } + path := productsPath + "/" + pair + "/" + statsPath + var resp *ProductStats + return resp, e.SendHTTPRequest(ctx, exchange.RestSpotSupplementary, path, nil, &resp) +} + +// GetProductTicker returns the ticker for the specified currency pair +func (e *Exchange) GetProductTicker(ctx context.Context, pair string) (*ProductTicker, error) { + if pair == "" { + return nil, currency.ErrCurrencyPairEmpty + } + path := productsPath + "/" + pair + "/" + tickerPath + var resp *ProductTicker + return resp, e.SendHTTPRequest(ctx, exchange.RestSpotSupplementary, path, nil, &resp) +} + +// GetProductTrades returns a list of the latest traides for a pair +func (e *Exchange) GetProductTrades(ctx context.Context, pair, step, direction string, limit int64) ([]ProductTrades, error) { + if pair == "" { + return nil, currency.ErrCurrencyPairEmpty + } + vals := url.Values{} + if step != "" { + vals.Set(direction, step) + } + vals.Set("limit", strconv.FormatInt(limit, 10)) + path := productsPath + "/" + pair + "/" + tradesPath + var resp []ProductTrades + return resp, e.SendHTTPRequest(ctx, exchange.RestSpotSupplementary, path, vals, &resp) +} + +// GetAllWrappedAssets returns a list of supported wrapped assets +func (e *Exchange) GetAllWrappedAssets(ctx context.Context) (*AllWrappedAssets, error) { + var resp *AllWrappedAssets + return resp, e.SendHTTPRequest(ctx, exchange.RestSpotSupplementary, wrappedAssetsPath, nil, &resp) +} + +// GetWrappedAssetDetails returns information on a single wrapped asset +func (e *Exchange) GetWrappedAssetDetails(ctx context.Context, wrappedAsset string) (*WrappedAsset, error) { + if wrappedAsset == "" { + return nil, errWrappedAssetEmpty + } + var resp *WrappedAsset + path := wrappedAssetsPath + "/" + wrappedAsset + return resp, e.SendHTTPRequest(ctx, exchange.RestSpotSupplementary, path, nil, &resp) +} + +// GetWrappedAssetConversionRate returns the conversion rate for the specified wrapped asset +func (e *Exchange) GetWrappedAssetConversionRate(ctx context.Context, wrappedAsset string) (*WrappedAssetConversionRate, error) { + if wrappedAsset == "" { + return nil, errWrappedAssetEmpty + } + var resp *WrappedAssetConversionRate + path := wrappedAssetsPath + "/" + wrappedAsset + "/" + conversionRatePath + return resp, e.SendHTTPRequest(ctx, exchange.RestSpotSupplementary, path, nil, &resp) +} + +// SendHTTPRequest sends an unauthenticated HTTP request +func (e *Exchange) SendHTTPRequest(ctx context.Context, ep exchange.URL, path string, vals url.Values, result any) error { + endpoint, err := e.API.Endpoints.GetURL(ep) + if err != nil { + return err + } + rLim := PubRate + if strings.Contains(path, v2Path) { + rLim = V2Rate + } + path = common.EncodeURLValues(path, vals) + item := &request.Item{ + Method: http.MethodGet, + Path: endpoint + path, + Result: result, + Verbose: e.Verbose, + HTTPDebugging: e.HTTPDebugging, + HTTPRecording: e.HTTPRecording, + HTTPMockDataSliceLimit: e.HTTPMockDataSliceLimit, + } + return e.SendPayload(ctx, rLim, func() (*request.Item, error) { + return item, nil + }, request.UnauthenticatedRequest) +} + +// SendAuthenticatedHTTPRequest sends an authenticated HTTP request +func (e *Exchange) SendAuthenticatedHTTPRequest(ctx context.Context, ep exchange.URL, method, path string, queryParams url.Values, payload any, isVersion3 bool, result any) (err error) { + endpoint, err := e.API.Endpoints.GetURL(ep) + if err != nil { + return err + } + if len(endpoint) < 8 { + return errEndpointPathInvalid + } + interim := json.RawMessage{} + newRequest := func() (*request.Item, error) { + payloadBytes := []byte("") + if payload != nil { + if payloadBytes, err = json.Marshal(payload); err != nil { + return nil, err + } + } + var jwt string + if jwt, _, err = e.GetJWT(ctx, method+" "+endpoint[8:]+path); err != nil { + return nil, err + } + headers := make(map[string]string) + headers["Content-Type"] = "application/json" + headers["CB-VERSION"] = "2025-03-26" + headers["Authorization"] = "Bearer " + jwt + return &request.Item{ + Method: method, + Path: endpoint + common.EncodeURLValues(path, queryParams), + Headers: headers, + Body: bytes.NewBuffer(payloadBytes), + Result: &interim, + Verbose: e.Verbose, + HTTPDebugging: e.HTTPDebugging, + HTTPRecording: e.HTTPRecording, + HTTPMockDataSliceLimit: e.HTTPMockDataSliceLimit, + }, nil + } + rateLim := V2Rate + if isVersion3 { + rateLim = V3Rate + } + if err := e.SendPayload(ctx, rateLim, newRequest, request.AuthenticatedRequest); err != nil { + return err + } + // Doing this error handling because the docs indicate that errors can be returned even with a 200 status code, and that these errors can be buried in the JSON returned + singleErrCap := struct { + ErrorResponse ErrorResponse `json:"error_response"` + }{} + if err := json.Unmarshal(interim, &singleErrCap); err == nil { + if singleErrCap.ErrorResponse.ErrorType != "" { + return fmt.Errorf("message: %s, error type: %s, error details: %s, edit failure reason: %s, preview failure reason: %s, new order failure reason: %s", singleErrCap.ErrorResponse.Message, singleErrCap.ErrorResponse.ErrorType, singleErrCap.ErrorResponse.ErrorDetails, singleErrCap.ErrorResponse.EditFailureReason, singleErrCap.ErrorResponse.PreviewFailureReason, singleErrCap.ErrorResponse.NewOrderFailureReason) + } + } + manyErrCap := struct { + Results []ManyErrors `json:"results"` + Errors []ManyErrors `json:"errors"` + }{} + if err := json.Unmarshal(interim, &manyErrCap); err == nil { + errMessage := "" + for i := range manyErrCap.Errors { + if !manyErrCap.Errors[i].Success && (manyErrCap.Errors[i].EditFailureReason != "" || manyErrCap.Errors[i].PreviewFailureReason != "") { + errMessage += fmt.Sprintf("order id: %s, failure reason: %s, edit failure reason: %s, preview failure reason: %s", manyErrCap.Errors[i].OrderID, manyErrCap.Errors[i].FailureReason, manyErrCap.Errors[i].EditFailureReason, manyErrCap.Errors[i].PreviewFailureReason) + } + } + for i := range manyErrCap.Results { + if !manyErrCap.Results[i].Success && (manyErrCap.Results[i].EditFailureReason != "" || manyErrCap.Results[i].PreviewFailureReason != "") { + errMessage += fmt.Sprintf("order id: %s, failure reason: %s, edit failure reason: %s, preview failure reason: %s", manyErrCap.Results[i].OrderID, manyErrCap.Results[i].FailureReason, manyErrCap.Results[i].EditFailureReason, manyErrCap.Results[i].PreviewFailureReason) + } + } + if errMessage != "" { + return errors.New(errMessage) + } + } + if result == nil { + return nil + } + return json.Unmarshal(interim, result) +} + +// GetJWT generates a new JWT +func (e *Exchange) GetJWT(ctx context.Context, uri string) (string, time.Time, error) { + creds, err := e.GetCredentials(ctx) + if err != nil { + return "", time.Time{}, err + } + block, _ := pem.Decode([]byte(creds.Secret)) + if block == nil { + return "", time.Time{}, errDecodingPrivateKey + } + privateKey, err := x509.ParseECPrivateKey(block.Bytes) + if err != nil { + return "", time.Time{}, err + } + nonce, err := common.GenerateRandomString(16, "1234567890ABCDEF") + if err != nil { + return "", time.Time{}, err + } + head := map[string]any{ + "kid": creds.Key, + "typ": "JWT", + "alg": "ES256", + "nonce": nonce, + } + headJSON, err := json.Marshal(head) + if err != nil { + return "", time.Time{}, err + } + headEnc := base64.RawURLEncoding.EncodeToString(headJSON) + regTime := time.Now() + body := map[string]any{ + "iss": "cdp", + "nbf": regTime.Unix(), + // As per documentation, the JWT expires after two minutes, with the exchange expecting this expiry time to be set accordingly + "exp": regTime.Add(2 * time.Minute).Unix(), + "sub": creds.Key, + } + if uri != "" { + body["uri"] = uri + } + bodyJSON, err := json.Marshal(body) + if err != nil { + return "", time.Time{}, err + } + bodyEnc := base64.RawURLEncoding.EncodeToString(bodyJSON) + signingInput := headEnc + "." + bodyEnc + hash := sha256.Sum256([]byte(signingInput)) + r, s, err := ecdsa.Sign(rand.Reader, privateKey, hash[:]) + if err != nil { + return "", time.Time{}, err + } + n := privateKey.Params().N + halfN := new(big.Int).Rsh(n, 1) + if s.Cmp(halfN) == 1 { + s.Sub(n, s) + } + rb := r.Bytes() + sb := s.Bytes() + sig := make([]byte, 64) + copy(sig[32-len(rb):32], rb) + copy(sig[64-len(sb):], sb) + sigEnc := base64.RawURLEncoding.EncodeToString(sig) + return signingInput + "." + sigEnc, regTime.Add(2 * time.Minute), nil +} + +// GetFee returns an estimate of fee based on type of transaction +func (e *Exchange) GetFee(ctx context.Context, feeBuilder *exchange.FeeBuilder) (float64, error) { + if feeBuilder == nil { + return 0, fmt.Errorf("%T %w", feeBuilder, common.ErrNilPointer) + } + var fee float64 + switch { + case !isStablePair(feeBuilder.Pair) && feeBuilder.FeeType == exchange.CryptocurrencyTradeFee: + fees, err := e.GetTransactionSummary(ctx, time.Now().Add(-time.Hour*24*30), time.Now(), "", "", "") + if err != nil { + return 0, err + } + if feeBuilder.IsMaker { + fee = fees.FeeTier.MakerFeeRate.Float64() + } else { + fee = fees.FeeTier.TakerFeeRate.Float64() + } + case feeBuilder.IsMaker && isStablePair(feeBuilder.Pair) && (feeBuilder.FeeType == exchange.CryptocurrencyTradeFee || feeBuilder.FeeType == exchange.OfflineTradeFee): + fee = StablePairMakerFee + case !feeBuilder.IsMaker && isStablePair(feeBuilder.Pair) && (feeBuilder.FeeType == exchange.CryptocurrencyTradeFee || feeBuilder.FeeType == exchange.OfflineTradeFee): + fee = WorstCaseStablePairTakerFee + case feeBuilder.IsMaker && !isStablePair(feeBuilder.Pair) && feeBuilder.FeeType == exchange.OfflineTradeFee: + fee = WorstCaseMakerFee + case !feeBuilder.IsMaker && !isStablePair(feeBuilder.Pair) && feeBuilder.FeeType == exchange.OfflineTradeFee: + fee = WorstCaseTakerFee + default: + return 0, errFeeTypeNotSupported + } + return fee * feeBuilder.Amount * feeBuilder.PurchasePrice, nil +} + +var stableMap = map[key.PairAsset]bool{ + {Base: currency.USDT.Item, Quote: currency.USD.Item}: true, + {Base: currency.USDT.Item, Quote: currency.EUR.Item}: true, + {Base: currency.USDC.Item, Quote: currency.EUR.Item}: true, + {Base: currency.USDC.Item, Quote: currency.GBP.Item}: true, + {Base: currency.USDT.Item, Quote: currency.GBP.Item}: true, + {Base: currency.USDT.Item, Quote: currency.USDC.Item}: true, + {Base: currency.DAI.Item, Quote: currency.USD.Item}: true, + {Base: currency.CBETH.Item, Quote: currency.ETH.Item}: true, + {Base: currency.PYUSD.Item, Quote: currency.USD.Item}: true, + {Base: currency.EUROC.Item, Quote: currency.USD.Item}: true, + {Base: currency.GUSD.Item, Quote: currency.USD.Item}: true, + {Base: currency.EUROC.Item, Quote: currency.EUR.Item}: true, + {Base: currency.WBTC.Item, Quote: currency.BTC.Item}: true, + {Base: currency.LSETH.Item, Quote: currency.ETH.Item}: true, + {Base: currency.GYEN.Item, Quote: currency.USD.Item}: true, + {Base: currency.PAX.Item, Quote: currency.USD.Item}: true, +} + +// IsStablePair returns true if the currency pair is considered a "stable pair" by Coinbase +func isStablePair(pair currency.Pair) bool { + return stableMap[key.PairAsset{Base: pair.Base.Item, Quote: pair.Quote.Item}] +} + +// urlValsFromDateRange encodes a set of parameters indicating start and end dates +func urlValsFromDateRange(startDate, endDate time.Time, labelStart, labelEnd string) (url.Values, error) { + values := url.Values{} + if err := common.StartEndTimeCheck(startDate, endDate); err != nil { + if errors.Is(err, common.ErrDateUnset) { + return values, nil + } + return nil, err + } + if labelStart == "" || labelEnd == "" { + return nil, errDateLabelEmpty + } + values.Set(labelStart, startDate.Format(time.RFC3339)) + values.Set(labelEnd, endDate.Format(time.RFC3339)) + return values, nil +} + +// urlValsFromPagination formats pagination information in the way the exchange expects +func urlValsFromPagination(pag PaginationInp) url.Values { + values := url.Values{} + if pag.Limit != 0 { + values.Set("limit", strconv.FormatInt(int64(pag.Limit), 10)) + } + if pag.OrderAscend { + values.Set("order", "asc") + } + if pag.StartingAfter != "" { + values.Set("starting_after", pag.StartingAfter) + } + if pag.EndingBefore != "" { + values.Set("ending_before", pag.EndingBefore) + } + return values +} + +// createOrderConfig populates the OrderConfiguration struct +func createOrderConfig(sharedParams *OrderInfo) (OrderConfiguration, error) { + if sharedParams == nil { + return OrderConfiguration{}, fmt.Errorf("%T %w", sharedParams, common.ErrNilPointer) + } + var orderConfig OrderConfiguration + switch sharedParams.OrderType { + case order.Market: + if sharedParams.BaseAmount != 0 { + orderConfig.MarketMarketIOC = &MarketMarketIOC{BaseSize: types.Number(sharedParams.BaseAmount), RFQDisabled: sharedParams.RFQDisabled} + } + if sharedParams.QuoteAmount != 0 { + orderConfig.MarketMarketIOC = &MarketMarketIOC{QuoteSize: types.Number(sharedParams.QuoteAmount), RFQDisabled: sharedParams.RFQDisabled} + } + case order.Limit: + switch { + case sharedParams.TimeInForce == order.StopOrReduce: + orderConfig.SORLimitIOC = &QuoteBaseLimit{BaseSize: types.Number(sharedParams.BaseAmount), QuoteSize: types.Number(sharedParams.QuoteAmount), LimitPrice: types.Number(sharedParams.LimitPrice), RFQDisabled: sharedParams.RFQDisabled} + case sharedParams.TimeInForce == order.FillOrKill: + orderConfig.LimitLimitFOK = &QuoteBaseLimit{BaseSize: types.Number(sharedParams.BaseAmount), QuoteSize: types.Number(sharedParams.QuoteAmount), LimitPrice: types.Number(sharedParams.LimitPrice), RFQDisabled: sharedParams.RFQDisabled} + case sharedParams.EndTime.IsZero(): + orderConfig.LimitLimitGTC = &LimitLimitGTC{LimitPrice: types.Number(sharedParams.LimitPrice), PostOnly: sharedParams.PostOnly, RFQDisabled: sharedParams.RFQDisabled} + if sharedParams.BaseAmount != 0 { + orderConfig.LimitLimitGTC.BaseSize = types.Number(sharedParams.BaseAmount) + } + if sharedParams.QuoteAmount != 0 { + orderConfig.LimitLimitGTC.QuoteSize = types.Number(sharedParams.QuoteAmount) + } + default: + if sharedParams.EndTime.Before(time.Now()) { + return orderConfig, errEndTimeInPast + } + orderConfig.LimitLimitGTD = &LimitLimitGTD{LimitPrice: types.Number(sharedParams.LimitPrice), PostOnly: sharedParams.PostOnly, EndTime: sharedParams.EndTime, RFQDisabled: sharedParams.RFQDisabled} + if sharedParams.BaseAmount != 0 { + orderConfig.LimitLimitGTD.BaseSize = types.Number(sharedParams.BaseAmount) + } + if sharedParams.QuoteAmount != 0 { + orderConfig.LimitLimitGTD.QuoteSize = types.Number(sharedParams.QuoteAmount) + } + } + case order.TWAP: + if sharedParams.EndTime.Before(time.Now()) { + return orderConfig, errEndTimeInPast + } + orderConfig.TWAPLimitGTD = &TWAPLimitGTD{StartTime: time.Now(), EndTime: sharedParams.EndTime, LimitPrice: types.Number(sharedParams.LimitPrice), NumberBuckets: sharedParams.BucketNumber, BucketSize: types.Number(sharedParams.BucketSize), BucketDuration: strconv.FormatFloat(sharedParams.BucketDuration.Seconds(), 'f', -1, 64) + "s"} + case order.StopLimit: + if sharedParams.EndTime.IsZero() { + orderConfig.StopLimitStopLimitGTC = &StopLimitStopLimitGTC{LimitPrice: types.Number(sharedParams.LimitPrice), StopPrice: types.Number(sharedParams.StopPrice), StopDirection: sharedParams.StopDirection} + if sharedParams.BaseAmount != 0 { + orderConfig.StopLimitStopLimitGTC.BaseSize = types.Number(sharedParams.BaseAmount) + } + if sharedParams.QuoteAmount != 0 { + orderConfig.StopLimitStopLimitGTC.QuoteSize = types.Number(sharedParams.QuoteAmount) + } + } else { + if sharedParams.EndTime.Before(time.Now()) { + return orderConfig, errEndTimeInPast + } + orderConfig.StopLimitStopLimitGTD = &StopLimitStopLimitGTD{LimitPrice: types.Number(sharedParams.LimitPrice), StopPrice: types.Number(sharedParams.StopPrice), StopDirection: sharedParams.StopDirection, EndTime: sharedParams.EndTime} + if sharedParams.BaseAmount != 0 { + orderConfig.StopLimitStopLimitGTD.BaseSize = types.Number(sharedParams.BaseAmount) + } + if sharedParams.QuoteAmount != 0 { + orderConfig.StopLimitStopLimitGTD.QuoteSize = types.Number(sharedParams.QuoteAmount) + } + } + case order.Bracket: + if sharedParams.EndTime.IsZero() { + orderConfig.TriggerBracketGTC = &TriggerBracketGTC{BaseSize: types.Number(sharedParams.BaseAmount), LimitPrice: types.Number(sharedParams.LimitPrice), StopTriggerPrice: types.Number(sharedParams.StopPrice)} + } else { + if sharedParams.EndTime.Before(time.Now()) { + return orderConfig, errEndTimeInPast + } + orderConfig.TriggerBracketGTD = &TriggerBracketGTD{BaseSize: types.Number(sharedParams.BaseAmount), LimitPrice: types.Number(sharedParams.LimitPrice), StopTriggerPrice: types.Number(sharedParams.StopPrice), EndTime: sharedParams.EndTime} + } + default: + return orderConfig, errInvalidOrderType + } + return orderConfig, nil +} + +// FormatMarginType properly formats the margin type for the request +func FormatMarginType(marginType string) string { + switch marginType { + case "ISOLATED", "CROSS": + return marginType + case "MULTI": + return "CROSS" + } + return "" +} + +// String implements the stringer interface +func (f FiatTransferType) String() string { + if f { + return "withdrawal" + } + return "deposit" +} + +// UnmarshalJSON unmarshals the JSON data +func (o *Orders) UnmarshalJSON(data []byte) error { + var alias any + err := json.Unmarshal(data, &[3]any{&o.Price, &o.Size, &alias}) + if err != nil { + return err + } + switch a := alias.(type) { + case string: + if o.OrderID, err = uuid.FromString(a); err != nil { + return err + } + o.OrderCount = 1 + case float64: + o.OrderCount = uint64(a) + default: + return common.GetTypeAssertError("string | float64", alias, "Orders[3]") + } + return nil +} + +// UnmarshalJSON unmarshals the JSON data +func (c *Candle) UnmarshalJSON(data []byte) error { + return json.Unmarshal(data, &[6]any{&c.Time, &c.Low, &c.High, &c.Open, &c.Close, &c.Volume}) +} + +// UnmarshalJSON unmarshals the JSON data +func (i *Integer) UnmarshalJSON(data []byte) error { + var temp string + if err := json.Unmarshal(data, &temp); err != nil { + return err + } + if temp == "" { + *i = 0 + return nil + } + value, err := strconv.ParseInt(temp, 10, 64) + if err != nil { + return err + } + *i = Integer(value) + return nil +} diff --git a/exchanges/coinbase/coinbase_test.go b/exchanges/coinbase/coinbase_test.go new file mode 100644 index 00000000..14b72d26 --- /dev/null +++ b/exchanges/coinbase/coinbase_test.go @@ -0,0 +1,2047 @@ +package coinbase + +import ( + "context" + "errors" + "log" + "net/http" + "os" + "strconv" + "strings" + "testing" + "time" + + "github.com/gofrs/uuid" + gws "github.com/gorilla/websocket" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/thrasher-corp/gocryptotrader/common" + "github.com/thrasher-corp/gocryptotrader/config" + "github.com/thrasher-corp/gocryptotrader/currency" + "github.com/thrasher-corp/gocryptotrader/encoding/json" + "github.com/thrasher-corp/gocryptotrader/exchange/websocket" + exchange "github.com/thrasher-corp/gocryptotrader/exchanges" + "github.com/thrasher-corp/gocryptotrader/exchanges/asset" + "github.com/thrasher-corp/gocryptotrader/exchanges/fundingrate" + "github.com/thrasher-corp/gocryptotrader/exchanges/futures" + "github.com/thrasher-corp/gocryptotrader/exchanges/kline" + "github.com/thrasher-corp/gocryptotrader/exchanges/order" + "github.com/thrasher-corp/gocryptotrader/exchanges/sharedtestvalues" + "github.com/thrasher-corp/gocryptotrader/exchanges/subscription" + testexch "github.com/thrasher-corp/gocryptotrader/internal/testing/exchange" + testsubs "github.com/thrasher-corp/gocryptotrader/internal/testing/subscriptions" + "github.com/thrasher-corp/gocryptotrader/portfolio/withdraw" +) + +// Please supply your APIKeys here for better testing +const ( + apiKey = "" + apiSecret = "" + canManipulateRealOrders = false + // Sandbox functionality only works for certain endpoints https://docs.cdp.coinbase.com/coinbase-app/advanced-trade-apis/sandbox + testingInSandbox = false +) + +var ( + e = &Exchange{} + testCrypto = currency.BTC + testFiat = currency.USD + testStable = currency.USDC + testWrappedAsset = currency.CBETH + testPairFiat = currency.NewPairWithDelimiter(testCrypto.String(), testFiat.String(), "-") + testPairStable = currency.NewPairWithDelimiter(testCrypto.String(), testStable.String(), "-") +) + +// Constants used within tests +const ( + testAddress = "fake address" + testAmount = 1e-08 + testAmount2 = 1e-02 + testAmount3 = 1 + testPrice = 1.5e+05 + + skipPayMethodNotFound = "no payment methods found, skipping" + skipInsufSuitableAccs = "insufficient suitable accounts for test, skipping" + skipInsufficientFunds = "insufficient funds for test, skipping" + skipInsufficientOrders = "insufficient orders for test, skipping" + skipInsufficientPortfolios = "insufficient portfolios for test, skipping" + skipInsufficientWallets = "insufficient wallets for test, skipping" + skipInsufficientFundsOrWallets = "insufficient funds or wallets for test, skipping" + skipInsufficientTransactions = "insufficient transactions for test, skipping" + + errExpectMismatch = "received: '%v' but expected: '%v'" + errExpectedNonEmpty = "expected non-empty response" + errInvalidProductID = `Coinbase unsuccessful HTTP status code: 404 raw response: {"error":"NOT_FOUND","error_details":"valid product_id is required","message":"valid product_id is required"}` + errExpectedFeeRange = "expected fee range of %v and %v, received %v" + errOptionInvalid = `Coinbase unsuccessful HTTP status code: 400 raw response: {"error":"unknown","error_details":"parsing field \"product_type\": \"OPTIONS\" is not a valid value","message":"parsing field \"product_type\": \"OPTIONS\" is not a valid value"}` + errJSONUnmarshalUnexpected = "JSON umarshalling did not return expected error" +) + +func TestMain(m *testing.M) { + if err := exchangeBaseHelper(e); err != nil { + log.Fatal(err) + } + if testingInSandbox { + err := e.API.Endpoints.SetDefaultEndpoints(map[exchange.URL]string{ + exchange.RestSpot: sandboxAPIURL, + }) + if err != nil { + log.Fatalf("Coinbase SetDefaultEndpoints sandbox error: %s", err) + } + } + os.Exit(m.Run()) +} + +func TestSetup(t *testing.T) { + cfg, err := e.GetStandardConfig() + assert.NoError(t, err) + exch := &Exchange{} + err = exchangeBaseHelper(exch) + require.NoError(t, err) + cfg.ProxyAddress = string(rune(0x7f)) + err = exch.Setup(cfg) + assert.ErrorIs(t, err, exchange.ErrSettingProxyAddress) +} + +func TestWsConnect(t *testing.T) { + sharedtestvalues.SkipTestIfCredentialsUnset(t, e) + exch := &Exchange{} + exch.Websocket = sharedtestvalues.NewTestWebsocket() + err := exch.WsConnect() + assert.ErrorIs(t, err, websocket.ErrWebsocketNotEnabled) + err = exchangeBaseHelper(exch) + require.NoError(t, err) + err = exch.Websocket.Enable() + assert.NoError(t, err) +} + +func TestGetAccountByID(t *testing.T) { + t.Parallel() + _, err := e.GetAccountByID(t.Context(), "") + assert.ErrorIs(t, err, errAccountIDEmpty) + sharedtestvalues.SkipTestIfCredentialsUnset(t, e) + longResp, err := e.ListAccounts(t.Context(), 49, 0) + assert.NoError(t, err) + require.True(t, longResp != nil && len(longResp.Accounts) > 0, errExpectedNonEmpty) + shortResp, err := e.GetAccountByID(t.Context(), longResp.Accounts[0].UUID) + assert.NoError(t, err) + if *shortResp != longResp.Accounts[0] { + t.Errorf(errExpectMismatch, shortResp, longResp.Accounts[0]) + } +} + +func TestListAccounts(t *testing.T) { + t.Parallel() + sharedtestvalues.SkipTestIfCredentialsUnset(t, e) + resp, err := e.ListAccounts(t.Context(), 50, 0) + require.NoError(t, err) + assert.NotEmpty(t, resp, errExpectedNonEmpty) +} + +func TestCreateConvertQuote(t *testing.T) { + t.Parallel() + _, err := e.CreateConvertQuote(t.Context(), "", "", "", "", 0) + assert.ErrorIs(t, err, errAccountIDEmpty) + _, err = e.CreateConvertQuote(t.Context(), "meow", "123", "", "", 0) + assert.ErrorIs(t, err, order.ErrAmountIsInvalid) + sharedtestvalues.SkipTestIfCredentialsUnset(t, e, canManipulateRealOrders) + fromAccID, toAccID := convertTestHelper(t) + resp, err := e.CreateConvertQuote(t.Context(), fromAccID, toAccID, "", "", 0.01) + require.NoError(t, err) + assert.NotEmpty(t, resp, errExpectedNonEmpty) +} + +func TestCommitConvertTrade(t *testing.T) { + convertTestShared(t, e.CommitConvertTrade) +} + +func TestGetConvertTradeByID(t *testing.T) { + convertTestShared(t, e.GetConvertTradeByID) +} + +func TestGetPermissions(t *testing.T) { + t.Parallel() + sharedtestvalues.SkipTestIfCredentialsUnset(t, e) + resp, err := e.GetPermissions(t.Context()) + require.NoError(t, err) + assert.NotEmpty(t, resp, errExpectedNonEmpty) +} + +func TestGetTransactionSummary(t *testing.T) { + t.Parallel() + _, err := e.GetTransactionSummary(t.Context(), time.Unix(2, 2), time.Unix(1, 1), "", "", "") + assert.ErrorIs(t, err, common.ErrStartAfterEnd) + sharedtestvalues.SkipTestIfCredentialsUnset(t, e) + resp, err := e.GetTransactionSummary(t.Context(), time.Unix(1, 1), time.Now(), "UNKNOWN_VENUE_TYPE", asset.Spot.Upper(), "UNKNOWN_CONTRACT_EXPIRY_TYPE") + require.NoError(t, err) + assert.NotEmpty(t, resp, errExpectedNonEmpty) +} + +func TestCancelPendingFuturesSweep(t *testing.T) { + sharedtestvalues.SkipTestIfCredentialsUnset(t, e, canManipulateRealOrders) + _, err := e.CancelPendingFuturesSweep(t.Context()) + assert.NoError(t, err) +} + +func TestGetCurrentMarginWindow(t *testing.T) { + t.Parallel() + _, err := e.GetCurrentMarginWindow(t.Context(), "") + assert.ErrorIs(t, err, errMarginProfileTypeEmpty) + sharedtestvalues.SkipTestIfCredentialsUnset(t, e) + resp, err := e.GetCurrentMarginWindow(t.Context(), "MARGIN_PROFILE_TYPE_RETAIL_REGULAR") + require.NoError(t, err) + assert.NotEmpty(t, resp, errExpectedNonEmpty) +} + +func TestGetFuturesBalanceSummary(t *testing.T) { + t.Parallel() + sharedtestvalues.SkipTestIfCredentialsUnset(t, e) + _, err := e.GetFuturesBalanceSummary(t.Context()) + assert.NoError(t, err) +} + +func TestGetFuturesPositionByID(t *testing.T) { + t.Parallel() + _, err := e.GetFuturesPositionByID(t.Context(), currency.Pair{}) + assert.ErrorIs(t, err, errProductIDEmpty) + sharedtestvalues.SkipTestIfCredentialsUnset(t, e) + _, err = e.GetFuturesPositionByID(t.Context(), currency.NewPairWithDelimiter("SLR-25NOV25", "CDE", "-")) + assert.NoError(t, err) +} + +func TestGetIntradayMarginSetting(t *testing.T) { + t.Parallel() + sharedtestvalues.SkipTestIfCredentialsUnset(t, e) + _, err := e.GetIntradayMarginSetting(t.Context()) + assert.NoError(t, err) +} + +func TestListFuturesPositions(t *testing.T) { + t.Parallel() + sharedtestvalues.SkipTestIfCredentialsUnset(t, e) + _, err := e.ListFuturesPositions(t.Context()) + assert.NoError(t, err) +} + +func TestListFuturesSweeps(t *testing.T) { + t.Parallel() + sharedtestvalues.SkipTestIfCredentialsUnset(t, e) + _, err := e.ListFuturesSweeps(t.Context()) + assert.NoError(t, err) +} + +func TestScheduleFuturesSweep(t *testing.T) { + sharedtestvalues.SkipTestIfCredentialsUnset(t, e, canManipulateRealOrders) + _, err := e.ScheduleFuturesSweep(t.Context(), 0.001337) + assert.NoError(t, err) +} + +func TestSetIntradayMarginSetting(t *testing.T) { + t.Parallel() + err := e.SetIntradayMarginSetting(t.Context(), "") + assert.ErrorIs(t, err, errSettingEmpty) + sharedtestvalues.SkipTestIfCredentialsUnset(t, e, canManipulateRealOrders) + err = e.SetIntradayMarginSetting(t.Context(), "INTRADAY_MARGIN_SETTING_STANDARD") + assert.NoError(t, err) +} + +func TestCancelOrders(t *testing.T) { + t.Parallel() + var orderSlice []string + _, err := e.CancelOrders(t.Context(), orderSlice) + assert.ErrorIs(t, err, order.ErrOrderIDNotSet) + orderSlice = make([]string, 200) + for i := range 200 { + orderSlice[i] = strconv.Itoa(i) + } + _, err = e.CancelOrders(t.Context(), orderSlice) + assert.ErrorIs(t, err, errCancelLimitExceeded) + sharedtestvalues.SkipTestIfCredentialsUnset(t, e, canManipulateRealOrders) + orderSlice = []string{"1"} + resp, err := e.CancelOrders(t.Context(), orderSlice) + require.NoError(t, err) + assert.NotEmpty(t, resp, errExpectedNonEmpty) +} + +func TestClosePosition(t *testing.T) { + t.Parallel() + _, err := e.ClosePosition(t.Context(), "", currency.Pair{}, 0) + assert.ErrorIs(t, err, order.ErrClientOrderIDMustBeSet) + _, err = e.ClosePosition(t.Context(), "meow", currency.Pair{}, 0) + assert.ErrorIs(t, err, errProductIDEmpty) + _, err = e.ClosePosition(t.Context(), "meow", testPairFiat, 0) + assert.ErrorIs(t, err, order.ErrAmountIsInvalid) + sharedtestvalues.SkipTestIfCredentialsUnset(t, e, canManipulateRealOrders) + resp, err := e.ClosePosition(t.Context(), "1", currency.NewPairWithDelimiter("BIT", "31OCT25-CDE", "-"), testAmount) + require.NoError(t, err) + assert.NotEmpty(t, resp, errExpectedNonEmpty) +} + +func TestPlaceOrder(t *testing.T) { + t.Parallel() + ord := &PlaceOrderInfo{} + _, err := e.PlaceOrder(t.Context(), ord) + assert.ErrorIs(t, err, order.ErrClientOrderIDMustBeSet) + ord.ClientOID = "meow" + _, err = e.PlaceOrder(t.Context(), ord) + assert.ErrorIs(t, err, errProductIDEmpty) + ord.ProductID = testPairFiat.String() + _, err = e.PlaceOrder(t.Context(), ord) + assert.ErrorIs(t, err, order.ErrAmountIsInvalid) + ord.BaseAmount = testAmount + _, err = e.PlaceOrder(t.Context(), ord) + assert.ErrorIs(t, err, errInvalidOrderType) + sharedtestvalues.SkipTestIfCredentialsUnset(t, e, canManipulateRealOrders) + id, err := uuid.NewV4() + assert.NoError(t, err) + ord = &PlaceOrderInfo{ + ClientOID: id.String(), + ProductID: testPairStable.String(), + Side: order.Buy.String(), + MarginType: "CROSS", + Leverage: 9999, + OrderInfo: OrderInfo{ + PostOnly: false, + EndTime: time.Now().Add(time.Hour), + OrderType: order.Limit, + BaseAmount: testAmount, + LimitPrice: testPrice, + }, + } + resp, err := e.PlaceOrder(t.Context(), ord) + if assert.NoError(t, err) { + assert.NotEmpty(t, resp, errExpectedNonEmpty) + } + id, err = uuid.NewV4() + assert.NoError(t, err) + ord.ClientOID = id.String() + ord.MarginType = "MULTI" + resp, err = e.PlaceOrder(t.Context(), ord) + require.NoError(t, err) + assert.NotEmpty(t, resp, errExpectedNonEmpty) +} + +func TestEditOrder(t *testing.T) { + t.Parallel() + _, err := e.EditOrder(t.Context(), "", 0, 0) + assert.ErrorIs(t, err, order.ErrOrderIDNotSet) + _, err = e.EditOrder(t.Context(), "meow", 0, 0) + assert.ErrorIs(t, err, errSizeAndPriceZero) + sharedtestvalues.SkipTestIfCredentialsUnset(t, e, canManipulateRealOrders) + resp, err := e.EditOrder(t.Context(), "1", testAmount, testPrice-1) + require.NoError(t, err) + assert.NotEmpty(t, resp, errExpectedNonEmpty) +} + +func TestEditOrderPreview(t *testing.T) { + t.Parallel() + _, err := e.EditOrderPreview(t.Context(), "", 0, 0) + assert.ErrorIs(t, err, order.ErrOrderIDNotSet) + _, err = e.EditOrderPreview(t.Context(), "meow", 0, 0) + assert.ErrorIs(t, err, errSizeAndPriceZero) + sharedtestvalues.SkipTestIfCredentialsUnset(t, e) + resp, err := e.EditOrderPreview(t.Context(), "1", testAmount, testPrice+2) + require.NoError(t, err) + assert.NotEmpty(t, resp, errExpectedNonEmpty) +} + +func TestGetOrderByID(t *testing.T) { + t.Parallel() + _, err := e.GetOrderByID(t.Context(), "", "", currency.Code{}) + assert.ErrorIs(t, err, order.ErrOrderIDNotSet) + sharedtestvalues.SkipTestIfCredentialsUnset(t, e) + ordID, err := e.ListOrders(t.Context(), &ListOrdersReq{Limit: 10}) + assert.NoError(t, err) + if ordID == nil || len(ordID.Orders) == 0 { + t.Skip(skipInsufficientOrders) + } + resp, err := e.GetOrderByID(t.Context(), ordID.Orders[0].OrderID, ordID.Orders[0].ClientOID, testFiat) + require.NoError(t, err) + assert.NotEmpty(t, resp, errExpectedNonEmpty) +} + +func TestListFills(t *testing.T) { + t.Parallel() + _, err := e.ListFills(t.Context(), nil, nil, nil, 0, "", time.Unix(2, 2), time.Unix(1, 1), 0) + assert.ErrorIs(t, err, common.ErrStartAfterEnd) + sharedtestvalues.SkipTestIfCredentialsUnset(t, e) + _, err = e.ListFills(t.Context(), nil, nil, currency.Pairs{testPairFiat, testPairStable}, 0, "TRADE_TIME", time.Time{}, time.Time{}, 0) + assert.NoError(t, err) + _, err = e.ListFills(t.Context(), []string{"1", "2"}, nil, nil, 0, "", time.Time{}, time.Time{}, 0) + assert.NoError(t, err) + _, err = e.ListFills(t.Context(), nil, []string{"3", "4"}, nil, 0, "", time.Time{}, time.Time{}, 0) + assert.NoError(t, err) +} + +func TestListOrders(t *testing.T) { + t.Parallel() + _, err := e.ListOrders(t.Context(), &ListOrdersReq{ + StartDate: time.Unix(2, 2), + EndDate: time.Unix(1, 1), + }) + assert.ErrorIs(t, err, common.ErrStartAfterEnd) + sharedtestvalues.SkipTestIfCredentialsUnset(t, e) + orderStatus := []string{"FILLED", "CANCELLED", "EXPIRED", "FAILED"} + orderTypes := []string{"MARKET", "LIMIT", "STOP", "STOP_LIMIT", "BRACKET", "TWAP"} + timeInForces := []string{"GOOD_UNTIL_DATE_TIME", "GOOD_UNTIL_CANCELLED", "IMMEDIATE_OR_CANCEL", "FILL_OR_KILL"} + productIDs := currency.Pairs{testPairFiat, testPairStable} + _, err = e.ListOrders(t.Context(), &ListOrdersReq{ + OrderStatus: orderStatus, + TimeInForces: timeInForces, + OrderTypes: orderTypes, + ProductIDs: productIDs, + OrderSide: "BUY", + OrderPlacementSource: "RETAIL_SIMPLE", + ContractExpiryType: "PERPETUAL", + SortBy: "LAST_FILL_TIME", + }) + assert.NoError(t, err) + resp, err := e.ListOrders(t.Context(), &ListOrdersReq{}) + assert.NoError(t, err) + if resp == nil || len(resp.Orders) == 0 { + t.Skip(skipInsufficientOrders) + } + orderIDs := []string{resp.Orders[0].OrderID} + _, err = e.ListOrders(t.Context(), &ListOrdersReq{ + OrderIDs: orderIDs, + }) + assert.NoError(t, err) +} + +func TestPreviewOrder(t *testing.T) { + t.Parallel() + inf := &PreviewOrderInfo{} + _, err := e.PreviewOrder(t.Context(), inf) + assert.ErrorIs(t, err, order.ErrAmountIsInvalid) + inf.BaseAmount = 1 + _, err = e.PreviewOrder(t.Context(), inf) + assert.ErrorIs(t, err, errInvalidOrderType) + sharedtestvalues.SkipTestIfCredentialsUnset(t, e) + inf.ProductID = testPairStable.String() + inf.Side = "BUY" + inf.OrderType = order.Market + inf.MarginType = "ISOLATED" + inf.BaseAmount = testAmount + resp, err := e.PreviewOrder(t.Context(), inf) + require.NoError(t, err) + assert.NotEmpty(t, resp, errExpectedNonEmpty) +} + +func TestGetPaymentMethodByID(t *testing.T) { + t.Parallel() + _, err := e.GetPaymentMethodByID(t.Context(), "") + assert.ErrorIs(t, err, errPaymentMethodEmpty) + sharedtestvalues.SkipTestIfCredentialsUnset(t, e) + pmID, err := e.ListPaymentMethods(t.Context()) + assert.NoError(t, err) + if len(pmID) == 0 { + t.Skip(skipPayMethodNotFound) + } + resp, err := e.GetPaymentMethodByID(t.Context(), pmID[0].ID) + require.NoError(t, err) + assert.NotEmpty(t, resp, errExpectedNonEmpty) +} + +func TestListPaymentMethods(t *testing.T) { + t.Parallel() + sharedtestvalues.SkipTestIfCredentialsUnset(t, e) + testGetNoArgs(t, e.ListPaymentMethods) +} + +func TestAllocatePortfolio(t *testing.T) { + t.Parallel() + err := e.AllocatePortfolio(t.Context(), "", "", "", 0) + assert.ErrorIs(t, err, errPortfolioIDEmpty) + err = e.AllocatePortfolio(t.Context(), "meow", "", "", 0) + assert.ErrorIs(t, err, errProductIDEmpty) + err = e.AllocatePortfolio(t.Context(), "meow", "bark", "", 0) + assert.ErrorIs(t, err, currency.ErrCurrencyCodeEmpty) + err = e.AllocatePortfolio(t.Context(), "meow", "bark", "woof", 0) + assert.ErrorIs(t, err, order.ErrAmountIsInvalid) + sharedtestvalues.SkipTestIfCredentialsUnset(t, e, canManipulateRealOrders) + pID := getINTXPortfolio(t) + err = e.AllocatePortfolio(t.Context(), pID, testCrypto.String(), testFiat.String(), 0.001337) + assert.NoError(t, err) +} + +func TestGetPerpetualsPortfolioSummary(t *testing.T) { + t.Parallel() + _, err := e.GetPerpetualsPortfolioSummary(t.Context(), "") + assert.ErrorIs(t, err, errPortfolioIDEmpty) + pID := getINTXPortfolio(t) + resp, err := e.GetPerpetualsPortfolioSummary(t.Context(), pID) + require.NoError(t, err) + assert.NotEmpty(t, resp, errExpectedNonEmpty) +} + +func TestGetPerpetualsPositionByID(t *testing.T) { + t.Parallel() + _, err := e.GetPerpetualsPositionByID(t.Context(), "", currency.Pair{}) + assert.ErrorIs(t, err, errPortfolioIDEmpty) + _, err = e.GetPerpetualsPositionByID(t.Context(), "meow", currency.Pair{}) + assert.ErrorIs(t, err, errProductIDEmpty) + pID := getINTXPortfolio(t) + _, err = e.GetPerpetualsPositionByID(t.Context(), pID, testPairFiat) + assert.NoError(t, err) +} + +func TestGetPortfolioBalances(t *testing.T) { + t.Parallel() + _, err := e.GetPortfolioBalances(t.Context(), "") + assert.ErrorIs(t, err, errPortfolioIDEmpty) + pID := getINTXPortfolio(t) + resp, err := e.GetPortfolioBalances(t.Context(), pID) + require.NoError(t, err) + assert.NotEmpty(t, resp, errExpectedNonEmpty) +} + +func TestGetAllPerpetualsPositions(t *testing.T) { + t.Parallel() + _, err := e.GetAllPerpetualsPositions(t.Context(), "") + assert.ErrorIs(t, err, errPortfolioIDEmpty) + pID := getINTXPortfolio(t) + _, err = e.GetAllPerpetualsPositions(t.Context(), pID) + assert.NoError(t, err) +} + +func TestMultiAssetCollateralToggle(t *testing.T) { + t.Parallel() + _, err := e.MultiAssetCollateralToggle(t.Context(), "", false) + assert.ErrorIs(t, err, errPortfolioIDEmpty) + sharedtestvalues.SkipTestIfCredentialsUnset(t, e) + pID := getINTXPortfolio(t) + _, err = e.MultiAssetCollateralToggle(t.Context(), pID, false) + assert.NoError(t, err) +} + +func TestCreatePortfolio(t *testing.T) { + t.Parallel() + _, err := e.CreatePortfolio(t.Context(), "") + assert.ErrorIs(t, err, errNameEmpty) + sharedtestvalues.SkipTestIfCredentialsUnset(t, e, canManipulateRealOrders) + _, err = e.CreatePortfolio(t.Context(), "GCT Test Portfolio") + assert.NoError(t, err) +} + +func TestDeletePortfolio(t *testing.T) { + t.Parallel() + err := e.DeletePortfolio(t.Context(), "") + assert.ErrorIs(t, err, errPortfolioIDEmpty) + sharedtestvalues.SkipTestIfCredentialsUnset(t, e, canManipulateRealOrders) + err = e.DeletePortfolio(t.Context(), "insert portfolio ID here") + // The new JWT-based keys only have permissions to delete portfolios they're assigned to, causing this to fail + assert.NoError(t, err) +} + +func TestEditPortfolio(t *testing.T) { + t.Parallel() + _, err := e.EditPortfolio(t.Context(), "", "") + assert.ErrorIs(t, err, errPortfolioIDEmpty) + _, err = e.EditPortfolio(t.Context(), "meow", "") + assert.ErrorIs(t, err, errNameEmpty) + sharedtestvalues.SkipTestIfCredentialsUnset(t, e, canManipulateRealOrders) + _, err = e.EditPortfolio(t.Context(), "insert portfolio ID here", "GCT Test Portfolio Edited") + // The new JWT-based keys only have permissions to edit portfolios they're assigned to, causing this to fail + assert.NoError(t, err) +} + +func TestGetPortfolioByID(t *testing.T) { + t.Parallel() + _, err := e.GetPortfolioByID(t.Context(), "") + assert.ErrorIs(t, err, errPortfolioIDEmpty) + sharedtestvalues.SkipTestIfCredentialsUnset(t, e) + portID, err := e.GetAllPortfolios(t.Context(), "") + assert.NoError(t, err) + if len(portID) == 0 { + t.Fatal(errExpectedNonEmpty) + } + resp, err := e.GetPortfolioByID(t.Context(), portID[0].UUID) + assert.NoError(t, err) + if resp.Portfolio != portID[0] { + t.Errorf(errExpectMismatch, resp.Portfolio, portID[0]) + } +} + +func TestGetAllPortfolios(t *testing.T) { + t.Parallel() + sharedtestvalues.SkipTestIfCredentialsUnset(t, e) + resp, err := e.GetAllPortfolios(t.Context(), "DEFAULT") + require.NoError(t, err) + assert.NotEmpty(t, resp, errExpectedNonEmpty) +} + +func TestMovePortfolioFunds(t *testing.T) { + t.Parallel() + _, err := e.MovePortfolioFunds(t.Context(), currency.Code{}, "", "", 0) + assert.ErrorIs(t, err, errPortfolioIDEmpty) + _, err = e.MovePortfolioFunds(t.Context(), currency.Code{}, "meowPort", "woofPort", 0) + assert.ErrorIs(t, err, currency.ErrCurrencyCodeEmpty) + _, err = e.MovePortfolioFunds(t.Context(), testCrypto, "meowPort", "woofPort", 0) + assert.ErrorIs(t, err, order.ErrAmountIsInvalid) + sharedtestvalues.SkipTestIfCredentialsUnset(t, e, canManipulateRealOrders) + portID, err := e.GetAllPortfolios(t.Context(), "") + assert.NoError(t, err) + if len(portID) < 2 { + t.Skip(skipInsufficientPortfolios) + } + _, err = e.MovePortfolioFunds(t.Context(), testCrypto, portID[0].UUID, portID[1].UUID, testAmount) + assert.NoError(t, err) +} + +func TestGetBestBidAsk(t *testing.T) { + t.Parallel() + sharedtestvalues.SkipTestIfCredentialsUnset(t, e) + testPairs := []string{testPairFiat.String(), "ETH-USD"} + resp, err := e.GetBestBidAsk(t.Context(), testPairs) + require.NoError(t, err) + assert.NotEmpty(t, resp, errExpectedNonEmpty) +} + +func TestGetTicker(t *testing.T) { + t.Parallel() + _, err := e.GetTicker(t.Context(), currency.Pair{}, 1, time.Time{}, time.Time{}, false) + assert.ErrorIs(t, err, errProductIDEmpty) + resp, err := e.GetTicker(t.Context(), testPairFiat, 5, time.Now().Add(-time.Minute*5), time.Now(), false) + if assert.NoError(t, err) { + assert.NotEmpty(t, resp, errExpectedNonEmpty) + } + sharedtestvalues.SkipTestIfCredentialsUnset(t, e) + resp, err = e.GetTicker(t.Context(), testPairFiat, 5, time.Now().Add(-time.Minute*5), time.Now(), true) + require.NoError(t, err) + assert.NotEmpty(t, resp, errExpectedNonEmpty) +} + +func TestGetProductByID(t *testing.T) { + t.Parallel() + _, err := e.GetProductByID(t.Context(), currency.Pair{}, false) + assert.ErrorIs(t, err, errProductIDEmpty) + resp, err := e.GetProductByID(t.Context(), testPairFiat, false) + if assert.NoError(t, err) { + assert.NotEmpty(t, resp, errExpectedNonEmpty) + } + sharedtestvalues.SkipTestIfCredentialsUnset(t, e) + resp, err = e.GetProductByID(t.Context(), testPairFiat, true) + require.NoError(t, err) + assert.NotEmpty(t, resp, errExpectedNonEmpty) +} + +func TestGetProductBookV3(t *testing.T) { + t.Parallel() + _, err := e.GetProductBookV3(t.Context(), currency.Pair{}, 0, 0, false) + assert.ErrorIs(t, err, errProductIDEmpty) + resp, err := e.GetProductBookV3(t.Context(), testPairFiat, 4, -1, false) + if assert.NoError(t, err) { + assert.NotEmpty(t, resp, errExpectedNonEmpty) + } + sharedtestvalues.SkipTestIfCredentialsUnset(t, e) + resp, err = e.GetProductBookV3(t.Context(), testPairFiat, 4, -1, true) + require.NoError(t, err) + assert.NotEmpty(t, resp, errExpectedNonEmpty) +} + +func TestGetHistoricKlines(t *testing.T) { + t.Parallel() + _, err := e.GetHistoricKlines(t.Context(), "", kline.Raw, time.Time{}, time.Time{}, false) + assert.ErrorIs(t, err, errProductIDEmpty) + _, err = e.GetHistoricKlines(t.Context(), testPairFiat.String(), kline.Raw, time.Time{}, time.Time{}, false) + assert.ErrorIs(t, err, kline.ErrUnsupportedInterval) + resp, err := e.GetHistoricKlines(t.Context(), testPairFiat.String(), kline.OneMin, time.Now().Add(-5*time.Minute), time.Now(), false) + if assert.NoError(t, err) { + assert.NotEmpty(t, resp, errExpectedNonEmpty) + } + sharedtestvalues.SkipTestIfCredentialsUnset(t, e) + resp, err = e.GetHistoricKlines(t.Context(), testPairFiat.String(), kline.OneMin, time.Now().Add(-5*time.Minute), time.Now(), true) + require.NoError(t, err) + assert.NotEmpty(t, resp, errExpectedNonEmpty) +} + +func TestGetAllProducts(t *testing.T) { + t.Parallel() + testPairs := []string{testPairFiat.String(), "ETH-USD"} + resp, err := e.GetAllProducts(t.Context(), 30000, 1, "SPOT", "PERPETUAL", "STATUS_ALL", "PRODUCTS_SORT_ORDER_UNDEFINED", testPairs, true, true, false) + if assert.NoError(t, err) { + assert.NotEmpty(t, resp, errExpectedNonEmpty) + } + sharedtestvalues.SkipTestIfCredentialsUnset(t, e) + resp, err = e.GetAllProducts(t.Context(), 0, 1, "SPOT", "PERPETUAL", "STATUS_ALL", "", nil, true, true, true) + require.NoError(t, err) + assert.NotEmpty(t, resp, errExpectedNonEmpty) +} + +func TestGetV3Time(t *testing.T) { + t.Parallel() + testGetNoArgs(t, e.GetV3Time) +} + +func TestSendMoney(t *testing.T) { + t.Parallel() + _, err := e.SendMoney(t.Context(), "", "", "", "", "", "", "", currency.Code{}, 0, false, &TravelRule{}) + assert.ErrorIs(t, err, errTransactionTypeEmpty) + _, err = e.SendMoney(t.Context(), "123", "", "", "", "", "", "", currency.Code{}, 0, false, &TravelRule{}) + assert.ErrorIs(t, err, errWalletIDEmpty) + _, err = e.SendMoney(t.Context(), "123", "123", "", "", "", "", "", currency.Code{}, 0, false, &TravelRule{}) + assert.ErrorIs(t, err, errToEmpty) + _, err = e.SendMoney(t.Context(), "123", "123", "123", "", "", "", "", currency.Code{}, 0, false, &TravelRule{}) + assert.ErrorIs(t, err, order.ErrAmountIsInvalid) + _, err = e.SendMoney(t.Context(), "123", "123", "123", "", "", "", "", currency.Code{}, 1, false, &TravelRule{}) + assert.ErrorIs(t, err, currency.ErrCurrencyCodeEmpty) + sharedtestvalues.SkipTestIfCredentialsUnset(t, e, canManipulateRealOrders) + wID, err := e.GetAllWallets(t.Context(), PaginationInp{}) + assert.NoError(t, err) + if wID == nil || len(wID.Data) < 2 { + t.Skip(skipInsufficientWallets) + } + var ( + fromID string + toID string + ) + for i := range wID.Data { + if wID.Data[i].Currency.Name == testCrypto.String() { + if wID.Data[i].Balance.Amount > testAmount*100 { + fromID = wID.Data[i].ID + } else { + toID = wID.Data[i].ID + } + } + if fromID != "" && toID != "" { + break + } + } + if fromID == "" || toID == "" { + t.Skip(skipInsufficientFundsOrWallets) + } + resp, err := e.SendMoney(t.Context(), "transfer", wID.Data[0].ID, wID.Data[1].ID, "GCT Test", "123", "", "", testCrypto, testAmount, false, &TravelRule{}) + require.NoError(t, err) + assert.NotEmpty(t, resp, errExpectedNonEmpty) +} + +func TestCreateAddress(t *testing.T) { + t.Parallel() + _, err := e.CreateAddress(t.Context(), "", "") + assert.ErrorIs(t, err, errWalletIDEmpty) + sharedtestvalues.SkipTestIfCredentialsUnset(t, e, canManipulateRealOrders) + wID, err := e.GetWalletByID(t.Context(), "", testCrypto) + require.NoError(t, err) + require.NotEmpty(t, wID, errExpectedNonEmpty) + resp, err := e.CreateAddress(t.Context(), wID.ID, "") + require.NoError(t, err) + assert.NotEmpty(t, resp, errExpectedNonEmpty) +} + +func TestGetAllAddresses(t *testing.T) { + t.Parallel() + var pag PaginationInp + _, err := e.GetAllAddresses(t.Context(), "", pag) + assert.ErrorIs(t, err, errWalletIDEmpty) + sharedtestvalues.SkipTestIfCredentialsUnset(t, e) + wID, err := e.GetWalletByID(t.Context(), "", testCrypto) + require.NoError(t, err) + require.NotEmpty(t, wID, errExpectedNonEmpty) + resp, err := e.GetAllAddresses(t.Context(), wID.ID, pag) + require.NoError(t, err) + assert.NotEmpty(t, resp, errExpectedNonEmpty) +} + +func TestGetAddressByID(t *testing.T) { + t.Parallel() + _, err := e.GetAddressByID(t.Context(), "", "") + assert.ErrorIs(t, err, errWalletIDEmpty) + _, err = e.GetAddressByID(t.Context(), "123", "") + assert.ErrorIs(t, err, errAddressIDEmpty) + sharedtestvalues.SkipTestIfCredentialsUnset(t, e) + wID, err := e.GetWalletByID(t.Context(), "", testCrypto) + require.NoError(t, err) + require.NotEmpty(t, wID, errExpectedNonEmpty) + addID, err := e.GetAllAddresses(t.Context(), wID.ID, PaginationInp{}) + require.NoError(t, err) + require.NotEmpty(t, addID, errExpectedNonEmpty) + resp, err := e.GetAddressByID(t.Context(), wID.ID, addID.Data[0].ID) + require.NoError(t, err) + assert.NotEmpty(t, resp, errExpectedNonEmpty) +} + +func TestGetAddressTransactions(t *testing.T) { + t.Parallel() + _, err := e.GetAddressTransactions(t.Context(), "", "", PaginationInp{}) + assert.ErrorIs(t, err, errWalletIDEmpty) + _, err = e.GetAddressTransactions(t.Context(), "123", "", PaginationInp{}) + assert.ErrorIs(t, err, errAddressIDEmpty) + sharedtestvalues.SkipTestIfCredentialsUnset(t, e) + wID, err := e.GetWalletByID(t.Context(), "", testCrypto) + require.NoError(t, err) + require.NotEmpty(t, wID, errExpectedNonEmpty) + addID, err := e.GetAllAddresses(t.Context(), wID.ID, PaginationInp{}) + require.NoError(t, err) + require.NotEmpty(t, addID, errExpectedNonEmpty) + _, err = e.GetAddressTransactions(t.Context(), wID.ID, addID.Data[0].ID, PaginationInp{}) + assert.NoError(t, err) +} + +func TestFiatTransfer(t *testing.T) { + t.Parallel() + _, err := e.FiatTransfer(t.Context(), "", "", "", 0, false, FiatDeposit) + assert.ErrorIs(t, err, errWalletIDEmpty) + _, err = e.FiatTransfer(t.Context(), "123", "", "", 0, false, FiatDeposit) + assert.ErrorIs(t, err, order.ErrAmountIsInvalid) + _, err = e.FiatTransfer(t.Context(), "123", "", "", 1, false, FiatDeposit) + assert.ErrorIs(t, err, currency.ErrCurrencyCodeEmpty) + _, err = e.FiatTransfer(t.Context(), "123", "123", "", 1, false, FiatDeposit) + assert.ErrorIs(t, err, errPaymentMethodEmpty) + sharedtestvalues.SkipTestIfCredentialsUnset(t, e, canManipulateRealOrders) + wallets, err := e.GetAllWallets(t.Context(), PaginationInp{}) + require.NoError(t, err) + assert.NotEmpty(t, wallets, errExpectedNonEmpty) + wID, pmID := transferTestHelper(t, wallets) + resp, err := e.FiatTransfer(t.Context(), wID, testFiat.String(), pmID, testAmount, false, FiatDeposit) + if assert.NoError(t, err) { + assert.NotEmpty(t, resp, errExpectedNonEmpty) + } + resp, err = e.FiatTransfer(t.Context(), wID, testFiat.String(), pmID, testAmount, false, FiatWithdrawal) + require.NoError(t, err) + assert.NotEmpty(t, resp, errExpectedNonEmpty) +} + +func TestCommitTransfer(t *testing.T) { + t.Parallel() + _, err := e.CommitTransfer(t.Context(), "", "", FiatDeposit) + assert.ErrorIs(t, err, errWalletIDEmpty) + _, err = e.CommitTransfer(t.Context(), "123", "", FiatDeposit) + assert.ErrorIs(t, err, errDepositIDEmpty) + sharedtestvalues.SkipTestIfCredentialsUnset(t, e, canManipulateRealOrders) + wallets, err := e.GetAllWallets(t.Context(), PaginationInp{}) + require.NoError(t, err) + assert.NotEmpty(t, wallets, errExpectedNonEmpty) + wID, pmID := transferTestHelper(t, wallets) + depID, err := e.FiatTransfer(t.Context(), wID, testFiat.String(), pmID, testAmount, false, FiatDeposit) + require.NoError(t, err) + resp, err := e.CommitTransfer(t.Context(), wID, depID.ID, FiatDeposit) + if assert.NoError(t, err) { + assert.NotEmpty(t, resp, errExpectedNonEmpty) + } + depID, err = e.FiatTransfer(t.Context(), wID, testFiat.String(), pmID, testAmount, false, FiatWithdrawal) + require.NoError(t, err) + resp, err = e.CommitTransfer(t.Context(), wID, depID.ID, FiatWithdrawal) + require.NoError(t, err) + assert.NotEmpty(t, resp, errExpectedNonEmpty) +} + +func TestGetAllFiatTransfers(t *testing.T) { + t.Parallel() + var pag PaginationInp + _, err := e.GetAllFiatTransfers(t.Context(), "", pag, FiatDeposit) + assert.ErrorIs(t, err, errWalletIDEmpty) + sharedtestvalues.SkipTestIfCredentialsUnset(t, e) + wID, err := e.GetWalletByID(t.Context(), "", currency.AUD) + require.NoError(t, err) + assert.NotEmpty(t, wID, errExpectedNonEmpty) + // Fiat deposits/withdrawals aren't accepted for fiat currencies for Australian business accounts; the error "id not found" possibly reflects this + _, err = e.GetAllFiatTransfers(t.Context(), wID.ID, pag, FiatDeposit) + assert.NoError(t, err) + _, err = e.GetAllFiatTransfers(t.Context(), wID.ID, pag, FiatWithdrawal) + assert.NoError(t, err) +} + +func TestGetFiatTransferByID(t *testing.T) { + t.Parallel() + _, err := e.GetFiatTransferByID(t.Context(), "", "", FiatDeposit) + assert.ErrorIs(t, err, errWalletIDEmpty) + _, err = e.GetFiatTransferByID(t.Context(), "123", "", FiatDeposit) + assert.ErrorIs(t, err, errDepositIDEmpty) + sharedtestvalues.SkipTestIfCredentialsUnset(t, e) + wID, err := e.GetWalletByID(t.Context(), "", currency.AUD) + require.NoError(t, err) + assert.NotEmpty(t, wID, errExpectedNonEmpty) + // Fiat deposits/withdrawals aren't accepted for fiat currencies for Australian business accounts; the error "id not found" possibly reflects this + dID, err := e.GetAllFiatTransfers(t.Context(), wID.ID, PaginationInp{}, FiatDeposit) + assert.NoError(t, err) + if dID == nil || len(dID.Data) == 0 { + t.Skip(skipInsufficientTransactions) + } + resp, err := e.GetFiatTransferByID(t.Context(), wID.ID, dID.Data[0].ID, FiatDeposit) + if assert.NoError(t, err) { + assert.NotEmpty(t, resp, errExpectedNonEmpty) + } + resp, err = e.GetFiatTransferByID(t.Context(), wID.ID, dID.Data[0].ID, FiatWithdrawal) + require.NoError(t, err) + assert.NotEmpty(t, resp, errExpectedNonEmpty) +} + +func TestGetAllWallets(t *testing.T) { + t.Parallel() + sharedtestvalues.SkipTestIfCredentialsUnset(t, e) + pagIn := PaginationInp{Limit: 2} + resp, err := e.GetAllWallets(t.Context(), pagIn) + assert.NoError(t, err) + require.NotEmpty(t, resp, errExpectedNonEmpty) + if resp.Pagination.NextStartingAfter == "" { + t.Skip(skipInsufficientWallets) + } + pagIn.StartingAfter = resp.Pagination.NextStartingAfter + resp, err = e.GetAllWallets(t.Context(), pagIn) + require.NoError(t, err) + assert.NotEmpty(t, resp, errExpectedNonEmpty) +} + +func TestGetWalletByID(t *testing.T) { + t.Parallel() + _, err := e.GetWalletByID(t.Context(), "", currency.Code{}) + assert.ErrorIs(t, err, errCurrWalletConflict) + sharedtestvalues.SkipTestIfCredentialsUnset(t, e) + resp, err := e.GetWalletByID(t.Context(), "", testCrypto) + require.NoError(t, err) + require.NotEmpty(t, resp, errExpectedNonEmpty) + resp, err = e.GetWalletByID(t.Context(), resp.ID, currency.Code{}) + require.NoError(t, err) + assert.NotEmpty(t, resp, errExpectedNonEmpty) +} + +func TestGetAllTransactions(t *testing.T) { + t.Parallel() + var pag PaginationInp + _, err := e.GetAllTransactions(t.Context(), "", pag) + assert.ErrorIs(t, err, errWalletIDEmpty) + sharedtestvalues.SkipTestIfCredentialsUnset(t, e) + wID, err := e.GetWalletByID(t.Context(), "", testCrypto) + require.NoError(t, err) + require.NotEmpty(t, wID, errExpectedNonEmpty) + _, err = e.GetAllTransactions(t.Context(), wID.ID, pag) + assert.NoError(t, err) +} + +func TestGetTransactionByID(t *testing.T) { + t.Parallel() + _, err := e.GetTransactionByID(t.Context(), "", "") + assert.ErrorIs(t, err, errWalletIDEmpty) + _, err = e.GetTransactionByID(t.Context(), "123", "") + assert.ErrorIs(t, err, errTransactionIDEmpty) + sharedtestvalues.SkipTestIfCredentialsUnset(t, e) + wID, err := e.GetWalletByID(t.Context(), "", testCrypto) + require.NoError(t, err) + require.NotEmpty(t, wID, errExpectedNonEmpty) + tID, err := e.GetAllTransactions(t.Context(), wID.ID, PaginationInp{}) + assert.NoError(t, err) + if tID == nil || len(tID.Data) == 0 { + t.Skip(skipInsufficientTransactions) + } + resp, err := e.GetTransactionByID(t.Context(), wID.ID, tID.Data[0].ID) + require.NoError(t, err) + assert.NotEmpty(t, resp, errExpectedNonEmpty) +} + +func TestGetFiatCurrencies(t *testing.T) { + t.Parallel() + testGetNoArgs(t, e.GetFiatCurrencies) +} + +func TestGetCryptocurrencies(t *testing.T) { + t.Parallel() + testGetNoArgs(t, e.GetCryptocurrencies) +} + +func TestGetExchangeRates(t *testing.T) { + t.Parallel() + resp, err := e.GetExchangeRates(t.Context(), "") + require.NoError(t, err) + assert.NotEmpty(t, resp, errExpectedNonEmpty) +} + +func TestGetPrice(t *testing.T) { + t.Parallel() + _, err := e.GetPrice(t.Context(), "", "") + assert.ErrorIs(t, err, errInvalidPriceType) + resp, err := e.GetPrice(t.Context(), testPairFiat.String(), asset.Spot.String()) + require.NoError(t, err) + assert.NotEmpty(t, resp, errExpectedNonEmpty) +} + +func TestGetV2Time(t *testing.T) { + t.Parallel() + testGetNoArgs(t, e.GetV2Time) +} + +func TestGetCurrentUser(t *testing.T) { + t.Parallel() + sharedtestvalues.SkipTestIfCredentialsUnset(t, e) + // This intermittently fails with the message "Unauthorized", for no clear reason + testGetNoArgs(t, e.GetCurrentUser) +} + +func TestGetAllCurrencies(t *testing.T) { + t.Parallel() + testGetNoArgs(t, e.GetAllCurrencies) +} + +func TestGetACurrency(t *testing.T) { + t.Parallel() + testGetOneArg(t, e.GetACurrency, testCrypto.String(), currency.ErrCurrencyCodeEmpty) +} + +func TestGetAllTradingPairs(t *testing.T) { + t.Parallel() + _, err := e.GetAllTradingPairs(t.Context(), "") + assert.NoError(t, err) +} + +func TestGetAllPairVolumes(t *testing.T) { + t.Parallel() + testGetNoArgs(t, e.GetAllPairVolumes) +} + +func TestGetPairDetails(t *testing.T) { + t.Parallel() + testGetOneArg(t, e.GetPairDetails, testPairFiat.String(), currency.ErrCurrencyPairEmpty) +} + +func TestGetProductBookV1(t *testing.T) { + t.Parallel() + _, err := e.GetProductBookV1(t.Context(), "", 0) + assert.ErrorIs(t, err, currency.ErrCurrencyPairEmpty) + resp, err := e.GetProductBookV1(t.Context(), testPairFiat.String(), 2) + if assert.NoError(t, err) { + assert.NotEmpty(t, resp, errExpectedNonEmpty) + } + resp, err = e.GetProductBookV1(t.Context(), testPairFiat.String(), 3) + require.NoError(t, err) + assert.NotEmpty(t, resp, errExpectedNonEmpty) +} + +func TestGetProductCandles(t *testing.T) { + t.Parallel() + _, err := e.GetProductCandles(t.Context(), "", 0, time.Time{}, time.Time{}) + assert.ErrorIs(t, err, currency.ErrCurrencyPairEmpty) + resp, err := e.GetProductCandles(t.Context(), testPairFiat.String(), 300, time.Time{}, time.Time{}) + require.NoError(t, err) + assert.NotEmpty(t, resp, errExpectedNonEmpty) +} + +func TestGetProductStats(t *testing.T) { + t.Parallel() + testGetOneArg(t, e.GetProductStats, testPairFiat.String(), currency.ErrCurrencyPairEmpty) +} + +func TestGetProductTicker(t *testing.T) { + t.Parallel() + testGetOneArg(t, e.GetProductTicker, testPairFiat.String(), currency.ErrCurrencyPairEmpty) +} + +func TestGetProductTrades(t *testing.T) { + t.Parallel() + _, err := e.GetProductTrades(t.Context(), "", "", "", 0) + assert.ErrorIs(t, err, currency.ErrCurrencyPairEmpty) + resp, err := e.GetProductTrades(t.Context(), testPairFiat.String(), "1", "before", 0) + require.NoError(t, err) + assert.NotEmpty(t, resp, errExpectedNonEmpty) +} + +func TestGetAllWrappedAssets(t *testing.T) { + t.Parallel() + testGetNoArgs(t, e.GetAllWrappedAssets) +} + +func TestGetWrappedAssetDetails(t *testing.T) { + t.Parallel() + testGetOneArg(t, e.GetWrappedAssetDetails, testWrappedAsset.String(), errWrappedAssetEmpty) +} + +func TestGetWrappedAssetConversionRate(t *testing.T) { + t.Parallel() + testGetOneArg(t, e.GetWrappedAssetConversionRate, testWrappedAsset.String(), errWrappedAssetEmpty) +} + +func TestSendHTTPRequest(t *testing.T) { + t.Parallel() + err := e.SendHTTPRequest(t.Context(), exchange.EdgeCase3, "", nil, nil) + assert.ErrorIs(t, err, exchange.ErrEndpointPathNotFound) +} + +func TestSendAuthenticatedHTTPRequest(t *testing.T) { + t.Parallel() + err := e.SendAuthenticatedHTTPRequest(t.Context(), exchange.EdgeCase3, "", "", nil, nil, false, nil) + assert.ErrorIs(t, err, exchange.ErrEndpointPathNotFound) + ch := make(chan struct{}) + body := map[string]any{"Unmarshalable": ch} + err = e.SendAuthenticatedHTTPRequest(t.Context(), exchange.RestSpot, "", "", nil, body, false, nil) + // TODO: Implement this more rigorously once thrasher investigates the code further + // var targetErr *json.UnsupportedTypeError + // assert.ErrorAs(t, err, &targetErr) + assert.ErrorContains(t, err, "json: unsupported type: chan struct {}") +} + +func TestGetFee(t *testing.T) { + t.Parallel() + _, err := e.GetFee(t.Context(), nil) + assert.ErrorIs(t, err, common.ErrNilPointer) + feeBuilder := exchange.FeeBuilder{ + FeeType: exchange.OfflineTradeFee, + Amount: 1, + PurchasePrice: 1, + } + resp, err := e.GetFee(t.Context(), &feeBuilder) + assert.NoError(t, err) + if resp != WorstCaseTakerFee { + t.Errorf(errExpectMismatch, resp, WorstCaseTakerFee) + } + feeBuilder.IsMaker = true + resp, err = e.GetFee(t.Context(), &feeBuilder) + assert.NoError(t, err) + if resp != WorstCaseMakerFee { + t.Errorf(errExpectMismatch, resp, WorstCaseMakerFee) + } + feeBuilder.Pair = currency.NewPair(currency.USDT, currency.USD) + resp, err = e.GetFee(t.Context(), &feeBuilder) + assert.NoError(t, err) + if resp != 0 { + t.Errorf(errExpectMismatch, resp, StablePairMakerFee) + } + feeBuilder.IsMaker = false + resp, err = e.GetFee(t.Context(), &feeBuilder) + assert.NoError(t, err) + if resp != WorstCaseStablePairTakerFee { + t.Errorf(errExpectMismatch, resp, WorstCaseStablePairTakerFee) + } + feeBuilder.FeeType = exchange.CryptocurrencyDepositFee + _, err = e.GetFee(t.Context(), &feeBuilder) + assert.ErrorIs(t, err, errFeeTypeNotSupported) + feeBuilder.Pair = currency.Pair{} + sharedtestvalues.SkipTestIfCredentialsUnset(t, e) + feeBuilder.FeeType = exchange.CryptocurrencyTradeFee + resp, err = e.GetFee(t.Context(), &feeBuilder) + assert.NoError(t, err) + if !(resp <= WorstCaseTakerFee && resp >= BestCaseTakerFee) { + t.Errorf(errExpectedFeeRange, BestCaseTakerFee, WorstCaseTakerFee, resp) + } + feeBuilder.IsMaker = true + resp, err = e.GetFee(t.Context(), &feeBuilder) + assert.NoError(t, err) + if !(resp <= WorstCaseMakerFee && resp >= BestCaseMakerFee) { + t.Errorf(errExpectedFeeRange, BestCaseMakerFee, WorstCaseMakerFee, resp) + } +} + +func TestFetchTradablePairs(t *testing.T) { + t.Parallel() + _, err := e.FetchTradablePairs(t.Context(), asset.Options) + assert.Equal(t, errOptionInvalid, err.Error()) + resp, err := e.FetchTradablePairs(t.Context(), asset.Spot) + if assert.NoError(t, err) { + assert.NotEmpty(t, resp, errExpectedNonEmpty) + } + resp, err = e.FetchTradablePairs(t.Context(), asset.Futures) + require.NoError(t, err) + assert.NotEmpty(t, resp, errExpectedNonEmpty) +} + +func TestUpdateTradablePairs(t *testing.T) { + t.Parallel() + testexch.UpdatePairsOnce(t, e) +} + +func TestUpdateAccountInfo(t *testing.T) { + t.Parallel() + sharedtestvalues.SkipTestIfCredentialsUnset(t, e) + resp, err := e.UpdateAccountInfo(t.Context(), asset.Spot) + require.NoError(t, err) + assert.NotEmpty(t, resp, errExpectedNonEmpty) +} + +func TestUpdateTicker(t *testing.T) { + t.Parallel() + _, err := e.UpdateTicker(t.Context(), currency.Pair{}, asset.Spot) + assert.ErrorIs(t, err, currency.ErrCurrencyPairEmpty) + resp, err := e.UpdateTicker(t.Context(), testPairFiat, asset.Spot) + require.NoError(t, err) + assert.NotEmpty(t, resp, errExpectedNonEmpty) +} + +// Not parallel; being parallel causes intermittent errors with another test for no discernible reason +func TestUpdateOrderbook(t *testing.T) { + testexch.UpdatePairsOnce(t, e) + _, err := e.UpdateOrderbook(t.Context(), currency.Pair{}, asset.Empty) + assert.ErrorIs(t, err, currency.ErrCurrencyPairEmpty) + _, err = e.UpdateOrderbook(t.Context(), testPairFiat, asset.Empty) + assert.ErrorIs(t, err, asset.ErrNotSupported) + _, err = e.UpdateOrderbook(t.Context(), currency.NewPairWithDelimiter("meow", "woof", "-"), asset.Spot) + assert.Equal(t, errInvalidProductID, err.Error()) + // There are no perpetual futures contracts, so I can only deterministically test spot + resp, err := e.UpdateOrderbook(t.Context(), testPairFiat, asset.Spot) + require.NoError(t, err) + assert.NotEmpty(t, resp, errExpectedNonEmpty) +} + +func TestGetAccountFundingHistory(t *testing.T) { + t.Parallel() + sharedtestvalues.SkipTestIfCredentialsUnset(t, e) + _, err := e.GetAccountFundingHistory(t.Context()) + assert.NoError(t, err) +} + +func TestGetWithdrawalsHistory(t *testing.T) { + t.Parallel() + sharedtestvalues.SkipTestIfCredentialsUnset(t, e) + _, err := e.GetWithdrawalsHistory(t.Context(), currency.NewCode("meow"), asset.Spot) + assert.ErrorIs(t, err, errNoMatchingWallets) + _, err = e.GetWithdrawalsHistory(t.Context(), testCrypto, asset.Spot) + assert.NoError(t, err) +} + +func TestSubmitOrder(t *testing.T) { + t.Parallel() + _, err := e.SubmitOrder(t.Context(), nil) + assert.ErrorIs(t, err, order.ErrSubmissionIsNil) + sharedtestvalues.SkipTestIfCredentialsUnset(t, e, canManipulateRealOrders) + ord := order.Submit{ + Exchange: e.Name, + Pair: testPairStable, + AssetType: asset.Spot, + Side: order.Buy, + Type: order.StopLimit, + StopDirection: order.StopUp, + Amount: testAmount2, + Price: testPrice, + TriggerPrice: testPrice + 1, + RetrieveFees: true, + ClientOrderID: strconv.FormatInt(time.Now().UnixMilli(), 18) + "GCTSubmitOrderTest", + } + resp, err := e.SubmitOrder(t.Context(), &ord) + if assert.NoError(t, err) { + assert.NotEmpty(t, resp, errExpectedNonEmpty) + } + ord.StopDirection = order.StopDown + ord.TriggerPrice = testPrice/2 + 1 + resp, err = e.SubmitOrder(t.Context(), &ord) + if assert.NoError(t, err) { + assert.NotEmpty(t, resp, errExpectedNonEmpty) + } + ord.Type = order.Market + ord.QuoteAmount = testAmount3 + resp, err = e.SubmitOrder(t.Context(), &ord) + require.NoError(t, err) + assert.NotEmpty(t, resp, errExpectedNonEmpty) +} + +func TestModifyOrder(t *testing.T) { + t.Parallel() + _, err := e.ModifyOrder(t.Context(), nil) + assert.ErrorIs(t, err, common.ErrNilPointer) + var ord order.Modify + _, err = e.ModifyOrder(t.Context(), &ord) + assert.ErrorIs(t, err, order.ErrPairIsEmpty) + sharedtestvalues.SkipTestIfCredentialsUnset(t, e, canManipulateRealOrders) + ord.OrderID = "a" + ord.Price = testPrice + 1 + ord.Amount = testAmount + ord.Pair = testPairStable + ord.AssetType = asset.Spot + resp2, err := e.ModifyOrder(t.Context(), &ord) + require.NoError(t, err) + assert.NotEmpty(t, resp2, errExpectedNonEmpty) +} + +func TestCancelOrder(t *testing.T) { + t.Parallel() + err := e.CancelOrder(t.Context(), nil) + assert.ErrorIs(t, err, common.ErrNilPointer) + var can order.Cancel + err = e.CancelOrder(t.Context(), &can) + assert.ErrorIs(t, err, order.ErrOrderIDNotSet) + sharedtestvalues.SkipTestIfCredentialsUnset(t, e, canManipulateRealOrders) + can.OrderID = "0" + err = e.CancelOrder(t.Context(), &can) + assert.Error(t, err) + can.OrderID = "2" + err = e.CancelOrder(t.Context(), &can) + assert.NoError(t, err) +} + +func TestCancelBatchOrders(t *testing.T) { + t.Parallel() + _, err := e.CancelBatchOrders(t.Context(), nil) + assert.ErrorIs(t, err, order.ErrOrderIDNotSet) + can := make([]order.Cancel, 1) + _, err = e.CancelBatchOrders(t.Context(), can) + assert.ErrorIs(t, err, order.ErrOrderIDNotSet) + sharedtestvalues.SkipTestIfCredentialsUnset(t, e, canManipulateRealOrders) + can[0].OrderID = "1" + resp2, err := e.CancelBatchOrders(t.Context(), can) + require.NoError(t, err) + assert.NotEmpty(t, resp2, errExpectedNonEmpty) +} + +func TestGetOrderInfo(t *testing.T) { + t.Parallel() + sharedtestvalues.SkipTestIfCredentialsUnset(t, e, canManipulateRealOrders) + resp, err := e.GetOrderInfo(t.Context(), "17", testPairStable, asset.Spot) + require.NoError(t, err) + assert.NotEmpty(t, resp, errExpectedNonEmpty) +} + +func TestGetDepositAddress(t *testing.T) { + t.Parallel() + sharedtestvalues.SkipTestIfCredentialsUnset(t, e) + _, err := e.GetDepositAddress(t.Context(), currency.NewCode("fake currency that doesn't exist"), "", "") + assert.ErrorIs(t, err, errNoWalletForCurrency) + resp, err := e.GetDepositAddress(t.Context(), testCrypto, "", "") + require.NoError(t, err) + assert.NotEmpty(t, resp, errExpectedNonEmpty) +} + +func TestWithdrawCryptocurrencyFunds(t *testing.T) { + t.Parallel() + req := withdraw.Request{} + _, err := e.WithdrawCryptocurrencyFunds(t.Context(), &req) + assert.ErrorIs(t, err, common.ErrExchangeNameNotSet) + req.Exchange = e.Name + req.Currency = testCrypto + req.Amount = testAmount + req.Type = withdraw.Crypto + req.Crypto.Address = testAddress + _, err = e.WithdrawCryptocurrencyFunds(t.Context(), &req) + assert.ErrorIs(t, err, errWalletIDEmpty) + sharedtestvalues.SkipTestIfCredentialsUnset(t, e, canManipulateRealOrders) + wallets, err := e.GetAllWallets(t.Context(), PaginationInp{}) + assert.NoError(t, err) + if wallets == nil || len(wallets.Data) == 0 { + t.Fatal(errExpectedNonEmpty) + } + for i := range wallets.Data { + if wallets.Data[i].Currency.Name == testCrypto.String() && wallets.Data[i].Balance.Amount > testAmount*100 { + req.WalletID = wallets.Data[i].ID + break + } + } + if req.WalletID == "" { + t.Skip(skipInsufficientFunds) + } + resp, err := e.WithdrawCryptocurrencyFunds(t.Context(), &req) + require.NoError(t, err) + assert.NotEmpty(t, resp, errExpectedNonEmpty) +} + +func TestWithdrawFiatFunds(t *testing.T) { + withdrawFiatFundsHelper(t, e.WithdrawFiatFunds) +} + +func TestWithdrawFiatFundsToInternationalBank(t *testing.T) { + withdrawFiatFundsHelper(t, e.WithdrawFiatFundsToInternationalBank) +} + +func TestGetFeeByType(t *testing.T) { + t.Parallel() + _, err := e.GetFeeByType(t.Context(), nil) + assert.ErrorIs(t, err, common.ErrNilPointer) + var feeBuilder exchange.FeeBuilder + feeBuilder.FeeType = exchange.OfflineTradeFee + feeBuilder.Amount = 1 + feeBuilder.PurchasePrice = 1 + resp, err := e.GetFeeByType(t.Context(), &feeBuilder) + assert.NoError(t, err) + if resp != WorstCaseTakerFee { + t.Errorf(errExpectMismatch, resp, WorstCaseTakerFee) + } +} + +func TestGetActiveOrders(t *testing.T) { + t.Parallel() + _, err := e.GetActiveOrders(t.Context(), nil) + assert.ErrorIs(t, err, common.ErrNilPointer) + var req order.MultiOrderRequest + _, err = e.GetActiveOrders(t.Context(), &req) + assert.ErrorIs(t, err, asset.ErrNotSupported) + sharedtestvalues.SkipTestIfCredentialsUnset(t, e) + req.AssetType = asset.Spot + req.Side = order.AnySide + req.Type = order.AnyType + _, err = e.GetActiveOrders(t.Context(), &req) + assert.NoError(t, err) + req.Pairs = req.Pairs.Add(currency.NewPair(testCrypto, testFiat)) + _, err = e.GetActiveOrders(t.Context(), &req) + assert.NoError(t, err) +} + +func TestGetOrderHistory(t *testing.T) { + t.Parallel() + _, err := e.GetOrderHistory(t.Context(), nil) + assert.ErrorIs(t, err, order.ErrGetOrdersRequestIsNil) + sharedtestvalues.SkipTestIfCredentialsUnset(t, e) + var req order.MultiOrderRequest + req.AssetType = asset.Spot + req.Side = order.AnySide + req.Type = order.AnyType + _, err = e.GetOrderHistory(t.Context(), &req) + assert.NoError(t, err) + req.Pairs = req.Pairs.Add(testPairStable) + _, err = e.GetOrderHistory(t.Context(), &req) + assert.NoError(t, err) +} + +func TestGetHistoricCandles(t *testing.T) { + t.Parallel() + _, err := e.GetHistoricCandles(t.Context(), currency.Pair{}, asset.Empty, kline.OneYear, time.Time{}, time.Time{}) + assert.ErrorIs(t, err, currency.ErrCurrencyPairEmpty) + resp, err := e.GetHistoricCandles(t.Context(), testPairFiat, asset.Spot, kline.SixHour, time.Now().Add(-time.Hour*60), time.Now()) + require.NoError(t, err) + assert.NotEmpty(t, resp, errExpectedNonEmpty) +} + +func TestGetHistoricCandlesExtended(t *testing.T) { + t.Parallel() + _, err := e.GetHistoricCandlesExtended(t.Context(), currency.Pair{}, asset.Empty, kline.OneYear, time.Time{}, time.Time{}) + assert.ErrorIs(t, err, currency.ErrCurrencyPairEmpty) + resp, err := e.GetHistoricCandlesExtended(t.Context(), testPairFiat, asset.Spot, kline.OneMin, time.Now().Add(-time.Hour*9), time.Now()) + require.NoError(t, err) + assert.NotEmpty(t, resp, errExpectedNonEmpty) +} + +func TestValidateAPICredentials(t *testing.T) { + t.Parallel() + sharedtestvalues.SkipTestIfCredentialsUnset(t, e) + err := e.ValidateAPICredentials(t.Context(), asset.Spot) + assert.NoError(t, err) +} + +func TestGetServerTime(t *testing.T) { + t.Parallel() + _, err := e.GetServerTime(t.Context(), 0) + assert.NoError(t, err) +} + +func TestGetLatestFundingRates(t *testing.T) { + t.Parallel() + _, err := e.GetLatestFundingRates(t.Context(), nil) + assert.ErrorIs(t, err, common.ErrNilPointer) + req := fundingrate.LatestRateRequest{Asset: asset.UpsideProfitContract} + _, err = e.GetLatestFundingRates(t.Context(), &req) + assert.ErrorIs(t, err, asset.ErrNotSupported) + sharedtestvalues.SkipTestIfCredentialsUnset(t, e) + req.Asset = asset.Futures + resp, err := e.GetLatestFundingRates(t.Context(), &req) + require.NoError(t, err) + assert.NotEmpty(t, resp, errExpectedNonEmpty) +} + +func TestGetFuturesContractDetails(t *testing.T) { + t.Parallel() + _, err := e.GetFuturesContractDetails(t.Context(), asset.Empty) + assert.ErrorIs(t, err, futures.ErrNotFuturesAsset) + _, err = e.GetFuturesContractDetails(t.Context(), asset.UpsideProfitContract) + assert.ErrorIs(t, err, asset.ErrNotSupported) + sharedtestvalues.SkipTestIfCredentialsUnset(t, e) + resp, err := e.GetFuturesContractDetails(t.Context(), asset.Futures) + require.NoError(t, err) + assert.NotEmpty(t, resp, errExpectedNonEmpty) +} + +func TestUpdateOrderExecutionLimits(t *testing.T) { + t.Parallel() + err := e.UpdateOrderExecutionLimits(t.Context(), asset.Options) + assert.ErrorIs(t, err, asset.ErrNotSupported) + err = e.UpdateOrderExecutionLimits(t.Context(), asset.Spot) + assert.NoError(t, err) +} + +func TestGetOrderRespToOrderDetail(t *testing.T) { + t.Parallel() + mockData := &GetOrderResponse{ + OrderConfiguration: OrderConfiguration{ + MarketMarketIOC: &MarketMarketIOC{}, + LimitLimitGTC: &LimitLimitGTC{}, + LimitLimitGTD: &LimitLimitGTD{}, + StopLimitStopLimitGTC: &StopLimitStopLimitGTC{}, + StopLimitStopLimitGTD: &StopLimitStopLimitGTD{}, + }, + SizeInQuote: false, + Side: "BUY", + Status: "OPEN", + Settled: true, + EditHistory: []EditHistory{(EditHistory{})}, + } + resp := e.getOrderRespToOrderDetail(mockData, testPairStable, asset.Spot) + expected := &order.Detail{TimeInForce: order.ImmediateOrCancel, Exchange: "Coinbase", Type: order.StopLimit, Side: order.Buy, Status: order.Open, AssetType: asset.Spot, Date: time.Date(1, time.January, 1, 0, 0, 0, 0, time.UTC), CloseTime: time.Date(1, time.January, 1, 0, 0, 0, 0, time.UTC), LastUpdated: time.Date(1, time.January, 1, 0, 0, 0, 0, time.UTC), Pair: testPairStable} + assert.Equal(t, expected, resp) + mockData.Side = "SELL" + mockData.Status = "FILLED" + resp = e.getOrderRespToOrderDetail(mockData, testPairStable, asset.Spot) + expected.Side = order.Sell + expected.Status = order.Filled + assert.Equal(t, expected, resp) + mockData.Status = "CANCELLED" + resp = e.getOrderRespToOrderDetail(mockData, testPairStable, asset.Spot) + expected.Status = order.Cancelled + assert.Equal(t, expected, resp) + mockData.Status = "EXPIRED" + resp = e.getOrderRespToOrderDetail(mockData, testPairStable, asset.Spot) + expected.Status = order.Expired + assert.Equal(t, expected, resp) + mockData.Status = "FAILED" + resp = e.getOrderRespToOrderDetail(mockData, testPairStable, asset.Spot) + expected.Status = order.Rejected + assert.Equal(t, expected, resp) + mockData.Status = "UNKNOWN_ORDER_STATUS" + resp = e.getOrderRespToOrderDetail(mockData, testPairStable, asset.Spot) + expected.Status = order.UnknownStatus + assert.Equal(t, expected, resp) +} + +func TestFiatTransferTypeString(t *testing.T) { + t.Parallel() + var f FiatTransferType + if f.String() != "deposit" { + t.Errorf(errExpectMismatch, f.String(), "deposit") + } + f = FiatWithdrawal + if f.String() != "withdrawal" { + t.Errorf(errExpectMismatch, f.String(), "withdrawal") + } +} + +func TestGetCurrencyTradeURL(t *testing.T) { + t.Parallel() + testexch.UpdatePairsOnce(t, e) + for _, a := range e.GetAssetTypes(false) { + pairs, err := e.CurrencyPairs.GetPairs(a, false) + require.NoErrorf(t, err, "cannot get pairs for %s", a) + require.NotEmptyf(t, pairs, "no pairs for %s", a) + resp, err := e.GetCurrencyTradeURL(t.Context(), a, pairs[0]) + require.NoError(t, err) + assert.NotEmpty(t, resp) + } +} + +// TestWsAuth dials websocket, sends login request. +func TestWsAuth(t *testing.T) { + p := currency.Pairs{testPairFiat} + if e.Websocket.IsEnabled() && !e.API.AuthenticatedWebsocketSupport || !sharedtestvalues.AreAPICredentialsSet(e) { + t.Skip(websocket.ErrWebsocketNotEnabled.Error()) + } + var dialer gws.Dialer + err := e.Websocket.Conn.Dial(t.Context(), &dialer, http.Header{}) + require.NoError(t, err) + e.Websocket.Wg.Add(1) + go e.wsReadData() + err = e.Subscribe(subscription.List{ + { + Channel: "myAccount", + Asset: asset.All, + Pairs: p, + Authenticated: true, + }, + }) + assert.NoError(t, err) + timer := time.NewTimer(sharedtestvalues.WebsocketResponseDefaultTimeout) + select { + case badResponse := <-e.Websocket.DataHandler: + assert.IsType(t, []order.Detail{}, badResponse) + case <-timer.C: + } + timer.Stop() +} + +func TestWsHandleData(t *testing.T) { + done := make(chan struct{}) + t.Cleanup(func() { + close(done) + }) + go func() { + for { + select { + case <-e.Websocket.DataHandler: + continue + case <-done: + return + } + } + }() + _, err := e.wsHandleData(nil) + var syntaxErr *json.SyntaxError + assert.True(t, errors.As(err, &syntaxErr) || strings.Contains(err.Error(), "Syntax error no sources available, the input json is empty"), errJSONUnmarshalUnexpected) + mockJSON := []byte(`{"type": "error"}`) + _, err = e.wsHandleData(mockJSON) + assert.Error(t, err) + mockJSON = []byte(`{"sequence_num": 0, "channel": "subscriptions"}`) + _, err = e.wsHandleData(mockJSON) + assert.NoError(t, err) + var unmarshalTypeErr *json.UnmarshalTypeError + mockJSON = []byte(`{"sequence_num": 0, "channel": "status", "events": [{"type": 1234}]}`) + _, err = e.wsHandleData(mockJSON) + assert.True(t, errors.As(err, &unmarshalTypeErr) || strings.Contains(err.Error(), "mismatched type with value"), errJSONUnmarshalUnexpected) + mockJSON = []byte(`{"sequence_num": 0, "channel": "status", "events": [{"type": "moo"}]}`) + _, err = e.wsHandleData(mockJSON) + assert.NoError(t, err) + mockJSON = []byte(`{"sequence_num": 0, "channel": "ticker", "events": [{"type": "moo", "tickers": false}]}`) + _, err = e.wsHandleData(mockJSON) + assert.True(t, errors.As(err, &unmarshalTypeErr) || strings.Contains(err.Error(), "mismatched type with value"), errJSONUnmarshalUnexpected) + mockJSON = []byte(`{"sequence_num": 0, "channel": "candles", "events": [{"type": false}]}`) + _, err = e.wsHandleData(mockJSON) + assert.True(t, errors.As(err, &unmarshalTypeErr) || strings.Contains(err.Error(), "mismatched type with value"), errJSONUnmarshalUnexpected) + mockJSON = []byte(`{"sequence_num": 0, "channel": "candles", "events": [{"type": "moo", "candles": [{"low": "1.1"}]}]}`) + _, err = e.wsHandleData(mockJSON) + assert.NoError(t, err) + mockJSON = []byte(`{"sequence_num": 0, "channel": "market_trades", "events": [{"type": false}]}`) + _, err = e.wsHandleData(mockJSON) + assert.True(t, errors.As(err, &unmarshalTypeErr) || strings.Contains(err.Error(), "mismatched type with value"), errJSONUnmarshalUnexpected) + mockJSON = []byte(`{"sequence_num": 0, "channel": "market_trades", "events": [{"type": "moo", "trades": [{"price": "1.1"}]}]}`) + _, err = e.wsHandleData(mockJSON) + assert.NoError(t, err) + mockJSON = []byte(`{"sequence_num": 0, "channel": "l2_data", "events": [{"type": false, "updates": [{"price_level": "1.1"}]}]}`) + _, err = e.wsHandleData(mockJSON) + assert.True(t, errors.As(err, &unmarshalTypeErr) || strings.Contains(err.Error(), "mismatched type with value"), errJSONUnmarshalUnexpected) + mockJSON = []byte(`{"sequence_num": 0, "channel": "l2_data", "timestamp": "2006-01-02T15:04:05Z", "events": [{"type": "moo", "updates": [{"price_level": "1.1"}]}]}`) + _, err = e.wsHandleData(mockJSON) + assert.ErrorIs(t, err, errUnknownL2DataType) + mockJSON = []byte(`{"sequence_num": 0, "channel": "l2_data", "timestamp": "2006-01-02T15:04:05Z", "events": [{"type": "snapshot", "product_id": "BTC-USD", "updates": [{"side": "bid", "price_level": "1.1", "new_quantity": "2.2"}]}]}`) + _, err = e.wsHandleData(mockJSON) + assert.NoError(t, err) + mockJSON = []byte(`{"sequence_num": 0, "channel": "l2_data", "timestamp": "2006-01-02T15:04:05Z", "events": [{"type": "update", "product_id": "BTC-USD", "updates": [{"side": "bid", "price_level": "1.1", "new_quantity": "2.2"}]}]}`) + _, err = e.wsHandleData(mockJSON) + assert.NoError(t, err) + mockJSON = []byte(`{"sequence_num": 0, "channel": "user", "events": [{"type": false}]}`) + _, err = e.wsHandleData(mockJSON) + assert.True(t, errors.As(err, &unmarshalTypeErr) || strings.Contains(err.Error(), "mismatched type with value"), errJSONUnmarshalUnexpected) + mockJSON = []byte(`{"sequence_num": 0, "channel": "user", "events": [{"type": "moo", "orders": [{"limit_price": "2.2", "total_fees": "1.1", "post_only": true}], "positions": {"perpetual_futures_positions": [{"margin_type": "fakeMarginType"}], "expiring_futures_positions": [{}]}}]}`) + _, err = e.wsHandleData(mockJSON) + assert.NoError(t, err) + mockJSON = []byte(`{"sequence_num": 0, "channel": "fakechan", "events": [{"type": ""}]}`) + _, err = e.wsHandleData(mockJSON) + assert.ErrorIs(t, err, errChannelNameUnknown) + p, err := e.FormatExchangeCurrency(currency.NewBTCUSD(), asset.Spot) + require.NoError(t, err) + e.pairAliases.Load(map[currency.Pair]currency.Pairs{ + p: {p}, + }) + mockJSON = []byte(`{"sequence_num": 0, "channel": "ticker", "events": [{"type": "moo", "tickers": [{"product_id": "BTC-USD", "price": "1.1"}]}]}`) + _, err = e.wsHandleData(mockJSON) + assert.NoError(t, err) +} + +func TestProcessSnapshotUpdate(t *testing.T) { + t.Parallel() + req := WebsocketOrderbookDataHolder{Changes: []WebsocketOrderbookData{{Side: "fakeside", PriceLevel: 1.1, NewQuantity: 2.2}}, ProductID: currency.NewBTCUSD()} + err := e.ProcessSnapshot(&req, time.Time{}) + assert.ErrorIs(t, err, order.ErrSideIsInvalid) + err = e.ProcessUpdate(&req, time.Time{}) + assert.ErrorIs(t, err, order.ErrSideIsInvalid) + req.Changes[0].Side = "offer" + err = e.ProcessSnapshot(&req, time.Now()) + assert.NoError(t, err) + err = e.ProcessUpdate(&req, time.Now()) + assert.NoError(t, err) +} + +func TestGenerateSubscriptions(t *testing.T) { + t.Parallel() + e := new(Exchange) //nolint:govet // Intentional shadow to avoid future copy/paste mistakes + if err := testexch.Setup(e); err != nil { + log.Fatal(err) + } + e.Websocket.SetCanUseAuthenticatedEndpoints(true) + p1, err := e.GetEnabledPairs(asset.Spot) + require.NoError(t, err) + p2, err := e.GetEnabledPairs(asset.Futures) + require.NoError(t, err) + exp := subscription.List{} + for _, baseSub := range defaultSubscriptions.Enabled() { + s := baseSub.Clone() + s.QualifiedChannel = subscriptionNames[s.Channel] + switch s.Asset { + case asset.Spot: + s.Pairs = p1 + case asset.Futures: + s.Pairs = p2 + case asset.All: + s2 := s.Clone() + s2.Asset = asset.Futures + s2.Pairs = p2 + exp = append(exp, s2) + s.Asset = asset.Spot + s.Pairs = p1 + } + exp = append(exp, s) + } + subs, err := e.generateSubscriptions() + require.NoError(t, err) + testsubs.EqualLists(t, exp, subs) + _, err = subscription.List{{Channel: "wibble"}}.ExpandTemplates(e) + assert.ErrorContains(t, err, "subscription channel not supported: wibble") +} + +func TestSubscribeUnsubscribe(t *testing.T) { + t.Parallel() + sharedtestvalues.SkipTestIfCredentialsUnset(t, e) + req := subscription.List{{Channel: "heartbeat", Asset: asset.Spot, Pairs: currency.Pairs{currency.NewPairWithDelimiter(testCrypto.String(), testFiat.String(), "-")}}} + err := e.Subscribe(req) + assert.NoError(t, err) + err = e.Unsubscribe(req) + assert.NoError(t, err) +} + +func TestCheckSubscriptions(t *testing.T) { + t.Parallel() + e := &Exchange{ //nolint:govet // Intentional shadow to avoid future copy/paste mistakes + Base: exchange.Base{ + Config: &config.Exchange{ + Features: &config.FeaturesConfig{ + Subscriptions: subscription.List{ + {Enabled: true, Channel: "matches"}, + }, + }, + }, + Features: exchange.Features{}, + }, + } + e.checkSubscriptions() + testsubs.EqualLists(t, defaultSubscriptions.Enabled(), e.Features.Subscriptions) + testsubs.EqualLists(t, defaultSubscriptions, e.Config.Features.Subscriptions) +} + +func TestGetJWT(t *testing.T) { + t.Parallel() + sharedtestvalues.SkipTestIfCredentialsUnset(t, e) + _, _, err := e.GetJWT(t.Context(), "a") + assert.NoError(t, err) +} + +func TestEncodeDateRange(t *testing.T) { + t.Parallel() + _, err := urlValsFromDateRange(time.Time{}, time.Time{}, "", "") + assert.NoError(t, err) + _, err = urlValsFromDateRange(time.Unix(1, 1), time.Unix(1, 1), "", "") + assert.ErrorIs(t, err, common.ErrStartEqualsEnd) + _, err = urlValsFromDateRange(time.Unix(1, 1), time.Unix(2, 2), "", "") + assert.ErrorIs(t, err, errDateLabelEmpty) + vals, err := urlValsFromDateRange(time.Unix(1, 1), time.Unix(2, 2), "start", "end") + assert.NoError(t, err) + assert.NotEmpty(t, vals) +} + +func TestEncodePagination(t *testing.T) { + t.Parallel() + vals := urlValsFromPagination(PaginationInp{ + Limit: 1, + OrderAscend: true, + StartingAfter: "a", + EndingBefore: "b", + }) + assert.NotEmpty(t, vals) +} + +func TestCreateOrderConfig(t *testing.T) { + t.Parallel() + _, err := createOrderConfig(nil) + assert.ErrorIs(t, err, common.ErrNilPointer) + params := &OrderInfo{} + _, err = createOrderConfig(params) + assert.ErrorIs(t, err, errInvalidOrderType) + params.BaseAmount = 1 + params.QuoteAmount = 2 + params.OrderType = order.Market + _, err = createOrderConfig(params) + assert.NoError(t, err) + params.OrderType = order.Limit + params.TimeInForce = order.StopOrReduce + _, err = createOrderConfig(params) + assert.NoError(t, err) + params.TimeInForce = order.FillOrKill + _, err = createOrderConfig(params) + assert.NoError(t, err) + params.TimeInForce = order.UnknownTIF + _, err = createOrderConfig(params) + assert.NoError(t, err) + params.EndTime = time.Unix(1, 1) + _, err = createOrderConfig(params) + assert.ErrorIs(t, err, errEndTimeInPast) + params.EndTime = time.Now().Add(time.Hour) + _, err = createOrderConfig(params) + assert.NoError(t, err) + params.OrderType = order.TWAP + _, err = createOrderConfig(params) + assert.NoError(t, err) + params.EndTime = time.Time{} + _, err = createOrderConfig(params) + assert.ErrorIs(t, err, errEndTimeInPast) + params.OrderType = order.StopLimit + _, err = createOrderConfig(params) + assert.NoError(t, err) + params.EndTime = time.Unix(1, 1) + _, err = createOrderConfig(params) + assert.ErrorIs(t, err, errEndTimeInPast) + params.EndTime = time.Now().Add(time.Hour) + _, err = createOrderConfig(params) + assert.NoError(t, err) + params.OrderType = order.Bracket + _, err = createOrderConfig(params) + assert.NoError(t, err) + params.EndTime = time.Unix(1, 1) + _, err = createOrderConfig(params) + assert.ErrorIs(t, err, errEndTimeInPast) + params.EndTime = time.Time{} + _, err = createOrderConfig(params) + assert.NoError(t, err) +} + +func TestFormatMarginType(t *testing.T) { + t.Parallel() + resp := FormatMarginType("ISOLATED") + assert.Equal(t, "ISOLATED", resp) + resp = FormatMarginType("MULTI") + assert.Equal(t, "CROSS", resp) + resp = FormatMarginType("fake") + assert.Empty(t, resp) +} + +func TestStatusToStandardStatus(t *testing.T) { + t.Parallel() + resp, _ := statusToStandardStatus("PENDING") + assert.Equal(t, order.New, resp) + resp, _ = statusToStandardStatus("OPEN") + assert.Equal(t, order.Active, resp) + resp, _ = statusToStandardStatus("FILLED") + assert.Equal(t, order.Filled, resp) + resp, _ = statusToStandardStatus("CANCELLED") + assert.Equal(t, order.Cancelled, resp) + resp, _ = statusToStandardStatus("EXPIRED") + assert.Equal(t, order.Expired, resp) + resp, _ = statusToStandardStatus("FAILED") + assert.Equal(t, order.Rejected, resp) + _, err := statusToStandardStatus("") + assert.ErrorIs(t, err, order.ErrUnsupportedStatusType) +} + +func TestStringToStandardType(t *testing.T) { + t.Parallel() + resp, _ := stringToStandardType("LIMIT_ORDER_TYPE") + assert.Equal(t, order.Limit, resp) + resp, _ = stringToStandardType("MARKET_ORDER_TYPE") + assert.Equal(t, order.Market, resp) + resp, _ = stringToStandardType("STOP_LIMIT_ORDER_TYPE") + assert.Equal(t, order.StopLimit, resp) + _, err := stringToStandardType("") + assert.ErrorIs(t, err, order.ErrUnrecognisedOrderType) +} + +func TestStringToStandardAsset(t *testing.T) { + t.Parallel() + resp, _ := stringToStandardAsset("SPOT") + assert.Equal(t, asset.Spot, resp) + resp, _ = stringToStandardAsset("FUTURE") + assert.Equal(t, asset.Futures, resp) + _, err := stringToStandardAsset("") + assert.ErrorIs(t, err, asset.ErrNotSupported) +} + +func TestStrategyDecoder(t *testing.T) { + t.Parallel() + resp, _ := strategyDecoder("IMMEDIATE_OR_CANCEL") + assert.True(t, resp.Is(order.ImmediateOrCancel)) + resp, _ = strategyDecoder("FILL_OR_KILL") + assert.True(t, resp.Is(order.FillOrKill)) + resp, _ = strategyDecoder("GOOD_UNTIL_CANCELLED") + assert.True(t, resp.Is(order.GoodTillCancel)) + resp, _ = strategyDecoder("GOOD_UNTIL_DATE_TIME") + assert.True(t, resp.Is(order.GoodTillDay|order.GoodTillTime)) + _, err := strategyDecoder("") + assert.ErrorIs(t, err, errUnrecognisedStrategyType) +} + +func TestProcessFundingData(t *testing.T) { + t.Parallel() + accHistory := []DeposWithdrData{ + { + Type: "unknown", + }, + { + Type: "TRANSFER_TYPE_WITHDRAWAL", + }, + } + cryptoHistory := []TransactionData{ + { + Type: "receive", + }, + { + Type: "send", + }, + } + _, err := e.processFundingData(accHistory, cryptoHistory) + assert.ErrorIs(t, err, errUnknownTransferType) + accHistory[0].Type = "TRANSFER_TYPE_DEPOSIT" + resp, err := e.processFundingData(accHistory, cryptoHistory) + require.NoError(t, err) + assert.NotEmpty(t, resp) +} + +func TestChannelName(t *testing.T) { + _, err := channelName(&subscription.Subscription{}) + assert.ErrorIs(t, err, subscription.ErrNotSupported) + _, err = channelName(&subscription.Subscription{Channel: subscription.HeartbeatChannel}) + assert.NoError(t, err) +} + +func exchangeBaseHelper(e *Exchange) error { + if err := testexch.Setup(e); err != nil { + return err + } + if apiKey != "" { + e.SetCredentials(apiKey, apiSecret, "", "", "", "") + e.API.AuthenticatedSupport = true + e.API.AuthenticatedWebsocketSupport = true + } + return nil +} + +func getINTXPortfolio(t *testing.T) string { + t.Helper() + sharedtestvalues.SkipTestIfCredentialsUnset(t, e) + resp, err := e.GetAllPortfolios(t.Context(), "") + assert.NoError(t, err) + if len(resp) == 0 { + t.Skip(skipInsufficientPortfolios) + } + var targetID string + for i := range resp { + if resp[i].Type == "INTX" { + targetID = resp[i].UUID + break + } + } + if targetID == "" { + t.Skip(skipInsufficientPortfolios) + } + return targetID +} + +func convertTestHelper(t *testing.T) (fromAccID, toAccID string) { + t.Helper() + accIDs, err := e.ListAccounts(t.Context(), 250, 0) + assert.NoError(t, err) + if accIDs == nil || len(accIDs.Accounts) == 0 { + t.Fatal(errExpectedNonEmpty) + } + for x := range accIDs.Accounts { + if accIDs.Accounts[x].Currency == testStable.String() { + fromAccID = accIDs.Accounts[x].UUID + } + if accIDs.Accounts[x].Currency == testFiat.String() { + toAccID = accIDs.Accounts[x].UUID + } + if fromAccID != "" && toAccID != "" { + break + } + } + if fromAccID == "" || toAccID == "" { + t.Skip(skipInsufSuitableAccs) + } + return fromAccID, toAccID +} + +func transferTestHelper(t *testing.T, wallets *GetAllWalletsResponse) (srcWalletID, tarWalletID string) { + t.Helper() + var hasValidFunds bool + for i := range wallets.Data { + if wallets.Data[i].Currency.Code == testFiat.String() && wallets.Data[i].Balance.Amount > 10 { + hasValidFunds = true + srcWalletID = wallets.Data[i].ID + } + } + if !hasValidFunds { + t.Skip(skipInsufficientFunds) + } + pmID, err := e.ListPaymentMethods(t.Context()) + assert.NoError(t, err) + if len(pmID) == 0 { + t.Skip(skipPayMethodNotFound) + } + return srcWalletID, pmID[0].ID +} + +type withdrawFiatFunc func(context.Context, *withdraw.Request) (*withdraw.ExchangeResponse, error) + +func withdrawFiatFundsHelper(t *testing.T, fn withdrawFiatFunc) { + t.Helper() + t.Parallel() + req := withdraw.Request{} + _, err := fn(t.Context(), &req) + assert.ErrorIs(t, err, common.ErrExchangeNameNotSet) + req.Exchange = e.Name + req.Currency = testFiat + req.Amount = 1 + req.Type = withdraw.Fiat + req.Fiat.Bank.Enabled = true + req.Fiat.Bank.SupportedExchanges = "Coinbase" + req.Fiat.Bank.SupportedCurrencies = testFiat.String() + req.Fiat.Bank.AccountNumber = "123" + req.Fiat.Bank.SWIFTCode = "456" + req.Fiat.Bank.BSBNumber = "789" + _, err = fn(t.Context(), &req) + assert.ErrorIs(t, err, errWalletIDEmpty) + sharedtestvalues.SkipTestIfCredentialsUnset(t, e) + req.WalletID = "meow" + req.Fiat.Bank.BankName = "GCT's Officially Fake and Not Real Test Bank" + _, err = fn(t.Context(), &req) + assert.ErrorIs(t, err, errPayMethodNotFound) + sharedtestvalues.SkipTestIfCredentialsUnset(t, e, canManipulateRealOrders) + wallets, err := e.GetAllWallets(t.Context(), PaginationInp{}) + assert.NoError(t, err) + if wallets == nil || len(wallets.Data) == 0 { + t.Fatal(errExpectedNonEmpty) + } + req.WalletID = "" + for i := range wallets.Data { + if wallets.Data[i].Currency.Name == testFiat.String() && wallets.Data[i].Balance.Amount > testAmount*100 { + req.WalletID = wallets.Data[i].ID + break + } + } + if req.WalletID == "" { + t.Skip(skipInsufficientFunds) + } + req.Fiat.Bank.BankName = "AUD Wallet" + resp, err := fn(t.Context(), &req) + require.NoError(t, err) + assert.NotEmpty(t, resp, errExpectedNonEmpty) +} + +type getNoArgsResp interface { + *ServerTimeV3 | []PaymentMethodData | *UserResponse | []FiatData | []CryptoData | *ServerTimeV2 | []CurrencyData | []PairVolumeData | *AllWrappedAssets +} + +type getNoArgsAssertNotEmpty[G getNoArgsResp] func(context.Context) (G, error) + +func testGetNoArgs[G getNoArgsResp](t *testing.T, f getNoArgsAssertNotEmpty[G]) { + t.Helper() + resp, err := f(t.Context()) + require.NoError(t, err) + assert.NotEmpty(t, resp, errExpectedNonEmpty) +} + +type genConvertTestFunc func(context.Context, string, string, string) (*ConvertResponse, error) + +func convertTestShared(t *testing.T, f genConvertTestFunc) { + t.Helper() + t.Parallel() + _, err := f(t.Context(), "", "", "") + assert.ErrorIs(t, err, errTransactionIDEmpty) + _, err = f(t.Context(), "meow", "", "") + assert.ErrorIs(t, err, errAccountIDEmpty) + sharedtestvalues.SkipTestIfCredentialsUnset(t, e, canManipulateRealOrders) + fromAccID, toAccID := convertTestHelper(t) + resp, err := e.CreateConvertQuote(t.Context(), fromAccID, toAccID, "", "", 0.01) + require.NoError(t, err) + require.NotNil(t, resp) + resp, err = f(t.Context(), resp.ID, fromAccID, toAccID) + require.NoError(t, err) + assert.NotEmpty(t, resp, errExpectedNonEmpty) +} + +type getOneArgResp interface { + *CurrencyData | *PairData | *ProductStats | *ProductTicker | *WrappedAsset | *WrappedAssetConversionRate +} + +type getOneArgAssertNotEmpty[G getOneArgResp] func(context.Context, string) (G, error) + +func testGetOneArg[G getOneArgResp](t *testing.T, f getOneArgAssertNotEmpty[G], arg string, tarErr error) { + t.Helper() + _, err := f(t.Context(), "") + assert.ErrorIs(t, err, tarErr) + resp, err := f(t.Context(), arg) + require.NoError(t, err) + assert.NotEmpty(t, resp, errExpectedNonEmpty) +} diff --git a/exchanges/coinbase/coinbase_types.go b/exchanges/coinbase/coinbase_types.go new file mode 100644 index 00000000..583efdeb --- /dev/null +++ b/exchanges/coinbase/coinbase_types.go @@ -0,0 +1,2722 @@ +package coinbase + +import ( + "net/url" + "sync" + "time" + + "github.com/gofrs/uuid" + "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/order" + "github.com/thrasher-corp/gocryptotrader/types" +) + +type jwtManager struct { + token string + expiresAt time.Time + m sync.RWMutex +} + +type pairAliases struct { + associatedAliases map[currency.Pair]currency.Pairs + m sync.RWMutex +} + +// Exchange is the overarching type across the coinbase package +type Exchange struct { + exchange.Base + jwt jwtManager + pairAliases pairAliases +} + +// Version is used for the niche cases where the Version of the API must be specified and passed around for proper functionality +type Version bool + +// FiatTransferType is used so that we don't need to duplicate the four fiat transfer-related endpoints under version 2 of the API +type FiatTransferType bool + +// Integer is used to represent an integer in the API, which is represented as a string in the JSON response +type Integer int64 + +// CurrencyAmount holds a currency code and amount +type CurrencyAmount struct { + Value types.Number `json:"value"` + Currency currency.Code `json:"currency"` +} + +// Account holds details for a trading account, returned by GetAccountByID and used as a sub-struct in the type AllAccountsResponse +type Account struct { + UUID string `json:"uuid"` + Name string `json:"name"` + Currency string `json:"currency"` + AvailableBalance CurrencyAmount `json:"available_balance"` + Default bool `json:"default"` + Active bool `json:"active"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt time.Time `json:"deleted_at"` + Type string `json:"type"` + Ready bool `json:"ready"` + Hold CurrencyAmount `json:"hold"` + RetailPortfolioID string `json:"retail_portfolio_id"` + Platform string `json:"platform"` +} + +// AllAccountsResponse holds many Account structs, as well as pagination information, returned by ListAccounts +type AllAccountsResponse struct { + Accounts []Account `json:"accounts"` + HasNext bool `json:"has_next"` + Cursor Integer `json:"cursor"` + Size uint8 `json:"size"` +} + +// PermissionsResponse holds information on the permissions of a user, returned by GetPermissions +type PermissionsResponse struct { + CanView bool `json:"can_view"` + CanTrade bool `json:"can_trade"` + CanTransfer bool `json:"can_transfer"` + PortfolioUUID string `json:"portfolio_uuid"` + PortfolioType string `json:"portfolio_type"` +} + +// Params is used within functions to make the setting of parameters easier +type Params struct { + url.Values +} + +// MarginWindow is a sub-struct used in the type CurrentMarginWindow +type MarginWindow struct { + MarginWindowType string `json:"margin_window_type"` + EndTime time.Time `json:"end_time"` +} + +// SuccessResp holds information on the success of an API request, returned by CancelPendingFuturesSweep, ScheduleFuturesSweep, and EditOrder +type SuccessResp struct { + Success bool `json:"success"` +} + +type futuresSweepReqBase struct { + USDAmount float64 `json:"usd_amount,string,omitempty"` +} + +type marginSettingReqBase struct { + Setting string `json:"setting"` +} + +// CurrentMarginWindow holds information on the current margin window, returned by GetCurrentMarginWindow +type CurrentMarginWindow struct { + MarginWindow MarginWindow `json:"margin_window"` + IsIntradayMarginKillswitchEnabled bool `json:"is_intraday_margin_killswitch_enabled"` + IsIntradayMarginEnrollmentKillswitchEnabled bool `json:"is_intraday_margin_enrollment_killswitch_enabled"` +} + +// PriceSize is a sub-struct used in the type ProductBook +type PriceSize struct { + Price types.Number `json:"price"` + Size types.Number `json:"size"` +} + +// ProductBook holds bid and ask prices for a particular product, returned by GetBestBidAsk and used in ProductBookResp +type ProductBook struct { + ProductID currency.Pair `json:"product_id"` + Bids []PriceSize `json:"bids"` + Asks []PriceSize `json:"asks"` + Time time.Time `json:"time"` +} + +// ProductBookResp holds a ProductBook struct, and associated information, returned by GetProductBookV3 +type ProductBookResp struct { + Pricebook ProductBook `json:"pricebook"` + Last types.Number `json:"last"` + MidMarket types.Number `json:"mid_market"` + SpreadBPs types.Number `json:"spread_bps"` + SpreadAbsolute types.Number `json:"spread_absolute"` +} + +// FCMTradingSessionDetails is a sub-struct used in the type Product +type FCMTradingSessionDetails struct { + IsSessionOpen bool `json:"is_session_open"` + OpenTime time.Time `json:"open_time"` + CloseTime time.Time `json:"close_time"` + SessionState string `json:"session_state"` + AfterHoursOrderEntryDisabled bool `json:"after_hours_order_entry_disabled"` +} + +// PerpetualDetails is a sub-struct used in the type FutureProductDetails +type PerpetualDetails struct { + OpenInterest types.Number `json:"open_interest"` + FundingRate types.Number `json:"funding_rate"` + FundingTime time.Time `json:"funding_time"` + MaxLeverage types.Number `json:"max_leverage"` + BaseAssetUUID string `json:"base_asset_uuid"` + UnderlyingType string `json:"underlying_type"` +} + +// FutureProductDetails is a sub-struct used in the type Product +type FutureProductDetails struct { + Venue string `json:"venue"` + ContractCode string `json:"contract_code"` + ContractExpiry time.Time `json:"contract_expiry"` + ContractSize types.Number `json:"contract_size"` + ContractRootUnit string `json:"contract_root_unit"` + GroupDescription string `json:"group_description"` + ContractExpiryTimezone string `json:"contract_expiry_timezone"` + GroupShortDescription string `json:"group_short_description"` + RiskManagedBy string `json:"risk_managed_by"` + ContractExpiryType string `json:"contract_expiry_type"` + PerpetualDetails PerpetualDetails `json:"perpetual_details"` + ContractDisplayName string `json:"contract_display_name"` + TimeToExpiry time.Duration `json:"time_to_expiry_ms,string"` + NonCrypto bool `json:"non_crypto"` + ContractExpiryName string `json:"contract_expiry_name"` + TwentyFourBySeven bool `json:"twenty_four_by_seven"` + FundingInterval string `json:"funding_interval"` + OpenInterest types.Number `json:"open_interest"` +} + +// Product holds product information, returned by GetProductByID, and used as a sub-struct in the type AllProducts +type Product struct { + ID currency.Pair `json:"product_id"` + Price types.Number `json:"price"` + PricePercentageChange24H types.Number `json:"price_percentage_change_24h"` + Volume24H types.Number `json:"volume_24h"` + VolumePercentageChange24H types.Number `json:"volume_percentage_change_24h"` + BaseIncrement types.Number `json:"base_increment"` + QuoteIncrement types.Number `json:"quote_increment"` + QuoteMinSize types.Number `json:"quote_min_size"` + QuoteMaxSize types.Number `json:"quote_max_size"` + BaseMinSize types.Number `json:"base_min_size"` + BaseMaxSize types.Number `json:"base_max_size"` + BaseName string `json:"base_name"` + QuoteName string `json:"quote_name"` + Watched bool `json:"watched"` + IsDisabled bool `json:"is_disabled"` + New bool `json:"new"` + Status string `json:"status"` + CancelOnly bool `json:"cancel_only"` + LimitOnly bool `json:"limit_only"` + PostOnly bool `json:"post_only"` + TradingDisabled bool `json:"trading_disabled"` + AuctionMode bool `json:"auction_mode"` + ProductType string `json:"product_type"` + QuoteCurrencyID currency.Code `json:"quote_currency_id"` + BaseCurrencyID currency.Code `json:"base_currency_id"` + FCMTradingSessionDetails FCMTradingSessionDetails `json:"fcm_trading_session_details"` + MidMarketPrice types.Number `json:"mid_market_price"` + Alias currency.Pair `json:"alias"` + AliasTo []currency.Pair `json:"alias_to"` + BaseDisplaySymbol string `json:"base_display_symbol"` + QuoteDisplaySymbol string `json:"quote_display_symbol"` + // Typically shows whether an FCM product is available for trading. If the request is authenticated, and the "get_tradability_status" bool is set to true, and the product is SPOT, and you're using our GetAllProducts function, this will instead reflect whether the product is available for trading. + ViewOnly bool `json:"view_only"` + PriceIncrement types.Number `json:"price_increment"` + DisplayName string `json:"display_name"` + ProductVenue string `json:"product_venue"` + ApproximateQuote24HVolume types.Number `json:"approximate_quote_24h_volume"` + NewAt time.Time `json:"new_at"` + // The following field only appears for future products + FutureProductDetails FutureProductDetails `json:"future_product_details"` +} + +// AllProducts holds information on a lot of available currency pairs, returned by GetAllProducts +type AllProducts struct { + Products []Product `json:"products"` + NumProducts int32 `json:"num_products"` +} + +// Klines holds historic trade information, returned by GetHistoricKlines +type Klines struct { + Start types.Time `json:"start"` + Low types.Number `json:"low"` + High types.Number `json:"high"` + Open types.Number `json:"open"` + Close types.Number `json:"close"` + Volume types.Number `json:"volume"` +} + +// Trades is a sub-struct used in the type Ticker +type Trades struct { + TradeID string `json:"trade_id"` + ProductID currency.Pair `json:"product_id"` + Price types.Number `json:"price"` + Size types.Number `json:"size"` + Time time.Time `json:"time"` + Side string `json:"side"` + Bid types.Number `json:"bid"` + Ask types.Number `json:"ask"` + Exchange string `json:"exchange"` +} + +// Ticker holds basic ticker information, returned by GetTicker +type Ticker struct { + Trades []Trades `json:"trades"` + BestBid types.Number `json:"best_bid"` + BestAsk types.Number `json:"best_ask"` +} + +// MarketMarketIOC is a sub-struct used in the type OrderConfiguration +type MarketMarketIOC struct { + QuoteSize types.Number `json:"quote_size,omitempty"` + BaseSize types.Number `json:"base_size,omitempty"` + RFQDisabled bool `json:"rfq_disabled"` + RFQEnabled *bool `json:"rfq_enabled,omitempty"` + ReduceOnly *bool `json:"reduce_only,omitempty"` +} + +// QuoteBaseLimit is a sub-struct used in the type OrderConfiguration +type QuoteBaseLimit struct { + QuoteSize types.Number `json:"quote_size,omitempty"` + BaseSize types.Number `json:"base_size,omitempty"` + LimitPrice types.Number `json:"limit_price"` + RFQDisabled bool `json:"rfq_disabled"` +} + +// LimitLimitGTC is a sub-struct used in the type OrderConfiguration +type LimitLimitGTC struct { + BaseSize types.Number `json:"base_size,omitempty"` + QuoteSize types.Number `json:"quote_size,omitempty"` + LimitPrice types.Number `json:"limit_price"` + PostOnly bool `json:"post_only"` + RFQDisabled bool `json:"rfq_disabled"` + ReduceOnly *bool `json:"reduce_only,omitempty"` +} + +// LimitLimitGTD is a sub-struct used in the type OrderConfiguration +type LimitLimitGTD struct { + BaseSize types.Number `json:"base_size,omitempty"` + QuoteSize types.Number `json:"quote_size,omitempty"` + LimitPrice types.Number `json:"limit_price"` + EndTime time.Time `json:"end_time"` + PostOnly bool `json:"post_only"` + ReduceOnly *bool `json:"reduce_only,omitempty"` + RFQDisabled bool `json:"rfq_disabled,omitempty"` +} + +// TWAPLimitGTD is a sub-struct used in the type OrderConfiguration +type TWAPLimitGTD struct { + QuoteSize types.Number `json:"quote_size,omitempty"` + BaseSize types.Number `json:"base_size,omitempty"` + StartTime time.Time `json:"start_time"` + EndTime time.Time `json:"end_time"` + LimitPrice types.Number `json:"limit_price"` + NumberBuckets int64 `json:"number_buckets,string"` + BucketSize types.Number `json:"bucket_size"` + BucketDuration string `json:"bucket_duration"` +} + +// StopLimitStopLimitGTC is a sub-struct used in the type OrderConfiguration +type StopLimitStopLimitGTC struct { + BaseSize types.Number `json:"base_size,omitempty"` + QuoteSize types.Number `json:"quote_size,omitempty"` + LimitPrice types.Number `json:"limit_price"` + StopPrice types.Number `json:"stop_price"` + StopDirection string `json:"stop_direction"` +} + +// StopLimitStopLimitGTD is a sub-struct used in the type OrderConfiguration +type StopLimitStopLimitGTD struct { + BaseSize types.Number `json:"base_size,omitempty"` + QuoteSize types.Number `json:"quote_size,omitempty"` + LimitPrice types.Number `json:"limit_price"` + StopPrice types.Number `json:"stop_price"` + EndTime time.Time `json:"end_time"` + StopDirection string `json:"stop_direction"` +} + +// TriggerBracketGTC is a sub-struct used in the type OrderConfiguration +type TriggerBracketGTC struct { + BaseSize types.Number `json:"base_size,omitempty"` + LimitPrice types.Number `json:"limit_price"` + StopTriggerPrice types.Number `json:"stop_trigger_price"` +} + +// TriggerBracketGTD is a sub-struct used in the type OrderConfiguration +type TriggerBracketGTD struct { + BaseSize types.Number `json:"base_size,omitempty"` + LimitPrice types.Number `json:"limit_price"` + StopTriggerPrice types.Number `json:"stop_trigger_price"` + EndTime time.Time `json:"end_time"` +} + +// OrderConfiguration is a struct used in the formation of requests in PrepareOrderConfig, and is a sub-struct used in the types SuccessFailureConfig and GetOrderResponse +type OrderConfiguration struct { + MarketMarketIOC *MarketMarketIOC `json:"market_market_ioc,omitempty"` + SORLimitIOC *QuoteBaseLimit `json:"sor_limit_ioc,omitempty"` + LimitLimitGTC *LimitLimitGTC `json:"limit_limit_gtc,omitempty"` + LimitLimitGTD *LimitLimitGTD `json:"limit_limit_gtd,omitempty"` + LimitLimitFOK *QuoteBaseLimit `json:"limit_limit_fok,omitempty"` + TWAPLimitGTD *TWAPLimitGTD `json:"twap_limit_gtd,omitempty"` + StopLimitStopLimitGTC *StopLimitStopLimitGTC `json:"stop_limit_stop_limit_gtc,omitempty"` + StopLimitStopLimitGTD *StopLimitStopLimitGTD `json:"stop_limit_stop_limit_gtd,omitempty"` + TriggerBracketGTC *TriggerBracketGTC `json:"trigger_bracket_gtc,omitempty"` + TriggerBracketGTD *TriggerBracketGTD `json:"trigger_bracket_gtd,omitempty"` +} + +// SuccessResponse is a sub-struct used in the type SuccessFailureConfig +type SuccessResponse struct { + OrderID string `json:"order_id"` + ProductID currency.Pair `json:"product_id"` + Side string `json:"side"` + ClientOrderID string `json:"client_order_id"` + AttachedOrderID string `json:"attached_order_id"` +} + +// ErrorResponse is a sub-struct used in unmarshalling errors from the exchange in general +type ErrorResponse struct { + ErrorType string `json:"error"` + Message string `json:"message"` + ErrorDetails string `json:"error_details"` + EditFailureReason string `json:"edit_failure_reason"` + PreviewFailureReason string `json:"preview_failure_reason"` + NewOrderFailureReason string `json:"new_order_failure_reason"` +} + +// OrderInfo contains order configuration information used in both PlaceOrderInfo and PreviewOrderInfo +type OrderInfo struct { + OrderType order.Type + TimeInForce order.TimeInForce + StopDirection string + BaseAmount float64 + QuoteAmount float64 + LimitPrice float64 + StopPrice float64 + BucketSize float64 + EndTime time.Time + PostOnly bool + RFQDisabled bool + BucketNumber int64 + BucketDuration time.Duration +} + +// PlaceOrderInfo is a struct used in the formation of requests in PlaceOrder +type PlaceOrderInfo struct { + ClientOID string + ProductID string + Side string + MarginType string + RetailPortfolioID string + PreviewID string + Leverage float64 + AttachedOrderConfiguration OrderConfiguration + OrderInfo +} + +// SuccessFailureConfig contains information on an order, returned by PlaceOrder +type SuccessFailureConfig struct { + Success bool `json:"success"` + SuccessResponse SuccessResponse `json:"success_response"` + OrderConfiguration OrderConfiguration `json:"order_configuration"` +} + +// OrderCancelDetail contains information on attempted order cancellations, returned by CancelOrders +type OrderCancelDetail struct { + Success bool `json:"success"` + FailureReason string `json:"failure_reason"` + OrderID string `json:"order_id"` +} + +type cancelOrdersReqBase struct { + OrderIDs []string `json:"order_ids"` +} + +type closePositionReqBase struct { + ClientOrderID string `json:"client_order_id"` + ProductID currency.Pair `json:"product_id"` + Size float64 `json:"size,string"` +} + +type placeOrderReqbase struct { + ClientOID string `json:"client_order_id"` + ProductID string `json:"product_id"` + Side string `json:"side"` + OrderConfiguration *OrderConfiguration `json:"order_configuration"` + RetailPortfolioID string `json:"retail_portfolio_id"` + PreviewID string `json:"preview_id"` + AttachedOrderConfiguration *OrderConfiguration `json:"attached_order_configuration"` + MarginType string `json:"margin_type,omitempty"` + Leverage float64 `json:"leverage,omitempty,string"` +} + +type editOrderReqBase struct { + OrderID string `json:"order_id"` + Size float64 `json:"size,string"` + Price float64 `json:"price,string"` +} + +// EditOrderPreviewResp contains information on the effects of editing an order, returned by EditOrderPreview +type EditOrderPreviewResp struct { + Slippage types.Number `json:"slippage"` + OrderTotal types.Number `json:"order_total"` + CommissionTotal types.Number `json:"commission_total"` + QuoteSize types.Number `json:"quote_size"` + BaseSize types.Number `json:"base_size"` + BestBid types.Number `json:"best_bid"` + BestAsk types.Number `json:"best_ask"` + AverageFilledPrice types.Number `json:"average_filled_price"` + OrderMarginTotal types.Number `json:"order_margin_total"` +} + +// EditHistory is a sub-struct used in the type GetOrderResponse +type EditHistory struct { + Price types.Number `json:"price"` + Size types.Number `json:"size"` + ReplaceAcceptTimestamp time.Time `json:"replace_accept_timestamp"` +} + +// GetOrderResponse contains information on an order, returned by GetOrderByID IterativeGetAllOrders, and used in GetAllOrdersResp +type GetOrderResponse struct { + OrderID string `json:"order_id"` + ProductID currency.Pair `json:"product_id"` + UserID string `json:"user_id"` + OrderConfiguration OrderConfiguration `json:"order_configuration"` + Side string `json:"side"` + ClientOID string `json:"client_order_id"` + Status string `json:"status"` + TimeInForce string `json:"time_in_force"` + CreatedTime time.Time `json:"created_time"` + CompletionPercentage types.Number `json:"completion_percentage"` + FilledSize types.Number `json:"filled_size"` + AverageFilledPrice types.Number `json:"average_filled_price"` + Fee types.Number `json:"fee"` + NumberOfFills int64 `json:"num_fills,string"` + FilledValue types.Number `json:"filled_value"` + PendingCancel bool `json:"pending_cancel"` + SizeInQuote bool `json:"size_in_quote"` + TotalFees types.Number `json:"total_fees"` + SizeInclusiveOfFees bool `json:"size_inclusive_of_fees"` + TotalValueAfterFees types.Number `json:"total_value_after_fees"` + TriggerStatus string `json:"trigger_status"` + OrderType string `json:"order_type"` + RejectReason string `json:"reject_reason"` + Settled bool `json:"settled"` + ProductType string `json:"product_type"` + RejectMessage string `json:"reject_message"` + CancelMessage string `json:"cancel_message"` + OrderPlacementSource string `json:"order_placement_source"` + OutstandingHoldAmount types.Number `json:"outstanding_hold_amount"` + IsLiquidation bool `json:"is_liquidation"` + LastFillTime time.Time `json:"last_fill_time"` + EditHistory []EditHistory `json:"edit_history"` + Leverage types.Number `json:"leverage"` + MarginType string `json:"margin_type"` + RetailPortfolioID string `json:"retail_portfolio_id"` + OriginatingOrderID string `json:"originating_order_id"` + AttachedOrderID string `json:"attached_order_id"` + AttachedOrderConfiguration OrderConfiguration `json:"attached_order_configuration"` + CurrentPendingReplace json.RawMessage `json:"current_pending_replace"` + CommissionDetailTotal json.RawMessage `json:"commission_detail_total"` +} + +// Fills is a sub-struct used in the type FillResponse +type Fills struct { + EntryID string `json:"entry_id"` + TradeID string `json:"trade_id"` + OrderID string `json:"order_id"` + TradeTime time.Time `json:"trade_time"` + TradeType string `json:"trade_type"` + Price types.Number `json:"price"` + Size types.Number `json:"size"` + Commission types.Number `json:"commission"` + ProductID currency.Pair `json:"product_id"` + SequenceTimestamp time.Time `json:"sequence_timestamp"` + LiquidityIndicator string `json:"liquidity_indicator"` + SizeInQuote bool `json:"size_in_quote"` + UserID string `json:"user_id"` + Side string `json:"side"` + RetailPortfolioID string `json:"retail_portfolio_id"` + FillSource string `json:"fill_source"` + CommissionDetailTotal json.RawMessage `json:"commission_detail_total"` +} + +// FillResponse contains fill information, returned by ListFills +type FillResponse struct { + Fills []Fills `json:"fills"` + Cursor Integer `json:"cursor"` +} + +// PreviewOrderInfo is a struct used in the formation of requests in PreviewOrder +type PreviewOrderInfo struct { + ProductID string + Side string + MarginType string + RetailPortfolioID string + Leverage float64 + AttachedOrderConfiguration OrderConfiguration + OrderInfo +} + +// TriggerBracketPNL is a sub-struct used in the type PreviewOrderResp +type TriggerBracketPNL struct { + TakeProfitPNL types.Number `json:"take_profit_pnl"` + StopLossPNL types.Number `json:"stop_loss_pnl"` +} + +// TWAPBucketMetadata is a sub-struct used in the type PreviewOrderResp +type TWAPBucketMetadata struct { + BucketDuration time.Duration `json:"bucket_duration"` + BucketSize types.Number `json:"bucket_size"` + BucketNumber Integer `json:"bucket_number"` +} + +// MarginRatioData is a sub-struct used in the type PreviewOrderResp +type MarginRatioData struct { + CurrentMarginRatio types.Number `json:"current_margin_ratio"` + ProjectedMarginRatio types.Number `json:"projected_margin_ratio"` +} + +// PreviewOrderResp contains information on the effects of placing an order, returned by PreviewOrder +type PreviewOrderResp struct { + OrderTotal types.Number `json:"order_total"` + CommissionTotal types.Number `json:"commission_total"` + Errs []string `json:"errs"` + Warning []string `json:"warning"` + QuoteSize types.Number `json:"quote_size"` + BaseSize types.Number `json:"base_size"` + BestBid types.Number `json:"best_bid"` + BestAsk types.Number `json:"best_ask"` + IsMax bool `json:"is_max"` + OrderMarginTotal types.Number `json:"order_margin_total"` + Leverage types.Number `json:"leverage"` + LongLeverage types.Number `json:"long_leverage"` + ShortLeverage types.Number `json:"short_leverage"` + Slippage types.Number `json:"slippage"` + PreviewID string `json:"preview_id"` + CurrentLiquidationBuffer types.Number `json:"current_liquidation_buffer"` + ProjectedLiquidationBuffer types.Number `json:"projected_liquidation_buffer"` + MaxLeverage types.Number `json:"max_leverage"` + PNLConfiguration TriggerBracketPNL `json:"pnl_configuration"` + TWAPBucketMetadata TWAPBucketMetadata `json:"twap_bucket_metadata"` + PositionNotionalLimit types.Number `json:"position_notional_limit"` + MaxNotionalAtRequestedLeverage types.Number `json:"max_notional_at_requested_leverage"` + MarginRatioData MarginRatioData `json:"margin_ratio_data"` + CommissionDetailTotal json.RawMessage `json:"commission_detail_total"` +} + +type previewOrderReqBase struct { + ProductID string `json:"product_id"` + Side string `json:"side"` + OrderConfiguration *OrderConfiguration `json:"order_configuration"` + RetailPortfolioID string `json:"retail_portfolio_id"` + Leverage float64 `json:"leverage,string"` + AttachedOrderConfiguration *OrderConfiguration `json:"attached_order_configuration"` + MarginType string `json:"margin_type,omitempty"` +} + +// SimplePortfolioData is a sub-struct used in the type DetailedPortfolioResponse +type SimplePortfolioData struct { + Name string `json:"name"` + UUID string `json:"uuid"` + Type string `json:"type"` + Deleted bool `json:"deleted"` +} + +type nameReqBase struct { + Name string `json:"name"` +} + +// MovePortfolioFundsResponse contains the UUIDs of the portfolios involved. Returned by MovePortfolioFunds +type MovePortfolioFundsResponse struct { + SourcePortfolioUUID string `json:"source_portfolio_uuid"` + TargetPortfolioUUID string `json:"target_portfolio_uuid"` +} + +type fundsData struct { + Value float64 `json:"value,string"` + Currency currency.Code `json:"currency"` +} + +type movePortfolioFundsReqBase struct { + SourcePortfolioUUID string `json:"source_portfolio_uuid"` + TargetPortfolioUUID string `json:"target_portfolio_uuid"` + Funds fundsData `json:"funds"` +} + +// NativeAndRaw is a sub-struct used in the type DetailedPortfolioResponse +type NativeAndRaw struct { + UserNativeCurrency CurrencyAmount `json:"userNativeCurrency"` + RawCurrency CurrencyAmount `json:"rawCurrency"` +} + +// PortfolioBalances is a sub-struct used in the type DetailedPortfolioResponse +type PortfolioBalances struct { + TotalBalance CurrencyAmount `json:"total_balance"` + TotalFuturesBalance CurrencyAmount `json:"total_futures_balance"` + TotalCashEquivalentBalance CurrencyAmount `json:"total_cash_equivalent_balance"` + TotalCryptoBalance CurrencyAmount `json:"total_crypto_balance"` + FuturesUnrealizedPNL CurrencyAmount `json:"futures_unrealized_pnl"` + PerpUnrealizedPNL CurrencyAmount `json:"perp_unrealized_pnl"` +} + +// SpotPositions is a sub-struct used in the type DetailedPortfolioResponse +type SpotPositions struct { + Asset string `json:"asset"` + AccountUUID string `json:"account_uuid"` + TotalBalanceFiat float64 `json:"total_balance_fiat"` + TotalBalanceCrypto float64 `json:"total_balance_crypto"` + AvailableToTreadeFiat float64 `json:"available_to_trade_fiat"` + Allocation float64 `json:"allocation"` + OneDayChange float64 `json:"one_day_change"` + CostBasis CurrencyAmount `json:"cost_basis"` + AssetImgURL string `json:"asset_img_url"` + IsCash bool `json:"is_cash"` +} + +// PerpPositions is a sub-struct used in the type DetailedPortfolioResponse +type PerpPositions struct { + ProductID currency.Pair `json:"product_id"` + ProductUUID string `json:"product_uuid"` + Symbol string `json:"symbol"` + AssetImageURL string `json:"asset_image_url"` + VWAP NativeAndRaw `json:"vwap"` + PositionSide string `json:"position_side"` + NetSize types.Number `json:"net_size"` + BuyOrderSize types.Number `json:"buy_order_size"` + SellOrderSize types.Number `json:"sell_order_size"` + IMContribution types.Number `json:"im_contribution"` + UnrealizedPNL NativeAndRaw `json:"unrealized_pnl"` + MarkPrice NativeAndRaw `json:"mark_price"` + LiquidationPrice NativeAndRaw `json:"liquidation_price"` + Leverage types.Number `json:"leverage"` + IMNotional NativeAndRaw `json:"im_notional"` + MMNotional NativeAndRaw `json:"mm_notional"` + PositionNotional NativeAndRaw `json:"position_notional"` + MarginType string `json:"margin_type"` + LiquidationBuffer types.Number `json:"liquidation_buffer"` + LiquidationPercentage types.Number `json:"liquidation_percentage"` +} + +// FuturesPositions is a sub-struct used in the type DetailedPortfolioResponse +type FuturesPositions []struct { + ProductID currency.Pair `json:"product_id"` + ContractSize types.Number `json:"contract_size"` + Side string `json:"side"` + Amount types.Number `json:"amount"` + AvgEntryPrice types.Number `json:"avg_entry_price"` + CurrentPrice types.Number `json:"current_price"` + UnrealizedPNL types.Number `json:"unrealized_pnl"` + Expiry time.Time `json:"expiry"` + UnderlyingAsset string `json:"underlying_asset"` + AssetImgURL string `json:"asset_img_url"` + ProductName string `json:"product_name"` + Venue string `json:"venue"` + NotionalValue types.Number `json:"notional_value"` +} + +// DetailedPortfolioResponse contains a great deal of information on a single portfolio. Returned by GetPortfolioByID +type DetailedPortfolioResponse struct { + Portfolio SimplePortfolioData `json:"portfolio"` + PortfolioBalances PortfolioBalances `json:"portfolio_balances"` + SpotPositions []SpotPositions `json:"spot_positions"` + PerpPositions []PerpPositions `json:"perp_positions"` + FuturesPositions []FuturesPositions `json:"futures_positions"` +} + +// MarginWindowMeasurement is a sub-struct used in the type FuturesBalanceSummary +type MarginWindowMeasurement struct { + MarginWindowType string `json:"margin_window_type"` + MarginLevel string `json:"margin_level"` + InitialMargin string `json:"initial_margin"` + MaintenanceMargin string `json:"maintenance_margin"` + LiquidationBuffer string `json:"liquidation_buffer"` + TotalHold string `json:"total_hold_amount"` + FuturesBuyingPower string `json:"futures_buying_power"` +} + +// FuturesBalanceSummary contains information on futures balances, returned by GetFuturesBalanceSummary +type FuturesBalanceSummary struct { + FuturesBuyingPower CurrencyAmount `json:"futures_buying_power"` + TotalUSDBalance CurrencyAmount `json:"total_usd_balance"` + CBIUSDBalance CurrencyAmount `json:"cbi_usd_balance"` + CFMUSDBalance CurrencyAmount `json:"cfm_usd_balance"` + TotalOpenOrdersHoldAmount CurrencyAmount `json:"total_open_orders_hold_amount"` + UnrealizedPNL CurrencyAmount `json:"unrealized_pnl"` + DailyRealizedPNL CurrencyAmount `json:"daily_realized_pnl"` + InitialMargin CurrencyAmount `json:"initial_margin"` + AvailableMargin CurrencyAmount `json:"available_margin"` + LiquidationThreshold CurrencyAmount `json:"liquidation_threshold"` + LiquidationBufferAmount CurrencyAmount `json:"liquidation_buffer_amount"` + LiquidationBufferPercentage types.Number `json:"liquidation_buffer_percentage"` + IntradayMarginWindowMeasurement MarginWindowMeasurement `json:"intraday_margin_window_measure"` + OvernightMarginWindowMeasurement MarginWindowMeasurement `json:"overnight_margin_window_measure"` +} + +// FuturesPosition contains information on a single futures position, returned by GetFuturesPositionByID and ListFuturesPositions +type FuturesPosition struct { + ProductID currency.Pair `json:"product_id"` + ExpirationTime time.Time `json:"expiration_time"` + Side string `json:"side"` + NumberOfContracts types.Number `json:"number_of_contracts"` + CurrentPrice types.Number `json:"current_price"` + AverageEntryPrice types.Number `json:"avg_entry_price"` + UnrealizedPNL types.Number `json:"unrealized_pnl"` + DailyRealizedPNL types.Number `json:"daily_realized_pnl"` +} + +// SweepData contains information on pending and processing sweep requests, returned by ListFuturesSweeps +type SweepData struct { + ID string `json:"id"` + RequestedAmount CurrencyAmount `json:"requested_amount"` + ShouldSweepAll bool `json:"should_sweep_all"` + Status string `json:"status"` + ScheduledTime time.Time `json:"scheduled_time"` +} + +// PerpPositionSummary contains information on perpetuals portfolio balances, used as a sub-struct in the types PerpPositionDetail, AllPerpPosResponse, and OnePerpPosResponse +type PerpPositionSummary struct { + PortfolioUUID string `json:"portfolio_uuid"` + Collateral types.Number `json:"collateral"` + PositionNotional types.Number `json:"position_notional"` + OpenPositionNotional types.Number `json:"open_position_notional"` + PendingFees types.Number `json:"pending_fees"` + Borrow types.Number `json:"borrow"` + AccruedInterest types.Number `json:"accrued_interest"` + RollingDebt types.Number `json:"rolling_debt"` + PortfolioInitialMargin types.Number `json:"portfolio_initial_margin"` + PortfolioIMNotional CurrencyAmount `json:"portfolio_im_notional"` + PortfolioMaintenanceMargin types.Number `json:"portfolio_maintenance_margin"` + PortfolioMMNotional CurrencyAmount `json:"portfolio_mm_notional"` + LiquidationPercentage types.Number `json:"liquidation_percentage"` + LiquidationBuffer types.Number `json:"liquidation_buffer"` + MarginType string `json:"margin_type"` + MarginFlags string `json:"margin_flags"` + LiquidationStatus string `json:"liquidation_status"` + UnrealizedPNL CurrencyAmount `json:"unrealized_pnl"` + BuyingPower CurrencyAmount `json:"buying_power"` // Not in the GetPerpetualsPortfolioSummary response + TotalBalance CurrencyAmount `json:"total_balance"` + MaxWithdrawal CurrencyAmount `json:"max_withdrawal"` // Not in the GetPerpetualsPortfolioSummary response +} + +// PerpetualPortfolioSummary contains information on perpetuals portfolio balances, used as a sub-struct in the type PerpetualPortfolioResponse +type PerpetualPortfolioSummary struct { + UnrealisedPNL CurrencyAmount `json:"unrealized_pnl"` + BuyingPower CurrencyAmount `json:"buying_power"` + TotalBalance CurrencyAmount `json:"total_balance"` + MaximumWithdrawalAmount CurrencyAmount `json:"maximum_withdrawal_amount"` +} + +// PerpetualPortfolioResponse contains information on perpetuals portfolio balances, returned by GetPerpetualsPortfolioSummary +type PerpetualPortfolioResponse struct { + Portfolios []PerpPositionSummary `json:"portfolios"` + Summary PerpetualPortfolioSummary `json:"summary"` +} + +// PerpPositionDetail contains information on a single perpetuals position, used as a sub-struct in the type AllPerpPosResponse, and returned by GetPerpetualsPositionByID +type PerpPositionDetail struct { + ProductID currency.Pair `json:"product_id"` + ProductUUID string `json:"product_uuid"` + PortfolioUUID string `json:"portfolio_uuid"` + Symbol string `json:"symbol"` + VWAP CurrencyAmount `json:"vwap"` + EntryVWAP CurrencyAmount `json:"entry_vwap"` + PositionSide string `json:"position_side"` + MarginType string `json:"margin_type"` + NetSize types.Number `json:"net_size"` + BuyOrderSize types.Number `json:"buy_order_size"` + SellOrderSize types.Number `json:"sell_order_size"` + IMContribution types.Number `json:"im_contribution"` + UnrealizedPNL CurrencyAmount `json:"unrealized_pnl"` + MarkPrice CurrencyAmount `json:"mark_price"` + LiquidationPrice CurrencyAmount `json:"liquidation_price"` + Leverage types.Number `json:"leverage"` + IMNotional CurrencyAmount `json:"im_notional"` + MMNotional CurrencyAmount `json:"mm_notional"` + PositionNotional CurrencyAmount `json:"position_notional"` + AggregatedPNL CurrencyAmount `json:"aggregated_pnl"` + LiquidationBuffer types.Number `json:"liquidation_buffer"` // Not in GetPerpetualsPositionByID + LiquidationPercentage types.Number `json:"liquidation_percentage"` // Not in GetPerpetualsPositionByID + PortfolioSummary PerpPositionSummary `json:"portfolio_summary"` // Not in GetPerpetualsPositionByID +} + +// PortfolioAsset contains information on a single portfolio asset, used as a sub-struct in the type PortfolioBalance +type PortfolioAsset struct { + AssetID string `json:"asset_id"` + AssetUUID string `json:"asset_uuid"` + AssetName string `json:"asset_name"` + Status string `json:"status"` + CollateralWeight types.Number `json:"collateral_weight"` + AccountCollateralLimit types.Number `json:"account_collateral_limit"` + EcosystemCollateralLimitBreached bool `json:"ecosystem_collateral_limit_breached"` + AssetIconURL string `json:"asset_icon_url"` + SupportedNetworksEnabled bool `json:"supported_networks_enabled"` +} + +// PortfolioBalance contains information on a single portfolio balance, used as a sub-struct in the type PortfolioBalancesResponse +type PortfolioBalance struct { + Asset PortfolioAsset `json:"asset"` + Quantity types.Number `json:"quantity"` + Hold types.Number `json:"hold"` + TransferHold types.Number `json:"transfer_hold"` + CollateralValue types.Number `json:"collateral_value"` + CollateralWeight types.Number `json:"collateral_weight"` + MaxWithdrawAmount types.Number `json:"max_withdraw_amount"` + Loan types.Number `json:"loan"` + LoanCollateralRequirementUSD types.Number `json:"loan_collateral_requirement_usd"` + PledgedQuantity types.Number `json:"pledged_quantity"` +} + +// PortfolioBalancesResponse contains information on a portfolio's balances, returned by GetPortfolioBalances +type PortfolioBalancesResponse struct { + PortfolioUUID string `json:"portfolio_uuid"` + Balances []PortfolioBalance `json:"balances"` + IsMarginLimitReached bool `json:"is_margin_limit_reached"` +} + +// PerpetualSummary contains information on a perpetual position's summary, used as a sub-struct in the type AllPerpPosResponse +type PerpetualSummary struct { + AggregatedPNL CurrencyAmount `json:"aggregated_pnl"` +} + +// AllPerpPosResponse contains information on perpetuals positions, returned by GetAllPerpetualsPositions +type AllPerpPosResponse struct { + Positions []PerpPositionDetail `json:"positions"` + Summary PerpetualSummary `json:"summary"` +} + +type assetCollateralToggleReqBase struct { + PortfolioUUID string `json:"portfolio_uuid"` + Enabled bool `json:"multi_asset_collateral_enabled"` +} + +// FeeTier is a sub-struct used in the type TransactionSummary +type FeeTier struct { + PricingTier string `json:"pricing_tier"` + USDFrom types.Number `json:"usd_from"` + USDTo types.Number `json:"usd_to"` + TakerFeeRate types.Number `json:"taker_fee_rate"` + MakerFeeRate types.Number `json:"maker_fee_rate"` + AOPFrom types.Number `json:"aop_from"` + AOPTo types.Number `json:"aop_to"` + PerpsVolFrom types.Number `json:"perps_vol_from"` + PerpsVolTo types.Number `json:"perps_vol_to"` +} + +// MarginRate is a sub-struct used in the type TransactionSummary +type MarginRate struct { + Value types.Number `json:"value"` +} + +// GoodsAndServicesTax is a sub-struct used in the type TransactionSummary +type GoodsAndServicesTax struct { + Rate types.Number `json:"rate"` + Type string `json:"type"` +} + +// TransactionSummary contains a summary of transaction fees, volume, and the like. Returned by GetTransactionSummary +type TransactionSummary struct { + TotalVolume float64 `json:"total_volume"` + TotalFees float64 `json:"total_fees"` + FeeTier FeeTier `json:"fee_tier"` + MarginRate MarginRate `json:"margin_rate"` + GoodsAndServicesTax GoodsAndServicesTax `json:"goods_and_services_tax"` + AdvancedTradeOnlyVolume float64 `json:"advanced_trade_only_volume"` + AdvancedTradeOnlyFees float64 `json:"advanced_trade_only_fees"` + CoinbaseProVolume float64 `json:"coinbase_pro_volume"` + CoinbaseProFees float64 `json:"coinbase_pro_fees"` + TotalBalance types.Number `json:"total_balance"` + HasPromoFee bool `json:"has_promo_fee"` +} + +// ListOrdersReq contains the parameters for the ListOrders request +type ListOrdersReq struct { + OrderIDs []string + OrderStatus []string + TimeInForces []string + OrderTypes []string + AssetFilters []string + ProductIDs currency.Pairs + ProductType string + OrderSide string + OrderPlacementSource string + ContractExpiryType string + RetailPortfolioID string + SortBy string + Cursor int64 + Limit int32 + StartDate time.Time + EndDate time.Time + UserNativeCurrency currency.Code +} + +// ListOrdersResp contains information on a lot of orders, returned by ListOrders +type ListOrdersResp struct { + Orders []GetOrderResponse `json:"orders"` + Sequence Integer `json:"sequence"` + HasNext bool `json:"has_next"` + Cursor Integer `json:"cursor"` +} + +// LinkStruct is a sub-struct storing information on links, used in Disclosure and ConvertResponse +type LinkStruct struct { + Text string `json:"text"` + URL string `json:"url"` +} + +// Disclosure is a sub-struct used in FeeStruct +type Disclosure struct { + Title string `json:"title"` + Description string `json:"description"` + Link LinkStruct `json:"link"` +} + +// WaivedDetails is a sub-struct used in FeeStruct +type WaivedDetails struct { + Amount CurrencyAmount `json:"amount"` + Source string `json:"source"` +} + +// FeeStruct is a sub-struct storing information on fees, used in ConvertResponse +type FeeStruct struct { + Title string `json:"title"` + Description string `json:"description"` + Amount CurrencyAmount `json:"amount"` + Label string `json:"label"` + Disclosure Disclosure `json:"disclosure"` + WaivedDetails WaivedDetails `json:"waived_details"` +} + +// AccountID is a sub-struct, used in AccountStruct +type AccountID struct { + AccountID string `json:"account_id"` +} + +// HashWithHeight is a sub-struct, used in AccountStruct +type HashWithHeight struct { + Hash string `json:"hsh"` + Height int32 `json:"height"` +} + +// AddressHolder is a sub-struct, used in AccountStruct and FedWireInstitution +type AddressHolder struct { + Lines []string `json:"lines"` + CountryCode string `json:"country_code"` +} + +// FedAccountHolder is a sub-struct, used in Fedwire +type FedAccountHolder struct { + LegalName string `json:"legal_name"` + AccountNumber string `json:"account_number"` + Address AddressHolder `json:"address"` +} + +// FedWireInstitution is a sub-struct, used in Fedwire +type FedWireInstitution struct { + Name string `json:"name"` + Address AddressHolder `json:"address"` + Identifier string `json:"identifier"` + Type string `json:"type"` + IdentifierCode string `json:"identifier_code"` +} + +// Fedwire is a sub-struct, used in AccountStruct +type Fedwire struct { + RoutingNumber string `json:"routing_number"` + AccountHolder FedAccountHolder `json:"account_holder"` + Bank FedWireInstitution `json:"bank"` + IntermediaryBank FedWireInstitution `json:"intermediary_bank"` +} + +// SwiftAccountHolder is a sub-struct, used in Swift +type SwiftAccountHolder struct { + LegalName string `json:"legal_name"` + IBAN string `json:"iban"` + BBAN string `json:"bban"` + DomesticAccountID string `json:"domestic_account_id"` + CustomerPaymentAddress1 string `json:"customer_payment_address1"` + CustomerPaymentAddress2 string `json:"customer_payment_address2"` + CustomerPaymentAddress3 string `json:"customer_payment_address3"` + CustomerPaymentCountryCode string `json:"customer_payment_country_code"` +} + +// SwiftInstitution is a sub-struct, used in Swift +type SwiftInstitution struct { + BIC string `json:"bic"` + Name string `json:"name"` + BankAddress1 string `json:"bank_address1"` + BankAddress2 string `json:"bank_address2"` + BankAddress3 string `json:"bank_address3"` + BankCountryCode string `json:"bank_country_code"` + DomesticBankID string `json:"domestic_bank_id"` + InternationalBankID string `json:"international_bank_id"` +} + +// Swift is a sub-struct, used in AccountStruct +type Swift struct { + AccountHolder SwiftAccountHolder `json:"account_holder"` + Institution SwiftInstitution `json:"institution"` + Intermediary SwiftInstitution `json:"intermediary"` +} + +// ValueWithStoreID is a sub-struct, used in CardInfo +type ValueWithStoreID struct { + Value string `json:"value"` + StoreID string `json:"store_id"` +} + +// MonthYear is a sub-struct, used in CardInfo +type MonthYear struct { + Month string `json:"month"` + Year string `json:"year"` +} + +// MerchantID is a sub-struct, used in CardInfo +type MerchantID struct { + MerchantID string `json:"mid"` +} + +// VaultToken is a sub-struct, used in CardInfo +type VaultToken struct { + Value string `json:"value"` + VaultID string `json:"vault_id"` +} + +// WorldplayParams is a sub-struct, used in CardInfo +type WorldplayParams struct { + TokenValue string `json:"token_value"` + UsesMerchantToken bool `json:"uses_merchant_token"` + AcceptHeader string `json:"accept_header"` + UserAgentHeader string `json:"user_agent_header"` + ShopperIP string `json:"shopper_ip"` + ShopperSessionID string `json:"shopper_session_id"` +} + +// MostlyFullAddress is a sub-struct, used in CardInfo and UKAccountHolder +type MostlyFullAddress struct { + Address1 string `json:"address1"` + Address2 string `json:"address2"` + City string `json:"city"` + State string `json:"state"` + PostalCode string `json:"postal_code"` + Country string `json:"country"` +} + +// FullDate is a sub-struct, used in CardInfo +type FullDate struct { + Month string `json:"month"` + Day string `json:"day"` + Year string `json:"year"` +} + +// SourceID is a sub-struct, used in CardInfo +type SourceID struct { + SourceID string `json:"source_id"` +} + +// CardInfo is a sub-struct, used in AccountStruct +type CardInfo struct { + FirstDataToken ValueWithStoreID `json:"first_data_token"` + ExpiryDate MonthYear `json:"expiry_date"` + PostalCode string `json:"postal_code"` + Merchant MerchantID `json:"merchant"` + VaultToken VaultToken `json:"vault_token"` + WorldpayParams WorldplayParams `json:"worldpay_params"` + PreviousSchemeTransactionID string `json:"previous_scheme_tx_id"` + CustomerName string `json:"customer_name"` + Address MostlyFullAddress `json:"address"` + PhoneNumber string `json:"phone_number"` + UserID string `json:"user_id"` + CustomerFirstName string `json:"customer_first_name"` + CustomerLastName string `json:"customer_last_name"` + SixDigitBin string `json:"six_digit_bin"` + CustomerDateOfBirth FullDate `json:"customer_dob"` + Scheme string `json:"scheme"` + EightDigitBin string `json:"eight_digit_bin"` + CheckoutToken SourceID `json:"checkout_token"` +} + +// ZenginAccountHolder is a sub-struct, used in Zengin +type ZenginAccountHolder struct { + LegalName string `json:"legal_name"` + Identifier string `json:"identifier"` + Type string `json:"type"` +} + +// BankAndBranchCode is a sub-struct, used in Zengin +type BankAndBranchCode struct { + BankCode string `json:"bank_code"` + BranchCode string `json:"branch_code"` +} + +// Zengin is a sub-struct, used in AccountStruct +type Zengin struct { + AccountHolder ZenginAccountHolder `json:"account_holder"` + Institution BankAndBranchCode `json:"institution"` +} + +// UKAccountHolder is a sub-struct, used in UKAccount +type UKAccountHolder struct { + LegalName string `json:"legal_name"` + BBAN string `json:"bban"` + SortCode string `json:"sort_code"` + AccountNumber string `json:"account_number"` + Address MostlyFullAddress `json:"address"` +} + +// Name is a sub-struct, used in UKAccount and AccountStruct +type Name struct { + Name string `json:"name"` +} + +// UKAccount is a sub-struct, used in AccountStruct +type UKAccount struct { + AccountHolder UKAccountHolder `json:"account_holder"` + Institution Name `json:"institution"` + CustomerFirstName string `json:"customer_first_name"` + CustomerLastName string `json:"customer_last_name"` + Email string `json:"email"` + PhoneNumber string `json:"phone_number"` +} + +// SEPAAccountHolder is a sub-struct, used in SEPA +type SEPAAccountHolder struct { + LegalName string `json:"legal_name"` + IBAN string `json:"iban"` + BBAN string `json:"bban"` +} + +// BICWithName is a sub-struct, used in SEPA +type BICWithName struct { + BIC string `json:"bic"` + Name string `json:"name"` +} + +// SEPA is a sub-struct, used in AccountStruct +type SEPA struct { + AccountHolder SEPAAccountHolder `json:"account_holder"` + Institution BICWithName `json:"institution"` + CustomerFirstName string `json:"customer_first_name"` + CustomerLastName string `json:"customer_last_name"` + Email string `json:"email"` + PhoneNumber string `json:"phone_number"` +} + +// PayPalAccountHolder is a sub-struct, used in PayPal +type PayPalAccountHolder struct { + PayPalID string `json:"paypal_id"` + PayPalPMID string `json:"paypal_pm_id"` +} + +// MerchantAccountID is a sub-struct, used in PayPal +type MerchantAccountID struct { + MerchantAccountID string `json:"merchant_account_id"` +} + +// PayPalCorrelationID is a sub-struct, used in PayPal +type PayPalCorrelationID struct { + PayPalCorrelationID string `json:"paypal_correlation_id"` +} + +// PayPal is a sub-struct, used in AccountStruct +type PayPal struct { + AccountHolder PayPalAccountHolder `json:"account_holder"` + Merchant MerchantAccountID `json:"merchant"` + Metadata PayPalCorrelationID `json:"metadata"` +} + +// Owner is a sub-struct, used in LedgerAccount +type Owner struct { + ID string `json:"id"` + UUID string `json:"uuid"` + UserUUID string `json:"user_uuid"` + Type string `json:"type"` +} + +// LedgerAccount is a sub-struct, used in AccountStruct and AccountHolder +type LedgerAccount struct { + AccountID string `json:"account_id"` + Currency string `json:"currency"` + Owner Owner `json:"owner"` +} + +// PaymentMethodID is a sub-struct, used in AccountStruct and AccountHolder +type PaymentMethodID struct { + PaymentMethodID string `json:"payment_method_id"` +} + +// ProAccount is a sub-struct, used in AccountStruct +type ProAccount struct { + AccountID string `json:"account_id"` + CoinbaseAccountID string `json:"coinbase_account_id"` + UserID string `json:"user_id"` + Currency string `json:"currency"` + PortfolioID string `json:"portfolio_id"` +} + +// RTPAccountHolder is a sub-struct, used in RTP +type RTPAccountHolder struct { + LegalName string `json:"legal_name"` + Identifier string `json:"identifier"` +} + +// RoutingNumber is a sub-struct, used in RTP +type RoutingNumber struct { + RoutingNumber string `json:"routing_number"` +} + +// RTP is a sub-struct, used in AccountStruct +type RTP struct { + AccountHolder RTPAccountHolder `json:"account_holder"` + Institution RoutingNumber `json:"institution"` +} + +// LedgerNamedAccount is a sub-struct, used in AccountStruct +type LedgerNamedAccount struct { + Name string `json:"name"` + Currency string `json:"currency"` + ForeignNetwork string `json:"foreign_network"` +} + +// CustodialPool is a sub-struct, used in AccountStruct +type CustodialPool struct { + Name string `json:"name"` + Network string `json:"network"` + FiatID string `json:"fiat_id"` +} + +// NonceWithCorrelation is a sub-struct, used in ApplePay and GooglePay +type NonceWithCorrelation struct { + Nonce string `json:"nonce"` + CorrelationID string `json:"correlation_id"` +} + +// ApplePay is a sub-struct, used in AccountStruct +type ApplePay struct { + BrainTree NonceWithCorrelation `json:"brain_tree"` + ApplePay NonceWithCorrelation `json:"apple_pay"` + UserID string `json:"user_id"` + PostalCode string `json:"postal_code"` + CustomerName string `json:"customer_name"` + Address MostlyFullAddress `json:"address"` + SixDigitBin string `json:"six_digit_bin"` + LastFour string `json:"last_four"` + IssuingCountry string `json:"issuing_country"` + IssuingBank string `json:"issuing_bank"` + ProductID string `json:"product_id"` + Scheme string `json:"scheme"` + Prepaid string `json:"prepaid"` + Debit string `json:"debit"` +} + +// UserUUIDWithCurrency is a sub-struct, used in AccountStruct +type UserUUIDWithCurrency struct { + UserUUID string `json:"user_uuid"` + Currency string `json:"currency"` +} + +// RemitlyAccountHolder is a sub-struct, used in Remitly +type RemitlyAccountHolder struct { + RecipientID string `json:"recipient_id"` + PayoutMethodType string `json:"payout_method_type"` +} + +// Remitly is a sub-struct, used in AccountStruct +type Remitly struct { + AccountHolder RemitlyAccountHolder `json:"account_holder"` +} + +// UserIDWithCurrency is a sub-struct, used in AccountStruct +type UserIDWithCurrency struct { + UserID string `json:"user_id"` + Currency string `json:"currency"` +} + +// DAPP is a sub-struct, used in AccountStruct +type DAPP struct { + UserUUID string `json:"user_uuid"` + Network string `json:"network"` + CohortID string `json:"cohort_id"` + SigningBackend string `json:"signing_backend"` + Currency string `json:"currency"` +} + +// GooglePay is a sub-struct, used in AccountStruct +type GooglePay struct { + BrainTree NonceWithCorrelation `json:"brain_tree"` + GooglePay NonceWithCorrelation `json:"google_pay"` + UserID string `json:"user_id"` + PostalCode string `json:"postal_code"` + CustomerName string `json:"customer_name"` + Address MostlyFullAddress `json:"address"` + SixDigitBin string `json:"six_digit_bin"` + LastFour string `json:"last_four"` + IssuingCountry string `json:"issuing_country"` + ProductID string `json:"product_id"` + Scheme string `json:"scheme"` + Prepaid string `json:"prepaid"` + Debit string `json:"debit"` +} + +// DAPPBlockchain is a sub-struct, used in AccountStruct +type DAPPBlockchain struct { + Network string `json:"network"` + Address string `json:"address"` + CohortID string `json:"cohort_id"` + UserUUID string `json:"user_uuid"` + Pool string `json:"pool"` +} + +// PhoneNumber is a sub-struct, used in AccountStruct and BancomatPay +type PhoneNumber struct { + PhoneNumber string `json:"phone_number"` +} + +// DenebUPI is a sub-struct, used in AccountStruct +type DenebUPI struct { + VPAID string `json:"vpa_id"` + CustomerFirstName string `json:"customer_first_name"` + CustomerLastName string `json:"customer_last_name"` + Email string `json:"email"` + PhoneNumber PhoneNumber `json:"phone_number"` + PAN string `json:"pan"` + Address MostlyFullAddress `json:"address"` +} + +// BankAccount is a sub-struct, used in AccountStruct +type BankAccount struct { + CustomerAccountType string `json:"customer_account_type"` + CustomerAccountNumber string `json:"customer_account_number"` + CustomerRoutingNumber string `json:"customer_routing_number"` + CustomerName string `json:"customer_name"` +} + +// NetworkWithAddress is a sub-struct, used in AccountStruct +type NetworkWithAddress struct { + Network string `json:"network"` + Address string `json:"address"` +} + +// DenebIMPS is a sub-struct, used in AccountStruct +type DenebIMPS struct { + IFSCCode string `json:"ifsc_code"` + AccountNumber string `json:"account_number"` + CustomerFirstName string `json:"customer_first_name"` + CustomerLastName string `json:"customer_last_name"` + Email string `json:"email"` + PhoneNumber PhoneNumber `json:"phone_number"` + PAN string `json:"pan"` + Address MostlyFullAddress `json:"address"` +} + +// Movements is a sub-struct, used in Legs +type Movements struct { + ID string `json:"id"` + SourceAccount LedgerAccount `json:"source_account"` + DestinationAccount LedgerAccount `json:"destination_account"` + Amount AmountWithCurrency `json:"amount"` +} + +// Legs is a sub-struct, used in Allocation +type Legs struct { + ID string `json:"id"` + Movements []Movements `json:"movements"` + IsNetted bool `json:"is_netted"` +} + +// Allocation is a sub-struct, used in AccountStruct +type Allocation struct { + ID string `json:"id"` + Legs []Legs `json:"legs"` + IsNetted bool `json:"is_netted"` +} + +// LiquidityPool is a sub-struct, used in AccountStruct +type LiquidityPool struct { + Network string `json:"network"` + Pool string `json:"pool"` + Currency string `json:"currency"` + AccountID string `json:"account_id"` + FromAddress string `json:"from_address"` +} + +// DirectDeposit is a sub-struct, used in AccountStruct +type DirectDeposit struct { + DirectDepositAccount string `json:"direct_deposit_account"` +} + +// NameAndIBAN is a sub-struct, used in SEPAV2 +type NameAndIBAN struct { + LegalName string `json:"legal_name"` + IBAN string `json:"iban"` +} + +// SEPAV2 is a sub-struct, used in AccountStruct +type SEPAV2 struct { + Account NameAndIBAN `json:"account"` + CustomerFirstName string `json:"customer_first_name"` + CustomerLastName string `json:"customer_last_name"` + Email string `json:"email"` + PhoneNumber PhoneNumber `json:"phone_number"` + CustomerCountry string `json:"customer_country"` + Address MostlyFullAddress `json:"address"` + SupportsOpenBanking bool `json:"supports_open_banking"` +} + +// ZeptoAccount is a sub-struct, used in Zepto +type ZeptoAccount struct { + ContactID string `json:"contact_id"` + BankAccountID string `json:"bank_account_id"` +} + +// Zepto is a sub-struct, used in AccountStruct +type Zepto struct { + Account ZeptoAccount `json:"account"` +} + +// TransactionWithAccount is a sub-struct, used in PixEBANX +type TransactionWithAccount struct { + TransactionID string `json:"transaction_id"` + AccountID string `json:"account_id"` +} + +// PixWithdrawal is a sub-struct, used in PixEBANX +type PixWithdrawal struct { + AccountNumber string `json:"account_number"` + AccountType string `json:"account_type"` + BankCode string `json:"bank_code"` + BranchNumber string `json:"branch_number"` + PixKey string `json:"pix_key"` +} + +// PixEBANX is a sub-struct, used in AccountStruct +type PixEBANX struct { + PaymentMethodID string `json:"payment_method_id"` + UserUUID string `json:"user_uuid"` + Deposit TransactionWithAccount `json:"deposit"` + Withdrawal PixWithdrawal `json:"withdrawal"` +} + +// Signet is a sub-struct, used in AccountStruct +type Signet struct { + SignetWalletID string `json:"signet_wallet_id"` +} + +// Settlement is a sub-struct, used in DerivativeSettlement +type Settlement struct { + Amount AmountWithCurrency `json:"amount"` + SourceLedgerAccount LedgerAccount `json:"source_ledger_account"` + SourceLedgerNamedAccount LedgerNamedAccount `json:"source_ledger_named_account"` + TargetLedgerAccount LedgerAccount `json:"target_ledger_account"` + TargetLedgerNamedAccount LedgerNamedAccount `json:"target_ledger_named_account"` + HoldIDToReplace string `json:"hold_id_to_replace"` + NewHoldID string `json:"new_hold_id"` + NewHoldAmount AmountWithCurrency `json:"new_hold_amount"` + ExistingHoldID string `json:"existing_hold_id"` +} + +// EquityReset is a sub-struct, used in DerivativeSettlement +type EquityReset struct { + Amount AmountWithCurrency `json:"amount"` + EquityAccount LedgerAccount `json:"equity_account"` +} + +// DerivativeSettlement is a sub-struct, used in AccountStruct +type DerivativeSettlement struct { + AccountSettlements []Settlement `json:"account_settlements"` + EquityReset EquityReset `json:"equity_reset"` +} + +// UserUUID is a sub-struct, used in AccountStruct +type UserUUID struct { + UserUUID string `json:"user_uuid"` +} + +// NameWithAccount is a sub-struct, used in SgFAST +type NameWithAccount struct { + CustomerName string `json:"customer_name"` + AccountNumber string `json:"account_number"` +} + +// BankCode is a sub-struct, used in SgFAST +type BankCode struct { + BankCode string `json:"bank_code"` +} + +// SgFAST is a sub-struct, used in AccountStruct +type SgFAST struct { + Account NameWithAccount `json:"account"` + Institution BankCode `json:"institution"` +} + +// InteracAccount is a sub-struct, used in Interac +type InteracAccount struct { + AccountName string `json:"account_name"` + InstitutionNumber string `json:"institution_number"` + TransitNumber string `json:"transit_number"` + AccountNumber string `json:"account_number"` +} + +// Interac is a sub-struct, used in AccountStruct +type Interac struct { + PmsvcID string `json:"pmsvc_id"` + Account InteracAccount `json:"account"` +} + +// IntraBank is a sub-struct, used in AccountStruct +type IntraBank struct { + Currency string `json:"currency"` + AccountNumber string `json:"account_number"` + RoutingNumber string `json:"routing_number"` + CustomerName string `json:"customer_name"` + FiatID string `json:"fiat_id"` +} + +// Cbit is a sub-struct, used in AccountStruct +type Cbit struct { + CbitWalletAddress string `json:"cbit_wallet_address"` + CusomtersBankAccountID string `json:"customers_bank_account_id"` +} + +// CustomerPaymentInfo is a sub-struct, used in AccountStruct +type CustomerPaymentInfo struct { + Currency string `json:"currency"` + IBAN string `json:"iban"` + BIC string `json:"bic"` + BankName string `json:"bank_name"` + CustomerPaymentName string `json:"customer_payment_name"` + CustomerCountryCode string `json:"customer_country_code"` +} + +// SgPayNow is a sub-struct, used in AccountStruct +type SgPayNow struct { + IdentifierType string `json:"identifier_type"` + Identifier string `json:"identifier"` + CustomerName string `json:"customer_name"` +} + +// PaymentLink is a sub-struct, used in AccountStruct +type PaymentLink struct { + PaymentLinkID string `json:"payment_link_id"` +} + +// StringValue is a sub-struct, used in AccountStruct +type StringValue struct { + Value string `json:"value"` +} + +// VendorPayment is a sub-struct, used in AccountStruct +type VendorPayment struct { + VendorName string `json:"vendor_name"` + VendorPaymentID string `json:"vendor_payment_id"` +} + +// IDString is a sub-struct, used in AccountStruct +type IDString struct { + ID string `json:"id"` +} + +// BancomatPay is a sub-struct, used in AccountStruct +type BancomatPay struct { + CustomerName string `json:"customer_name"` + Account PhoneNumber `json:"account"` +} + +// NovaAccount is a sub-struct, used in AccountStruct +type NovaAccount struct { + Network string `json:"network"` + NovaAccountID string `json:"nova_account_id"` + PoolName string `json:"pool_name"` + AccountIdempotencyKey string `json:"account_idempotency_key"` +} + +// IdempotencyString is a sub-struct, used in AccountStruct +type IdempotencyString struct { + Idempotency string `json:"idem"` +} + +// EFTAccount is a sub-struct, used in EFT +type EFTAccount struct { + AccountName string `json:"account_name"` + AccountPhoneNumber string `json:"account_phone_number"` + AccountEmail string `json:"account_email"` + InstitutionNumber string `json:"institution_number"` + TransitNumber string `json:"transit_number"` + AccountNumber string `json:"account_number"` +} + +// EFT is a sub-struct, used in AccountStruct +type EFT struct { + Account EFTAccount `json:"account"` +} + +// WallaceAccount is a sub-struct, used in AccountStruct +type WallaceAccount struct { + WallaceAccountID string `json:"wallace_account_id"` + PoolName string `json:"pool_name"` +} + +// ManualSettlement is a sub-struct, used in AccountStruct +type ManualSettlement struct { + SettlementBankName string `json:"settlement_bank_name"` + SettlementAccountNumber string `json:"settlement_account_number"` + Reference string `json:"reference"` +} + +// TaxWithCBU is a sub-struct, used in AccountStruct +type TaxWithCBU struct { + TaxID string `json:"tax_id"` + CBU string `json:"cbu"` +} + +// CurrencyString is a sub-struct, used in AccountStruct +type CurrencyString struct { + Currency string `json:"currency"` +} + +// BankingCircleNow is a sub-struct, used in AccountStruct +type BankingCircleNow struct { + IBAN string `json:"iban"` + Currency string `json:"currency"` + CustomerPaymentName string `json:"customer_payment_name"` +} + +// Trustly is a sub-struct, used in AccountStruct +type Trustly struct { + Country string `json:"country"` + IBAN string `json:"iban"` + AccountHolder string `json:"account_holder"` + BankCode string `json:"bank_code"` + AccountNumber string `json:"account_number"` + PartialAccountNumber string `json:"partial_account_number"` + BankName string `json:"bank_name"` + Email string `json:"email"` +} + +// Blik is a sub-struct, used in AccountStruct +type Blik struct { + Email string `json:"email"` + Country string `json:"country"` + AccountHolder string `json:"account_holder"` +} + +// PIX is a sub-struct, used in AccountStruct +type PIX struct { + AccountNumber string `json:"account_number"` + AccountType string `json:"account_type"` + BankCode string `json:"bank_code"` + BankName string `json:"bank_name"` + BranchNumber string `json:"branch_number"` + CustomerPaymentName string `json:"customer_payment_name"` + SenderDocument string `json:"sender_document"` + PIXKey string `json:"pix_key"` +} + +// AccountStruct is a sub-struct storing information on accounts, used in ConvertResponse +type AccountStruct struct { + Type string `json:"type"` + Network string `json:"network"` + PaymentMethodID string `json:"payment_method_id"` + BlockchainAddress AddressInfo `json:"blockchain_address"` + CoinbaseAccount AccountID `json:"coinbase_account"` + BlockchainTransaction HashWithHeight `json:"blockchain_transaction"` + Fedwire Fedwire `json:"fedwire"` + Swift Swift `json:"swift"` + Card CardInfo `json:"card"` + Zengin Zengin `json:"zengin"` + UK UKAccount `json:"uk"` + SEPA SEPA `json:"sepa"` + PayPal PayPal `json:"paypal"` + LedgerAccount LedgerAccount `json:"ledger_account"` + ExternalPaymentMethod PaymentMethodID `json:"external_payment_method"` + ProAccount ProAccount `json:"pro_account"` + RTP RTP `json:"rtp"` + Venue Name `json:"venue"` + LedgerNamedAccount LedgerNamedAccount `json:"ledger_named_account"` + CustodialPool CustodialPool `json:"custodial_pool"` + ApplePay ApplePay `json:"apple_pay"` + DefaultAccount UserUUIDWithCurrency `json:"default_account"` + Remitly Remitly `json:"remitly"` + ProInternalAccount UserIDWithCurrency `json:"pro_internal_account"` + DAPPWalletAccount DAPP `json:"dapp_wallet_account"` + GooglePay GooglePay `json:"google_pay"` + DAPPWalletBlockchainAddress DAPPBlockchain `json:"dapp_wallet_blockchain_address"` + ZaakpayMobikwik PhoneNumber `json:"zaakpay_mobikwik"` + DenebUPI DenebUPI `json:"deneb_upi"` + BankAccount BankAccount `json:"bank_account"` + IdentityContractCall NetworkWithAddress `json:"identity_contract_call"` + DenebIMPS DenebIMPS `json:"deneb_imps"` + Allocation Allocation `json:"allocation"` + LiquidityPool LiquidityPool `json:"liquidity_pool"` + ZenginV2 Zengin `json:"zengin_v2"` + DirectDeposit DirectDeposit `json:"direct_deposit"` + SEPAV2 SEPAV2 `json:"sepa_v2"` + Zepto Zepto `json:"zepto"` + PixEBANX PixEBANX `json:"pix_ebanx"` + Signet Signet `json:"signet"` + DerivativeSettlement DerivativeSettlement `json:"derivative_settlement"` + User UserUUID `json:"user"` + SgFAST SgFAST `json:"sg_fast"` + Interac Interac `json:"interac"` + IntraBank IntraBank `json:"intra_bank"` + Cbit Cbit `json:"cbit"` + Ideal CustomerPaymentInfo `json:"ideal"` + Sofort CustomerPaymentInfo `json:"sofort"` + SgPayNow SgPayNow `json:"sg_paynow"` + CheckoutPaymentLink PaymentLink `json:"checkout_payment_link"` + EmailAddress StringValue `json:"email_address"` + PhoneNumber StringValue `json:"phone_number"` + VendorPayment VendorPayment `json:"vendor_payment"` + CTN IDString `json:"ctn"` + BancomatPay BancomatPay `json:"bancomat_pay"` + HotWallet NetworkWithAddress `json:"hot_wallet"` + NovaAccount NovaAccount `json:"nova_account"` + MagicSpendBlockchainAddress AddressInfo `json:"magic_spend_blockchain_address"` + TransferPointer IdempotencyString `json:"transfer_pointer"` + EFT EFT `json:"eft"` + WallaceAccount WallaceAccount `json:"wallace_account"` + Manual ManualSettlement `json:"manual"` + ArgentineBankAccount TaxWithCBU `json:"argentine_bank_account"` + Representment CurrencyString `json:"representment"` + BankingCircleNow BankingCircleNow `json:"banking_circle_now"` + Trustly Trustly `json:"trustly"` + Blik Blik `json:"blik"` + MBWay json.RawMessage `json:"mbway"` + PIX PIX `json:"pix"` +} + +// AmScale is a sub-struct storing information on amounts and scales, used in ConvertResponse +type AmScale struct { + Amount CurrencyAmount `json:"amount"` + Scale int32 `json:"scale"` +} + +// UnitPrice is a sub-struct used in ConvertResponse +type UnitPrice struct { + TargetToFiat AmScale `json:"target_to_fiat"` + TargetToSource AmScale `json:"target_to_source"` + SourceToFiat AmScale `json:"source_to_fiat"` +} + +// Context is a sub-struct used in UserWarnings +type Context struct { + Details []string `json:"details"` + Title string `json:"title"` + LinkText string `json:"link_text"` +} + +// UserWarnings is a sub-struct used in ConvertResponse +type UserWarnings struct { + ID string `json:"id"` + Link LinkStruct `json:"link"` + Context Context `json:"context"` + Code string `json:"code"` + Message string `json:"message"` +} + +// ErrorMetadata is a sub-struct used in CancellationReason +type ErrorMetadata struct { + LimitAmount CurrencyAmount `json:"limit_amount"` +} + +// CancellationReason is a sub-struct used in ConvertResponse and DeposWithdrData +type CancellationReason struct { + Message string `json:"message"` + Code string `json:"code"` + ErrorCode string `json:"error_code"` + ErrorCTA string `json:"error_cta"` + ErrorMetadata ErrorMetadata `json:"error_metadata"` + Title string `json:"title"` +} + +// SubscriptionInfo is a sub-struct used in ConvertResponse +type SubscriptionInfo struct { + FreeTradingResetDate time.Time `json:"free_trading_reset_date"` + UsedZeroFeeTrading CurrencyAmount `json:"used_zero_fee_trading"` + RemainingFreeTradingVolume CurrencyAmount `json:"remaining_free_trading_volume"` + MaxFreeTradingVolume CurrencyAmount `json:"max_free_trading_volume"` + HasBenefitCap bool `json:"has_benefit_cap"` + AppliedSubscriptionBenefit bool `json:"applied_subscription_benefit"` + FeeWithoutSubscriptionBenefit CurrencyAmount `json:"fee_without_subscription_benefit"` + PaymentMethodFeeWithoutSubscriptionBenefit CurrencyAmount `json:"payment_method_fee_without_subscription_benefit"` +} + +// TaxDetails is a sub-struct used in ConvertResponse +type TaxDetails struct { + Name string `json:"name"` + Amount CurrencyAmount `json:"amount"` +} + +// TradeIncentiveInfo is a sub-struct used in ConvertResponse +type TradeIncentiveInfo struct { + AppliedIncentive bool `json:"applied_incentive"` + UserIncentiveID string `json:"user_incentive_id"` + CodeVal string `json:"code_val"` + EndsAt time.Time `json:"ends_at"` + FeeWithoutIncentive CurrencyAmount `json:"fee_without_incentive"` + Redeemed bool `json:"redeemed"` +} + +// ConvertWrapper wraps a ConvertResponse, used by CreateConvertQuote, CommitConvertTrade, and GetConvertTradeByID +type ConvertWrapper struct { + Trade ConvertResponse `json:"trade"` +} + +type convertTradeReqBase struct { + FromAccount string `json:"from_account"` + ToAccount string `json:"to_account"` +} + +type convertQuoteReqBase struct { + FromAccount string `json:"from_account"` + ToAccount string `json:"to_account"` + Amount float64 `json:"amount,string"` + Metadata tradeIncentiveMetadata `json:"trade_incentive_metadata"` +} + +type tradeIncentiveMetadata struct { + UserIncentiveID string `json:"user_incentive_id"` + CodeVal string `json:"code_val"` +} + +// ConvertResponse contains information on a convert trade, returned by CreateConvertQuote, CommitConvertTrade, and GetConvertTradeByID +type ConvertResponse struct { + // Many of these fields and subfields could, in truth, be types.Number, but documentation lists them as strings, and these endpoints can't be tested with Australian accounts + ID string `json:"id"` + Status string `json:"status"` + UserEnteredAmount CurrencyAmount `json:"user_entered_amount"` + Amount CurrencyAmount `json:"amount"` + Subtotal CurrencyAmount `json:"subtotal"` + Total CurrencyAmount `json:"total"` + Fees []FeeStruct `json:"fees"` + TotalFee FeeStruct `json:"total_fee"` + Source AccountStruct `json:"source"` + Target AccountStruct `json:"target"` + UnitPrice UnitPrice `json:"unit_price"` + UserWarnings []UserWarnings `json:"user_warnings"` + UserReference string `json:"user_reference"` + SourceCurrency string `json:"source_currency"` + TargetCurrency string `json:"target_currency"` + CancellationReason CancellationReason `json:"cancellation_reason"` + SourceID string `json:"source_id"` + TargetID string `json:"target_id"` + SubscriptionInfo SubscriptionInfo `json:"subscription_info"` + ExchangeRate CurrencyAmount `json:"exchange_rate"` + TaxDetails []TaxDetails `json:"tax_details"` + TradeIncentiveInfo TradeIncentiveInfo `json:"trade_incentive_info"` + TotalFeeWithoutTax FeeStruct `json:"total_fee_without_tax"` + FiatDenotedTotal CurrencyAmount `json:"fiat_denoted_total"` +} + +// ServerTimeV3 holds information on the server's time, returned by GetV3Time +type ServerTimeV3 struct { + Iso time.Time `json:"iso"` + EpochSeconds types.Time `json:"epochSeconds"` + EpochMilliseconds types.Time `json:"epochMillis"` +} + +// PaymentMethodData is a sub-type that holds information on a payment method +type PaymentMethodData struct { + ID string `json:"id"` + Type string `json:"type"` + Name string `json:"name"` + Currency currency.Code `json:"currency"` + Verified bool `json:"verified"` + AllowBuy bool `json:"allow_buy"` + AllowSell bool `json:"allow_sell"` + AllowDeposit bool `json:"allow_deposit"` + AllowWithdraw bool `json:"allow_withdraw"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type paymentMethodReqBase struct { + Currency currency.Code `json:"currency"` +} + +type allocatePortfolioReqBase struct { + PortfolioUUID string `json:"portfolio_uuid"` + Symbol string `json:"symbol"` + Currency string `json:"currency"` + Amount float64 `json:"amount,string"` +} + +// IDResource holds an ID, resource type, and associated data, used in ListNotificationsResponse, TransactionData, DeposWithdrData, and PaymentMethodData +type IDResource struct { + ID string `json:"id"` + Resource string `json:"resource"` + ResourcePath string `json:"resource_path"` + Email string `json:"email"` +} + +// PaginationResp holds pagination information, used in ListNotificationsResponse, GetAllWalletsResponse, GetAllAddrResponse, ManyTransactionsResp, and ManyDeposWithdrResp +type PaginationResp struct { + EndingBefore string `json:"ending_before"` + StartingAfter string `json:"starting_after"` + PreviousEndingBefore string `json:"previous_ending_before"` // This is only present on some endpoints + NextStartingAfter string `json:"next_starting_after"` // This is only present on some endpoints + Limit uint8 `json:"limit"` // This is only present on some endpoints + Order string `json:"order"` + PreviousURI string `json:"previous_uri"` // Might only be present on some endpoints + NextURI string `json:"next_uri"` // Might only be present on some endpoints + Page uint8 `json:"page"` // This is only present on some endpoints + TotalCount uint32 `json:"total_count,string"` // This is only present on some endpoints +} + +// PaginationInp holds information needed to engage in pagination with Sign in With Coinbase. Used in ListNotifications, GetAllWallets, GetAllAddresses, GetAddressTransactions, GetAllTransactions, GetAllFiatTransfers, ListPaymentMethods, and preparePagination +type PaginationInp struct { + Limit uint8 + OrderAscend bool + StartingAfter string + EndingBefore string +} + +// AmountWithCurrency is a sub-struct used in ListNotificationsSubData, WalletData, TransactionData, DeposWithdrData, Settlement, EquityReset, and PaymentMethodData +type AmountWithCurrency struct { + Amount types.Number `json:"amount"` + Currency string `json:"currency"` +} + +// Fees is a sub-struct used in ListNotificationsSubData +type Fees []struct { + Type string `json:"type"` + Amount AmountWithCurrency `json:"amount"` +} + +// ListNotificationsSubData is a sub-struct used in ListNotificationsData +type ListNotificationsSubData struct { + ID string `json:"id"` + Address string `json:"address"` + Name string `json:"name"` + Status string `json:"status"` + PaymentMethod IDResource `json:"payment_method"` + Transaction IDResource `json:"transaction"` + Amount AmountWithCurrency `json:"amount"` + Total AmountWithCurrency `json:"total"` + Subtotal AmountWithCurrency `json:"subtotal"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Resource string `json:"resource"` + ResourcePath string `json:"resource_path"` + Committed bool `json:"committed"` + Instant bool `json:"instant"` + Fee AmountWithCurrency `json:"fee"` + Fees []Fees `json:"fees"` + PayoutAt time.Time `json:"payout_at"` +} + +// AdditionalData is a sub-struct used in ListNotificationsData +type AdditionalData struct { + Hash string `json:"hash"` + Amount AmountWithCurrency `json:"amount"` +} + +// ListNotificationsData is a sub-struct used in ListNotificationsResponse +type ListNotificationsData struct { + ID string `json:"id"` + Type string `json:"type"` + Data ListNotificationsSubData `json:"data"` + AdditionalData AdditionalData `json:"additional_data"` + User IDResource `json:"user"` + Account IDResource `json:"account"` + DeliveryAttempts int32 `json:"delivery_attempts"` + CreatedAt time.Time `json:"created_at"` + Resource string `json:"resource"` + ResourcePath string `json:"resource_path"` + Transaction IDResource `json:"transaction"` +} + +// ListNotificationsResponse holds information on notifications that the user is subscribed to. Returned by ListNotifications +type ListNotificationsResponse struct { + Pagination PaginationResp `json:"pagination"` + Data []ListNotificationsData `json:"data"` +} + +// CodeName is a sub-struct holding a code and a name, used in UserResponse +type CodeName struct { + Code string `json:"code"` + Name string `json:"name"` +} + +// Country is a sub-struct, used in UserResponse +type Country struct { + Code string `json:"code"` + Name string `json:"name"` + IsInEurope bool `json:"is_in_europe"` +} + +// Tiers is a sub-struct, used in UserResponse +type Tiers struct { + CompletedDescription string `json:"completed_description"` + UpgradeButtonText string `json:"upgrade_button_text"` + Header string `json:"header"` + Body string `json:"body"` +} + +// ReferralMoney is a sub-struct, used in UserResponse +type ReferralMoney struct { + Amount types.Number `json:"amount"` + Currency string `json:"currency"` + CurrencySymbol string `json:"currency_symbol"` + ReferralThreshold types.Number `json:"referral_threshold"` +} + +// UserResponse holds information on a user, returned by GetCurrentUser +type UserResponse struct { + ID string `json:"id"` + Name string `json:"name"` + Username string `json:"username"` + ProfileLocation string `json:"profile_location"` + ProfileBio string `json:"profile_bio"` + ProfileURL string `json:"profile_url"` + AvatarURL string `json:"avatar_url"` + Resource string `json:"resource"` + ResourcePath string `json:"resource_path"` + LegacyID string `json:"legacy_id"` + TimeZone string `json:"time_zone"` + NativeCurrency string `json:"native_currency"` + BitcoinUnit string `json:"bitcoin_unit"` + State string `json:"state"` + Country Country `json:"country"` + Nationality CodeName `json:"nationality"` + RegionSupportsFiatTransfers bool `json:"region_supports_fiat_transfers"` + RegionSupportsCryptoToCryptoTransfers bool `json:"region_supports_crypto_to_crypto_transfers"` + CreatedAt time.Time `json:"created_at"` + SupportsRewards bool `json:"supports_rewards"` + Tiers Tiers `json:"tiers"` + ReferralMoney ReferralMoney `json:"referral_money"` + HasBlockingBuyRestrictions bool `json:"has_blocking_buy_restrictions"` + HasMadeAPurchase bool `json:"has_made_a_purchase"` + HasBuyDepositPaymentMethods bool `json:"has_buy_deposit_payment_methods"` + HasUnverifiedBuyDepositPaymentMethods bool `json:"has_unverified_buy_deposit_payment_methods"` + NeedsKYCRemediation bool `json:"needs_kyc_remediation"` + ShowInstantAchUx bool `json:"show_instant_ach_ux"` + UserType string `json:"user_type"` + Email string `json:"email"` + SendsDisabled bool `json:"sends_disabled"` +} + +// Currency is a sub-struct holding information on a currency, used in WalletData +type Currency struct { + Code string `json:"code"` + Name string `json:"name"` + Color string `json:"color"` + Exponent int32 `json:"exponent"` + Type string `json:"type"` + AssetID string `json:"asset_id"` + Slug string `json:"slug"` + Rewards json.RawMessage `json:"rewards"` +} + +// WalletData is a sub-struct holding wallet information, used in GetAllWalletsResponse +type WalletData struct { + ID string `json:"id"` + Name string `json:"name"` + Primary bool `json:"primary"` + Type string `json:"type"` + Currency Currency `json:"currency"` + Balance AmountWithCurrency `json:"balance"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Resource string `json:"resource"` + ResourcePath string `json:"resource_path"` + AllowDeposits bool `json:"allow_deposits"` + AllowWithdrawals bool `json:"allow_withdrawals"` + PortfolioID string `json:"portfolio_id"` +} + +// GetAllWalletsResponse holds information on many wallets, returned by GetAllWallets +type GetAllWalletsResponse struct { + Pagination *PaginationResp `json:"pagination"` + Data []WalletData `json:"data"` +} + +// AddressInfo holds an address and a destination tag, used in AddressData and AccountStruct +type AddressInfo struct { + Address string `json:"address"` + DestinationTag string `json:"destination_tag"` +} + +// TitleSubtitle holds a title and a subtitle, used in AddressData and TransactionData +type TitleSubtitle struct { + Title string `json:"title"` + Subtitle string `json:"subtitle"` +} + +// Options is a sub-struct used in Warnings +type Options struct { + Text string `json:"text"` + Style string `json:"style"` + ID string `json:"id"` +} + +// Warnings is a sub-struct used in AddressData +type Warnings struct { + Type string `json:"type"` + Title string `json:"title"` + Details string `json:"details"` + ImageURL string `json:"image_url"` + Options []Options `json:"options"` +} + +// ShareAddressCopy is a sub-struct used in AddressData +type ShareAddressCopy struct { + Line1 string `json:"line1"` + Line2 string `json:"line2"` +} + +// InlineWarning is a sub-struct used in AddressData +type InlineWarning struct { + Text string `json:"text"` + Tooltip TitleSubtitle `json:"tooltip"` +} + +// AddressData holds address information, used in GetAllAddrResponse, and returned by CreateAddress and GetAddressByID +type AddressData struct { + ID string `json:"id"` + Address string `json:"address"` + Currency currency.Code `json:"currency"` + Name string `json:"name"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Network string `json:"network"` + Resource string `json:"resource"` + ResourcePath string `json:"resource_path"` + DestinationTag string `json:"destination_tag"` +} + +// GetAllAddrResponse holds information on many addresses, returned by GetAllAddresses +type GetAllAddrResponse struct { + Pagination PaginationResp `json:"pagination"` + Data []AddressData `json:"data"` +} + +// AdvancedTradeFill is a sub-struct used in TransactionData +type AdvancedTradeFill struct { + FillPrice types.Number `json:"fill_price"` + ProductID currency.Pair `json:"product_id"` + OrderID string `json:"order_id"` + Commission types.Number `json:"commission"` + OrderSide string `json:"order_side"` +} + +// Network is a sub-struct used in TransactionData +type Network struct { + Status string `json:"status"` + Hash string `json:"hash"` + Name string `json:"name"` +} + +// FullAddress is a sub-struct, used in TravelRule +type FullAddress struct { + Address1 string `json:"address1"` + Address2 string `json:"address2"` + Address3 string `json:"address3"` + City string `json:"city"` + State string `json:"state"` + Country string `json:"country"` + PostalCode string `json:"postal_code"` +} + +// TravelRule contains information that may need to be provided to comply with local regulations. Used as a parameter for SendMoney +type TravelRule struct { + BeneficiaryWalletType string `json:"beneficiary_wallet_type"` + IsSelf string `json:"is_self"` + BeneficiaryName string `json:"beneficiary_name"` + BeneficiaryAddress FullAddress `json:"beneficiary_address"` + BeneficiaryFinancialInstitution string `json:"beneficiary_financial_institution"` + TransferPurpose string `json:"transfer_purpose"` +} + +type sendMoneyReqBase struct { + Type string `json:"type"` + To string `json:"to"` + Amount float64 `json:"amount,string"` + Currency currency.Code `json:"currency"` + Description string `json:"description"` + SkipNotifications bool `json:"skip_notifications"` + Idem string `json:"idem"` + DestinationTag string `json:"destination_tag"` + Network string `json:"network"` + TravelRuleData *TravelRule `json:"travel_rule_data"` +} + +// TransactionData is a sub-type that holds information on a transaction. Used in ManyTransactionsResp and returned by SendMoney +type TransactionData struct { + ID string `json:"id"` + Type string `json:"type"` + Status string `json:"status"` + Amount AmountWithCurrency `json:"amount"` + NativeAmount AmountWithCurrency `json:"native_amount"` + CreatedAt time.Time `json:"created_at"` + Resource string `json:"resource"` + ResourcePath string `json:"resource_path"` + AdvancedTradeFill AdvancedTradeFill `json:"advanced_trade_fill,omitzero"` +} + +// ManyTransactionsResp holds information on many transactions. Returned by GetAddressTransactions and GetAllTransactions +type ManyTransactionsResp struct { + Pagination PaginationResp `json:"pagination"` + Data []TransactionData `json:"data"` +} + +// AccountHolder is a sub-type that holds information on an account holder. Used in DeposWithdrData +type AccountHolder struct { + Type string `json:"type"` + Network string `json:"network"` + PaymentMethodID string `json:"payment_method_id"` + ExternalPaymentMethod *PaymentMethodID `json:"external_payment_method,omitempty"` + LedgerAccount *LedgerAccount `json:"ledger_account,omitempty"` +} + +// FeeDetail is a sub-type that holds information on a fee. Used in DeposWithdrData +type FeeDetail struct { + Title string `json:"title"` + Description string `json:"description"` + Amount CurrencyAmount `json:"amount"` + Type string `json:"type"` +} + +// DeposWithdrData is a sub-type that holds information on a deposit/withdrawal. Returned by FiatTransfer and CommitTransfer, and used in ManyDeposWithdrResp +type DeposWithdrData struct { + UserEnteredAmount CurrencyAmount `json:"user_entered_amount"` + Amount CurrencyAmount `json:"amount"` + Total CurrencyAmount `json:"total"` + Subtotal CurrencyAmount `json:"subtotal"` + Idempotency string `json:"idempotency"` + Committed bool `json:"committed"` + ID string `json:"id"` + Instant bool `json:"instant"` + Source AccountHolder `json:"source"` + Target AccountHolder `json:"target"` + PayoutAt time.Time `json:"payout_at"` + Status string `json:"status"` + UserReference string `json:"user_reference"` + Type string `json:"type"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + UserWarnings []string `json:"user_warnings"` + Fees json.RawMessage `json:"fees"` + TotalFee FeeDetail `json:"total_fee"` + CancellationReason CancellationReason `json:"cancellation_reason"` + HoldDays int32 `json:"hold_days"` + NextStep json.RawMessage `json:"next_step"` + CheckoutURL string `json:"checkout_url"` + RequiresCompletionStep bool `json:"requires_completion_step"` +} + +type fiatTransferReqBase struct { + Currency string `json:"currency"` + PaymentMethod string `json:"payment_method"` + Amount float64 `json:"amount,string"` + Commit bool `json:"commit"` +} + +// ManyDeposWithdrResp holds information on many deposits. Returned by GetAllFiatTransfers +type ManyDeposWithdrResp struct { + Pagination PaginationResp `json:"pagination"` + Data []DeposWithdrData `json:"data"` +} + +// FiatData holds information on fiat currencies. Returned by GetFiatCurrencies +type FiatData struct { + ID string `json:"id"` + Name string `json:"name"` + MinSize types.Number `json:"min_size"` +} + +// CryptoData holds information on cryptocurrencies. Returned by GetCryptocurrencies +type CryptoData struct { + Code string `json:"code"` + Name string `json:"name"` + Color string `json:"color"` + SortIndex uint16 `json:"sort_index"` + Exponent uint8 `json:"exponent"` + Type string `json:"type"` + AddressRegex string `json:"address_regex"` + AssetID string `json:"asset_id"` +} + +// GetExchangeRatesResp holds information on exchange rates. Returned by GetExchangeRates +type GetExchangeRatesResp struct { + Currency string `json:"currency"` + Rates map[string]types.Number `json:"rates"` +} + +// GetPriceResp holds information on a price. Returned by GetPrice +type GetPriceResp struct { + Amount types.Number `json:"amount"` + Base string `json:"base"` + Currency string `json:"currency"` +} + +// ServerTimeV2 holds current requested server time information, returned by GetV2Time +type ServerTimeV2 struct { + ISO time.Time `json:"iso"` + Epoch uint64 `json:"epoch"` +} + +// WebsocketRequest is an aspect of constructing a request to the websocket server, used in sendRequest +type WebsocketRequest struct { + Type string `json:"type"` + ProductIDs []currency.Pair `json:"product_ids,omitempty"` + Channel string `json:"channel,omitempty"` + Signature string `json:"signature,omitempty"` + Key string `json:"api_key,omitempty"` + Timestamp string `json:"timestamp,omitempty"` + JWT string `json:"jwt,omitempty"` +} + +// StandardWebsocketResponse is a standard response from the websocket connection +type StandardWebsocketResponse struct { + Channel string `json:"channel"` + ClientID string `json:"client_id"` + Timestamp time.Time `json:"timestamp"` + Sequence uint64 `json:"sequence_num"` + Events json.RawMessage `json:"events"` + Error string `json:"type"` +} + +// WebsocketTicker defines a ticker websocket response, used in WebsocketTickerHolder +type WebsocketTicker struct { + Type string `json:"type"` + ProductID currency.Pair `json:"product_id"` + Price types.Number `json:"price"` + Volume24H types.Number `json:"volume_24_h"` + Low24H types.Number `json:"low_24_h"` + High24H types.Number `json:"high_24_h"` + Low52W types.Number `json:"low_52_w"` + High52W types.Number `json:"high_52_w"` + PricePercentageChange24H types.Number `json:"price_percent_chg_24_h"` + BestBid types.Number `json:"best_bid"` + BestBidQuantity types.Number `json:"best_bid_size"` + BestAsk types.Number `json:"best_ask"` + BestAskQuantity types.Number `json:"best_ask_size"` +} + +// WebsocketTickerHolder holds a variety of ticker responses, used when wsHandleData processes tickers +type WebsocketTickerHolder struct { + Type string `json:"type"` + Tickers []WebsocketTicker `json:"tickers"` +} + +// WebsocketCandle defines a candle websocket response, used in WebsocketCandleHolder +type WebsocketCandle struct { + Start types.Time `json:"start"` + Low types.Number `json:"low"` + High types.Number `json:"high"` + Open types.Number `json:"open"` + Close types.Number `json:"close"` + Volume types.Number `json:"volume"` + ProductID currency.Pair `json:"product_id"` +} + +// WebsocketCandleHolder holds a variety of candle responses, used when wsHandleData processes candles +type WebsocketCandleHolder struct { + Type string `json:"type"` + Candles []WebsocketCandle `json:"candles"` +} + +// WebsocketMarketTrade defines a market trade websocket response, used in WebsocketMarketTradeHolder +type WebsocketMarketTrade struct { + TradeID string `json:"trade_id"` + ProductID currency.Pair `json:"product_id"` + Price types.Number `json:"price"` + Size types.Number `json:"size"` + Side order.Side `json:"side"` + Time time.Time `json:"time"` +} + +// WebsocketMarketTradeHolder holds a variety of market trade responses, used when wsHandleData processes trades +type WebsocketMarketTradeHolder struct { + Type string `json:"type"` + Trades []WebsocketMarketTrade `json:"trades"` +} + +// WebsocketProduct defines a product websocket response, used in WebsocketProductHolder +type WebsocketProduct struct { + ProductType string `json:"product_type"` + ID currency.Pair `json:"id"` + BaseCurrency string `json:"base_currency"` + QuoteCurrency string `json:"quote_currency"` + BaseIncrement types.Number `json:"base_increment"` + QuoteIncrement types.Number `json:"quote_increment"` + DisplayName string `json:"display_name"` + Status string `json:"status"` + StatusMessage string `json:"status_message"` + MinMarketFunds types.Number `json:"min_market_funds"` +} + +// WebsocketProductHolder holds a variety of product responses, used when wsHandleData processes an update on a product's status +type WebsocketProductHolder struct { + Type string `json:"type"` + Products []WebsocketProduct `json:"products"` +} + +// WebsocketOrderbookData defines a websocket orderbook response, used in WebsocketOrderbookDataHolder +type WebsocketOrderbookData struct { + Side string `json:"side"` + EventTime time.Time `json:"event_time"` + PriceLevel types.Number `json:"price_level"` + NewQuantity types.Number `json:"new_quantity"` +} + +// WebsocketOrderbookDataHolder holds a variety of orderbook responses, used when wsHandleData processes orderbooks, as well as under typical operation of ProcessSnapshot, ProcessUpdate, and processBidAskArray +type WebsocketOrderbookDataHolder struct { + Type string `json:"type"` + ProductID currency.Pair `json:"product_id"` + Changes []WebsocketOrderbookData `json:"updates"` +} + +// WebsocketOrderData defines a websocket order response, used in WebsocketOrderDataHolder +type WebsocketOrderData struct { + AveragePrice types.Number `json:"avg_price"` + CancelReason string `json:"cancel_reason"` + ClientOrderID string `json:"client_order_id"` + CompletionPercentage types.Number `json:"completion_percentage"` + ContractExpiryType string `json:"contract_expiry_type"` + CumulativeQuantity types.Number `json:"cumulative_quantity"` + FilledValue types.Number `json:"filled_value"` + LeavesQuantity types.Number `json:"leaves_quantity"` + LimitPrice types.Number `json:"limit_price"` + NumberOfFills int64 `json:"number_of_fills"` + OrderID string `json:"order_id"` + OrderSide string `json:"order_side"` + OrderType string `json:"order_type"` + OutstandingHoldAmount types.Number `json:"outstanding_hold_amount"` + PostOnly bool `json:"post_only"` + ProductID currency.Pair `json:"product_id"` + ProductType string `json:"product_type"` + RejectReason string `json:"reject_reason"` + RetailPortfolioID string `json:"retail_portfolio_id"` + RiskManagedBy string `json:"risk_managed_by"` + Status string `json:"status"` + StopPrice types.Number `json:"stop_price"` + TimeInForce string `json:"time_in_force"` + TotalFees types.Number `json:"total_fees"` + TotalValueAfterFees types.Number `json:"total_value_after_fees"` + TriggerStatus string `json:"trigger_status"` + CreationTime time.Time `json:"creation_time"` + EndTime time.Time `json:"end_time"` + StartTime time.Time `json:"start_time"` +} + +// WebsocketPerpData defines a websocket perpetual position response, used in WebsocketPositionStruct +type WebsocketPerpData struct { + ProductID currency.Pair `json:"product_id"` + PortfolioUUID string `json:"portfolio_uuid"` + VWAP types.Number `json:"vwap"` + EntryVWAP types.Number `json:"entry_vwap"` + PositionSide string `json:"position_side"` + MarginType string `json:"margin_type"` + NetSize types.Number `json:"net_size"` + BuyOrderSize types.Number `json:"buy_order_size"` + SellOrderSize types.Number `json:"sell_order_size"` + Leverage types.Number `json:"leverage"` + MarkPrice types.Number `json:"mark_price"` + LiquidationPrice types.Number `json:"liquidation_price"` + IMNotional types.Number `json:"im_notional"` + MMNotional types.Number `json:"mm_notional"` + PositionNotional types.Number `json:"position_notional"` + UnrealizedPNL types.Number `json:"unrealized_pnl"` + AggregatedPNL types.Number `json:"aggregated_pnl"` +} + +// WebsocketExpData defines a websocket expiring position response, used in WebsocketPositionStruct +type WebsocketExpData struct { + ProductID currency.Pair `json:"product_id"` + Side string `json:"side"` + NumberOfContracts types.Number `json:"number_of_contracts"` + RealizedPNL types.Number `json:"realized_pnl"` + UnrealizedPNL types.Number `json:"unrealized_pnl"` + EntryPrice types.Number `json:"entry_price"` +} + +// WebsocketPositionStruct holds position data, used in WebsocketOrderDataHolder +type WebsocketPositionStruct struct { + PerpetualFuturesPositions []WebsocketPerpData `json:"perpetual_futures_positions"` + ExpiringFuturesPositions []WebsocketExpData `json:"expiring_futures_positions"` +} + +// WebsocketOrderDataHolder holds a variety of order responses, used when wsHandleData processes orders +type WebsocketOrderDataHolder struct { + Type string `json:"type"` + Orders []WebsocketOrderData `json:"orders"` + Positions WebsocketPositionStruct `json:"positions"` +} + +// Details is a sub-struct used in CurrencyData +type Details struct { + Type string `json:"type"` + Symbol string `json:"symbol"` + NetworkConfirmations int32 `json:"network_confirmations"` + SortOrder int32 `json:"sort_order"` + CryptoAddressLink string `json:"crypto_address_link"` + CryptoTransactionLink string `json:"crypto_transaction_link"` + PushPaymentMethods []string `json:"push_payment_methods"` + GroupTypes []string `json:"group_types"` + DisplayName string `json:"display_name"` + ProcessingTimeSeconds int64 `json:"processing_time_seconds"` + MinWithdrawalAmount float64 `json:"min_withdrawal_amount"` + MaxWithdrawalAmount float64 `json:"max_withdrawal_amount"` +} + +// SupportedNetworks is a sub-struct used in CurrencyData +type SupportedNetworks struct { + ID string `json:"id"` + Name string `json:"name"` + Status string `json:"status"` + ContractAddress string `json:"contract_address"` + CryptoAddressLink string `json:"crypto_address_link"` + CryptoTransactionLink string `json:"crypto_transaction_link"` + MinWithdrawalAmount float64 `json:"min_withdrawal_amount"` + MaxWithdrawalAmount float64 `json:"max_withdrawal_amount"` + NetworkConfirmations int32 `json:"network_confirmations"` + ProcessingTimeSeconds int64 `json:"processing_time_seconds"` +} + +// CurrencyData contains information on known currencies, used in GetAllCurrencies and GetACurrency +type CurrencyData struct { + ID string `json:"id"` + Name string `json:"name"` + MinSize string `json:"min_size"` + Status string `json:"status"` + Message string `json:"message"` + MaxPrecision types.Number `json:"max_precision"` + ConvertibleTo []string `json:"convertible_to"` + Details Details `json:"details"` + DefaultNetwork string `json:"default_network"` + SupportedNetworks []SupportedNetworks `json:"supported_networks"` + DisplayName string `json:"display_name"` +} + +// PairData contains information on available trading pairs, used in GetAllTradingPairs +type PairData struct { + ID string `json:"id"` + BaseCurrency string `json:"base_currency"` + QuoteCurrency string `json:"quote_currency"` + QuoteIncrement types.Number `json:"quote_increment"` + BaseIncrement types.Number `json:"base_increment"` + DisplayName string `json:"display_name"` + MinMarketFunds types.Number `json:"min_market_funds"` + MarginEnabled bool `json:"margin_enabled"` + PostOnly bool `json:"post_only"` + LimitOnly bool `json:"limit_only"` + CancelOnly bool `json:"cancel_only"` + Status string `json:"status"` + StatusMessage string `json:"status_message"` + TradingDisabled bool `json:"trading_disabled"` + FXStablecoin bool `json:"fx_stablecoin"` + MaxSlippagePercentage types.Number `json:"max_slippage_percentage"` + AuctionMode bool `json:"auction_mode"` + HighBidLimitPercentage types.Number `json:"high_bid_limit_percentage"` +} + +// PairVolumeData contains information on trading pair volume, used in GetAllPairVolumes +type PairVolumeData struct { + ID string `json:"id"` + BaseCurrency string `json:"base_currency"` + QuoteCurrency string `json:"quote_currency"` + DisplayName string `json:"display_name"` + MarketTypes []string `json:"market_types"` + SpotVolume24Hour types.Number `json:"spot_volume_24hour"` + SpotVolume30Day types.Number `json:"spot_volume_30day"` + RFQVolume24Hour types.Number `json:"rfq_volume_24hour"` + RFQVolume30Day types.Number `json:"rfq_volume_30day"` + ConversionVolume24Hour types.Number `json:"conversion_volume_24hour"` + ConversionVolume30Day types.Number `json:"conversion_volume_30day"` +} + +// Auction holds information on an ongoing auction, used as a sub-struct in OrderBookResp and OrderBook +type Auction struct { + OpenPrice types.Number `json:"open_price"` + OpenSize types.Number `json:"open_size"` + BestBidPrice types.Number `json:"best_bid_price"` + BestBidSize types.Number `json:"best_bid_size"` + BestAskPrice types.Number `json:"best_ask_price"` + BestAskSize types.Number `json:"best_ask_size"` + AuctionState string `json:"auction_state"` + CanOpen string `json:"can_open"` + Time time.Time `json:"time"` +} + +// OrderBookResp holds information on bids and asks for a particular currency pair, used for unmarshalling in GetProductBookV1 +type OrderBookResp struct { + Bids []Orders `json:"bids"` + Asks []Orders `json:"asks"` + Sequence float64 `json:"sequence"` + AuctionMode bool `json:"auction_mode"` + Auction Auction `json:"auction"` + Time time.Time `json:"time"` +} + +// Orders holds information on orders, used as a sub-struct in OrderBook +type Orders struct { + Price types.Number + Size types.Number + OrderCount uint64 + OrderID uuid.UUID +} + +// Candle holds properly formatted candle data, returned by GetProductCandles +type Candle struct { + Time types.Time + Low float64 + High float64 + Open float64 + Close float64 + Volume float64 +} + +// ProductStats holds information on a pair's price and volume, returned by GetProductStats +type ProductStats struct { + Open types.Number `json:"open"` + High types.Number `json:"high"` + Low types.Number `json:"low"` + Last types.Number `json:"last"` + Volume types.Number `json:"volume"` + Volume30Day types.Number `json:"volume_30day"` + RFQVolume24Hour types.Number `json:"rfq_volume_24hour"` + RFQVolume30Day types.Number `json:"rfq_volume_30day"` + ConversionsVolume24Hour types.Number `json:"conversions_volume_24hour"` + ConversionsVolume30Day types.Number `json:"conversions_volume_30day"` +} + +// ProductTicker holds information on a pair's price and volume, returned by GetProductTicker +type ProductTicker struct { + Ask types.Number `json:"ask"` + Bid types.Number `json:"bid"` + Volume types.Number `json:"volume"` + TradeID int32 `json:"trade_id"` + Price types.Number `json:"price"` + Size types.Number `json:"size"` + Time time.Time `json:"time"` + RFQVolume types.Number `json:"rfq_volume"` + ConversionsVolume types.Number `json:"conversions_volume"` +} + +// ProductTrades holds information on a pair's trades, returned by GetProductTrades +type ProductTrades struct { + TradeID int32 `json:"trade_id"` + Side string `json:"side"` + Size types.Number `json:"size"` + Price types.Number `json:"price"` + Time time.Time `json:"time"` +} + +// WrappedAsset holds information on a wrapped asset, used in AllWrappedAssets and returned by GetWrappedAssetDetails +type WrappedAsset struct { + ID string `json:"id"` + CirculatingSupply types.Number `json:"circulating_supply"` + TotalSupply types.Number `json:"total_supply"` + ConversionRate types.Number `json:"conversion_rate"` + APY types.Number `json:"apy"` +} + +// AllWrappedAssets holds information on all wrapped assets, returned by GetAllWrappedAssets +type AllWrappedAssets struct { + WrappedAssets []WrappedAsset `json:"wrapped_assets"` +} + +// WrappedAssetConversionRate holds information on a wrapped asset's conversion rate, returned by GetWrappedAssetConversionRate +type WrappedAssetConversionRate struct { + Amount types.Number `json:"amount"` +} + +// ManyErrors holds information on errors +type ManyErrors struct { + Success bool `json:"success"` + FailureReason string `json:"failure_reason"` + OrderID string `json:"order_id"` + EditFailureReason string `json:"edit_failure_reason"` + PreviewFailureReason string `json:"preview_failure_reason"` +} diff --git a/exchanges/coinbase/coinbase_websocket.go b/exchanges/coinbase/coinbase_websocket.go new file mode 100644 index 00000000..7dec8d40 --- /dev/null +++ b/exchanges/coinbase/coinbase_websocket.go @@ -0,0 +1,606 @@ +package coinbase + +import ( + "context" + "fmt" + "net/http" + "slices" + "strconv" + "text/template" + "time" + + gws "github.com/gorilla/websocket" + "github.com/pkg/errors" + "github.com/thrasher-corp/gocryptotrader/common" + "github.com/thrasher-corp/gocryptotrader/encoding/json" + "github.com/thrasher-corp/gocryptotrader/exchange/websocket" + "github.com/thrasher-corp/gocryptotrader/exchanges/asset" + "github.com/thrasher-corp/gocryptotrader/exchanges/margin" + "github.com/thrasher-corp/gocryptotrader/exchanges/order" + "github.com/thrasher-corp/gocryptotrader/exchanges/orderbook" + "github.com/thrasher-corp/gocryptotrader/exchanges/subscription" + "github.com/thrasher-corp/gocryptotrader/exchanges/ticker" + "github.com/thrasher-corp/gocryptotrader/exchanges/trade" +) + +const ( + coinbaseWebsocketURL = "wss://advanced-trade-ws.coinbase.com" +) + +var subscriptionNames = map[string]string{ + subscription.HeartbeatChannel: "heartbeats", + subscription.TickerChannel: "ticker", + subscription.CandlesChannel: "candles", + subscription.AllTradesChannel: "market_trades", + subscription.OrderbookChannel: "level2", + subscription.MyAccountChannel: "user", + "status": "status", + "ticker_batch": "ticker_batch", + /* Not Implemented: + "futures_balance_summary": "futures_balance_summary", + */ +} + +var defaultSubscriptions = subscription.List{ + {Enabled: true, Channel: subscription.HeartbeatChannel}, + {Enabled: true, Asset: asset.All, Channel: "status"}, + {Enabled: true, Asset: asset.Spot, Channel: subscription.TickerChannel}, + {Enabled: true, Asset: asset.Spot, Channel: subscription.CandlesChannel}, + {Enabled: true, Asset: asset.Spot, Channel: subscription.AllTradesChannel}, + {Enabled: true, Asset: asset.Spot, Channel: subscription.OrderbookChannel}, + {Enabled: true, Asset: asset.All, Channel: subscription.MyAccountChannel, Authenticated: true}, + {Enabled: true, Asset: asset.Spot, Channel: "ticker_batch"}, + /* Not Implemented: + {Enabled: false, Asset: asset.Spot, Channel: "futures_balance_summary", Authenticated: true}, + */ +} + +// WsConnect initiates a websocket connection +func (e *Exchange) WsConnect() error { + ctx := context.TODO() + if !e.Websocket.IsEnabled() || !e.IsEnabled() { + return websocket.ErrWebsocketNotEnabled + } + var dialer gws.Dialer + if err := e.Websocket.Conn.Dial(ctx, &dialer, http.Header{}); err != nil { + return err + } + e.Websocket.Wg.Add(1) + go e.wsReadData() + return nil +} + +// wsReadData receives and passes on websocket messages for processing +func (e *Exchange) wsReadData() { + defer e.Websocket.Wg.Done() + var seqCount uint64 + for { + resp := e.Websocket.Conn.ReadMessage() + if resp.Raw == nil { + return + } + sequence, err := e.wsHandleData(resp.Raw) + if err != nil { + e.Websocket.DataHandler <- err + } + if sequence != nil { + if *sequence != seqCount { + e.Websocket.DataHandler <- fmt.Errorf("%w: received %v, expected %v", errOutOfSequence, sequence, seqCount) + seqCount = *sequence + } + seqCount++ + } + } +} + +// wsProcessTicker handles ticker data from the websocket +func (e *Exchange) wsProcessTicker(resp *StandardWebsocketResponse) error { + var wsTickers []WebsocketTickerHolder + if err := json.Unmarshal(resp.Events, &wsTickers); err != nil { + return err + } + var allTickers []ticker.Price + aliases := e.pairAliases.GetAliases() + for i := range wsTickers { + for j := range wsTickers[i].Tickers { + symbolAliases := aliases[wsTickers[i].Tickers[j].ProductID] + t := ticker.Price{ + LastUpdated: resp.Timestamp, + AssetType: asset.Spot, + ExchangeName: e.Name, + High: wsTickers[i].Tickers[j].High24H.Float64(), + Low: wsTickers[i].Tickers[j].Low24H.Float64(), + Last: wsTickers[i].Tickers[j].Price.Float64(), + Volume: wsTickers[i].Tickers[j].Volume24H.Float64(), + Bid: wsTickers[i].Tickers[j].BestBid.Float64(), + BidSize: wsTickers[i].Tickers[j].BestBidQuantity.Float64(), + Ask: wsTickers[i].Tickers[j].BestAsk.Float64(), + AskSize: wsTickers[i].Tickers[j].BestAskQuantity.Float64(), + } + var errs error + for k := range symbolAliases { + if isEnabled, err := e.CurrencyPairs.IsPairEnabled(symbolAliases[k], asset.Spot); err != nil { + errs = common.AppendError(errs, err) + continue + } else if isEnabled { + t.Pair = symbolAliases[k] + allTickers = append(allTickers, t) + } + } + } + } + e.Websocket.DataHandler <- allTickers + return nil +} + +// wsProcessCandle handles candle data from the websocket +func (e *Exchange) wsProcessCandle(resp *StandardWebsocketResponse) error { + var wsCandles []WebsocketCandleHolder + if err := json.Unmarshal(resp.Events, &wsCandles); err != nil { + return err + } + var allCandles []websocket.KlineData + for i := range wsCandles { + for j := range wsCandles[i].Candles { + allCandles = append(allCandles, websocket.KlineData{ + Timestamp: resp.Timestamp, + Pair: wsCandles[i].Candles[j].ProductID, + AssetType: asset.Spot, + Exchange: e.Name, + StartTime: wsCandles[i].Candles[j].Start.Time(), + OpenPrice: wsCandles[i].Candles[j].Open.Float64(), + ClosePrice: wsCandles[i].Candles[j].Close.Float64(), + HighPrice: wsCandles[i].Candles[j].High.Float64(), + LowPrice: wsCandles[i].Candles[j].Low.Float64(), + Volume: wsCandles[i].Candles[j].Volume.Float64(), + }) + } + } + e.Websocket.DataHandler <- allCandles + return nil +} + +// wsProcessMarketTrades handles market trades data from the websocket +func (e *Exchange) wsProcessMarketTrades(resp *StandardWebsocketResponse) error { + var wsTrades []WebsocketMarketTradeHolder + if err := json.Unmarshal(resp.Events, &wsTrades); err != nil { + return err + } + var allTrades []trade.Data + for i := range wsTrades { + for j := range wsTrades[i].Trades { + allTrades = append(allTrades, trade.Data{ + TID: wsTrades[i].Trades[j].TradeID, + Exchange: e.Name, + CurrencyPair: wsTrades[i].Trades[j].ProductID, + AssetType: asset.Spot, + Side: wsTrades[i].Trades[j].Side, + Price: wsTrades[i].Trades[j].Price.Float64(), + Amount: wsTrades[i].Trades[j].Size.Float64(), + Timestamp: wsTrades[i].Trades[j].Time, + }) + } + } + e.Websocket.DataHandler <- allTrades + return nil +} + +// wsProcessL2 handles l2 orderbook data from the websocket +func (e *Exchange) wsProcessL2(resp *StandardWebsocketResponse) error { + var wsL2 []WebsocketOrderbookDataHolder + err := json.Unmarshal(resp.Events, &wsL2) + if err != nil { + return err + } + for i := range wsL2 { + switch wsL2[i].Type { + case "snapshot": + err = e.ProcessSnapshot(&wsL2[i], resp.Timestamp) + case "update": + err = e.ProcessUpdate(&wsL2[i], resp.Timestamp) + default: + err = fmt.Errorf("%w %v", errUnknownL2DataType, wsL2[i].Type) + } + if err != nil { + return err + } + } + return nil +} + +// wsProcessUser handles user data from the websocket +func (e *Exchange) wsProcessUser(resp *StandardWebsocketResponse) error { + var wsUser []WebsocketOrderDataHolder + err := json.Unmarshal(resp.Events, &wsUser) + if err != nil { + return err + } + var allOrders []order.Detail + for i := range wsUser { + for j := range wsUser[i].Orders { + var oType order.Type + if oType, err = stringToStandardType(wsUser[i].Orders[j].OrderType); err != nil { + e.Websocket.DataHandler <- order.ClassificationError{ + Exchange: e.Name, + Err: err, + } + } + var oSide order.Side + if oSide, err = order.StringToOrderSide(wsUser[i].Orders[j].OrderSide); err != nil { + e.Websocket.DataHandler <- order.ClassificationError{ + Exchange: e.Name, + Err: err, + } + } + var oStatus order.Status + if oStatus, err = statusToStandardStatus(wsUser[i].Orders[j].Status); err != nil { + e.Websocket.DataHandler <- order.ClassificationError{ + Exchange: e.Name, + Err: err, + } + } + price := wsUser[i].Orders[j].AveragePrice + if wsUser[i].Orders[j].LimitPrice != 0 { + price = wsUser[i].Orders[j].LimitPrice + } + var assetType asset.Item + if assetType, err = stringToStandardAsset(wsUser[i].Orders[j].ProductType); err != nil { + e.Websocket.DataHandler <- order.ClassificationError{ + Exchange: e.Name, + Err: err, + } + } + var tif order.TimeInForce + if tif, err = strategyDecoder(wsUser[i].Orders[j].TimeInForce); err != nil { + e.Websocket.DataHandler <- order.ClassificationError{ + Exchange: e.Name, + Err: err, + } + } + if wsUser[i].Orders[j].PostOnly { + tif |= order.PostOnly + } + allOrders = append(allOrders, order.Detail{ + Price: price.Float64(), + ClientOrderID: wsUser[i].Orders[j].ClientOrderID, + ExecutedAmount: wsUser[i].Orders[j].CumulativeQuantity.Float64(), + RemainingAmount: wsUser[i].Orders[j].LeavesQuantity.Float64(), + Amount: wsUser[i].Orders[j].CumulativeQuantity.Float64() + wsUser[i].Orders[j].LeavesQuantity.Float64(), + OrderID: wsUser[i].Orders[j].OrderID, + Side: oSide, + Type: oType, + Pair: wsUser[i].Orders[j].ProductID, + AssetType: assetType, + Status: oStatus, + TriggerPrice: wsUser[i].Orders[j].StopPrice.Float64(), + TimeInForce: tif, + Fee: wsUser[i].Orders[j].TotalFees.Float64(), + Date: wsUser[i].Orders[j].CreationTime, + CloseTime: wsUser[i].Orders[j].EndTime, + Exchange: e.Name, + }) + } + for j := range wsUser[i].Positions.PerpetualFuturesPositions { + var oSide order.Side + if oSide, err = order.StringToOrderSide(wsUser[i].Positions.PerpetualFuturesPositions[j].PositionSide); err != nil { + e.Websocket.DataHandler <- order.ClassificationError{ + Exchange: e.Name, + Err: err, + } + } + var mType margin.Type + if mType, err = margin.StringToMarginType(wsUser[i].Positions.PerpetualFuturesPositions[j].MarginType); err != nil { + e.Websocket.DataHandler <- order.ClassificationError{ + Exchange: e.Name, + Err: err, + } + } + allOrders = append(allOrders, order.Detail{ + Pair: wsUser[i].Positions.PerpetualFuturesPositions[j].ProductID, + Side: oSide, + MarginType: mType, + Amount: wsUser[i].Positions.PerpetualFuturesPositions[j].NetSize.Float64(), + Leverage: wsUser[i].Positions.PerpetualFuturesPositions[j].Leverage.Float64(), + AssetType: asset.Futures, + Exchange: e.Name, + }) + } + for j := range wsUser[i].Positions.ExpiringFuturesPositions { + var oSide order.Side + if oSide, err = order.StringToOrderSide(wsUser[i].Positions.ExpiringFuturesPositions[j].Side); err != nil { + e.Websocket.DataHandler <- order.ClassificationError{ + Exchange: e.Name, + Err: err, + } + } + allOrders = append(allOrders, order.Detail{ + Pair: wsUser[i].Positions.ExpiringFuturesPositions[j].ProductID, + Side: oSide, + ContractAmount: wsUser[i].Positions.ExpiringFuturesPositions[j].NumberOfContracts.Float64(), + Price: wsUser[i].Positions.ExpiringFuturesPositions[j].EntryPrice.Float64(), + }) + } + } + e.Websocket.DataHandler <- allOrders + return nil +} + +// wsHandleData handles all the websocket data coming from the websocket connection +func (e *Exchange) wsHandleData(respRaw []byte) (*uint64, error) { + var resp StandardWebsocketResponse + if err := json.Unmarshal(respRaw, &resp); err != nil { + return nil, err + } + if resp.Error != "" { + return &resp.Sequence, errors.New(resp.Error) + } + switch resp.Channel { + case "subscriptions", "heartbeats": + return &resp.Sequence, nil + case "status": + var wsStatus []WebsocketProductHolder + if err := json.Unmarshal(resp.Events, &wsStatus); err != nil { + return &resp.Sequence, err + } + e.Websocket.DataHandler <- wsStatus + case "ticker", "ticker_batch": + if err := e.wsProcessTicker(&resp); err != nil { + return &resp.Sequence, err + } + case "candles": + if err := e.wsProcessCandle(&resp); err != nil { + return &resp.Sequence, err + } + case "market_trades": + if err := e.wsProcessMarketTrades(&resp); err != nil { + return &resp.Sequence, err + } + case "l2_data": + if err := e.wsProcessL2(&resp); err != nil { + return &resp.Sequence, err + } + case "user": + if err := e.wsProcessUser(&resp); err != nil { + return &resp.Sequence, err + } + default: + return &resp.Sequence, errChannelNameUnknown + } + return &resp.Sequence, nil +} + +// ProcessSnapshot processes the initial orderbook snap shot +func (e *Exchange) ProcessSnapshot(snapshot *WebsocketOrderbookDataHolder, timestamp time.Time) error { + bids, asks, err := processBidAskArray(snapshot, true) + if err != nil { + return err + } + book := &orderbook.Book{ + Bids: bids, + Asks: asks, + Exchange: e.Name, + Pair: snapshot.ProductID, + Asset: asset.Spot, + LastUpdated: timestamp, + ValidateOrderbook: e.ValidateOrderbook, + } + for _, a := range e.pairAliases.GetAlias(snapshot.ProductID) { + isEnabled, err := e.IsPairEnabled(a, asset.Spot) + if err != nil { + return err + } + if isEnabled { + book.Pair = a + if err := e.Websocket.Orderbook.LoadSnapshot(book); err != nil { + return err + } + } + } + return nil +} + +// ProcessUpdate updates the orderbook local cache +func (e *Exchange) ProcessUpdate(update *WebsocketOrderbookDataHolder, timestamp time.Time) error { + bids, asks, err := processBidAskArray(update, false) + if err != nil { + return err + } + obU := &orderbook.Update{ + Bids: bids, + Asks: asks, + Pair: update.ProductID, + UpdateTime: timestamp, + Asset: asset.Spot, + } + for _, a := range e.pairAliases.GetAlias(update.ProductID) { + isEnabled, err := e.IsPairEnabled(a, asset.Spot) + if err != nil { + return err + } + if isEnabled { + obU.Pair = a + if err := e.Websocket.Orderbook.Update(obU); err != nil { + return err + } + } + } + return nil +} + +// GenerateSubscriptions adds default subscriptions to websocket to be handled by ManageSubscriptions() +func (e *Exchange) generateSubscriptions() (subscription.List, error) { + return e.Features.Subscriptions.ExpandTemplates(e) +} + +// GetSubscriptionTemplate returns a subscription channel template +func (e *Exchange) GetSubscriptionTemplate(_ *subscription.Subscription) (*template.Template, error) { + return template.New("master.tmpl").Funcs(template.FuncMap{"channelName": channelName}).Parse(subTplText) +} + +// Subscribe sends a websocket message to receive data from a list of channels +func (e *Exchange) Subscribe(subs subscription.List) error { + return e.ParallelChanOp(context.TODO(), subs, func(ctx context.Context, subs subscription.List) error { return e.manageSubs(ctx, "subscribe", subs) }, 1) +} + +// Unsubscribe sends a websocket message to stop receiving data from a list of channels +func (e *Exchange) Unsubscribe(subs subscription.List) error { + return e.ParallelChanOp(context.TODO(), subs, func(ctx context.Context, subs subscription.List) error { return e.manageSubs(ctx, "unsubscribe", subs) }, 1) +} + +// manageSubs subscribes or unsubscribes from a list of websocket channels +func (e *Exchange) manageSubs(ctx context.Context, op string, subs subscription.List) error { + var errs error + subs, errs = subs.ExpandTemplates(e) + for _, s := range subs { + r := &WebsocketRequest{ + Type: op, + ProductIDs: s.Pairs, + Channel: s.QualifiedChannel, + Timestamp: strconv.FormatInt(time.Now().Unix(), 10), + } + var err error + limitType := WSUnauthRate + if s.Authenticated { + limitType = WSAuthRate + if r.JWT, err = e.GetWSJWT(ctx); err != nil { + return err + } + } + if err = e.Websocket.Conn.SendJSONMessage(ctx, limitType, r); err == nil { + switch op { + case "subscribe": + err = e.Websocket.AddSuccessfulSubscriptions(e.Websocket.Conn, s) + case "unsubscribe": + err = e.Websocket.RemoveSubscriptions(e.Websocket.Conn, s) + } + } + errs = common.AppendError(errs, err) + } + return errs +} + +// GetWSJWT returns a JWT, using a stored one of it's provided, and generating a new one otherwise +func (e *Exchange) GetWSJWT(ctx context.Context) (string, error) { + e.jwt.m.RLock() + if e.jwt.expiresAt.After(time.Now()) { + retStr := e.jwt.token + e.jwt.m.RUnlock() + return retStr, nil + } + e.jwt.m.RUnlock() + e.jwt.m.Lock() + defer e.jwt.m.Unlock() + var err error + e.jwt.token, e.jwt.expiresAt, err = e.GetJWT(ctx, "") + return e.jwt.token, err +} + +// processBidAskArray is a helper function that turns WebsocketOrderbookDataHolder into arrays of bids and asks +func processBidAskArray(data *WebsocketOrderbookDataHolder, snapshot bool) (bids, asks orderbook.Levels, err error) { + bids = make(orderbook.Levels, 0, len(data.Changes)) + asks = make(orderbook.Levels, 0, len(data.Changes)) + for i := range data.Changes { + change := orderbook.Level{Price: data.Changes[i].PriceLevel.Float64(), Amount: data.Changes[i].NewQuantity.Float64()} + switch data.Changes[i].Side { + case "bid": + bids = append(bids, change) + case "offer": + asks = append(asks, change) + default: + return nil, nil, fmt.Errorf("%w %v", order.ErrSideIsInvalid, data.Changes[i].Side) + } + } + if snapshot { + return slices.Clip(bids), slices.Clip(asks), nil + } + return bids, asks, nil +} + +// statusToStandardStatus is a helper function that converts a Coinbase Pro status string to a standardised order.Status type +func statusToStandardStatus(stat string) (order.Status, error) { + switch stat { + case "PENDING": + return order.New, nil + case "OPEN": + return order.Active, nil + case "FILLED": + return order.Filled, nil + case "CANCELLED": + return order.Cancelled, nil + case "EXPIRED": + return order.Expired, nil + case "FAILED": + return order.Rejected, nil + default: + return order.UnknownStatus, fmt.Errorf("%w %v", order.ErrUnsupportedStatusType, stat) + } +} + +// stringToStandardType is a helper function that converts a Coinbase Pro side string to a standardised order.Type type +func stringToStandardType(str string) (order.Type, error) { + switch str { + case "LIMIT_ORDER_TYPE": + return order.Limit, nil + case "MARKET_ORDER_TYPE": + return order.Market, nil + case "STOP_LIMIT_ORDER_TYPE": + return order.StopLimit, nil + default: + return order.UnknownType, fmt.Errorf("%w %v", order.ErrUnrecognisedOrderType, str) + } +} + +// stringToStandardAsset is a helper function that converts a Coinbase Pro asset string to a standardised asset.Item type +func stringToStandardAsset(str string) (asset.Item, error) { + switch str { + case "SPOT": + return asset.Spot, nil + case "FUTURE": + return asset.Futures, nil + default: + return asset.Empty, asset.ErrNotSupported + } +} + +// strategyDecoder is a helper function that converts a Coinbase Pro time in force string to a few standardised bools +func strategyDecoder(str string) (tif order.TimeInForce, err error) { + switch str { + case "IMMEDIATE_OR_CANCEL": + return order.ImmediateOrCancel, nil + case "FILL_OR_KILL": + return order.FillOrKill, nil + case "GOOD_UNTIL_CANCELLED": + return order.GoodTillCancel, nil + case "GOOD_UNTIL_DATE_TIME": + return order.GoodTillDay | order.GoodTillTime, nil + default: + return order.UnknownTIF, fmt.Errorf("%w %v", errUnrecognisedStrategyType, str) + } +} + +// checkSubscriptions looks for incompatible subscriptions and if found replaces all with defaults +// This should be unnecessary and removable by mid-2025 +func (e *Exchange) checkSubscriptions() { + for _, s := range e.Config.Features.Subscriptions { + switch s.Channel { + case "level2_batch", "matches": + e.Config.Features.Subscriptions = defaultSubscriptions.Clone() + e.Features.Subscriptions = e.Config.Features.Subscriptions.Enabled() + return + } + } +} + +func channelName(s *subscription.Subscription) (string, error) { + if n, ok := subscriptionNames[s.Channel]; ok { + return n, nil + } + return "", fmt.Errorf("%w: %s", subscription.ErrNotSupported, s.Channel) +} + +const subTplText = ` +{{ range $asset, $pairs := $.AssetPairs }} + {{- channelName $.S -}} + {{- $.AssetSeparator }} +{{- end }} +` diff --git a/exchanges/coinbase/coinbase_wrapper.go b/exchanges/coinbase/coinbase_wrapper.go new file mode 100644 index 00000000..d3e24d29 --- /dev/null +++ b/exchanges/coinbase/coinbase_wrapper.go @@ -0,0 +1,1206 @@ +package coinbase + +import ( + "context" + "fmt" + "maps" + "slices" + "time" + + "github.com/shopspring/decimal" + "github.com/thrasher-corp/gocryptotrader/common" + "github.com/thrasher-corp/gocryptotrader/common/key" + "github.com/thrasher-corp/gocryptotrader/config" + "github.com/thrasher-corp/gocryptotrader/currency" + "github.com/thrasher-corp/gocryptotrader/exchange/order/limits" + "github.com/thrasher-corp/gocryptotrader/exchange/websocket" + "github.com/thrasher-corp/gocryptotrader/exchange/websocket/buffer" + exchange "github.com/thrasher-corp/gocryptotrader/exchanges" + "github.com/thrasher-corp/gocryptotrader/exchanges/account" + "github.com/thrasher-corp/gocryptotrader/exchanges/asset" + "github.com/thrasher-corp/gocryptotrader/exchanges/deposit" + "github.com/thrasher-corp/gocryptotrader/exchanges/fundingrate" + "github.com/thrasher-corp/gocryptotrader/exchanges/futures" + "github.com/thrasher-corp/gocryptotrader/exchanges/kline" + "github.com/thrasher-corp/gocryptotrader/exchanges/order" + "github.com/thrasher-corp/gocryptotrader/exchanges/orderbook" + "github.com/thrasher-corp/gocryptotrader/exchanges/protocol" + "github.com/thrasher-corp/gocryptotrader/exchanges/request" + "github.com/thrasher-corp/gocryptotrader/exchanges/ticker" + "github.com/thrasher-corp/gocryptotrader/exchanges/trade" + "github.com/thrasher-corp/gocryptotrader/log" + "github.com/thrasher-corp/gocryptotrader/portfolio/withdraw" +) + +// SetDefaults sets default values for the exchange +func (e *Exchange) SetDefaults() { + e.Name = "Coinbase" + e.Enabled = true + e.API.CredentialsValidator.RequiresKey = true + e.API.CredentialsValidator.RequiresSecret = true + requestFmt := ¤cy.PairFormat{Delimiter: currency.DashDelimiter, Uppercase: true} + configFmt := ¤cy.PairFormat{Delimiter: currency.DashDelimiter, Uppercase: true} + err := e.SetGlobalPairsManager(requestFmt, configFmt, asset.Spot, asset.Futures) + if err != nil { + log.Errorln(log.ExchangeSys, err) + } + e.Features = exchange.Features{ + Supports: exchange.FeaturesSupported{ + REST: true, + Websocket: true, + RESTCapabilities: protocol.Features{ + AutoPairUpdates: true, + AccountBalance: true, + CryptoDeposit: true, + CryptoWithdrawal: true, + FiatWithdraw: true, + GetOrder: true, + GetOrders: true, + CancelOrders: true, + CancelOrder: true, + SubmitOrder: true, + ModifyOrder: true, + DepositHistory: true, + WithdrawalHistory: true, + FiatWithdrawalFee: true, + CryptoWithdrawalFee: true, + TickerFetching: true, + KlineFetching: true, + OrderbookFetching: true, + AccountInfo: true, + FiatDeposit: true, + FundingRateFetching: true, + HasAssetTypeAccountSegregation: true, + }, + WebsocketCapabilities: protocol.Features{ + TickerFetching: true, + OrderbookFetching: true, + Subscribe: true, + Unsubscribe: true, + AuthenticatedEndpoints: true, + MessageSequenceNumbers: true, + GetOrders: true, + GetOrder: true, + }, + WithdrawPermissions: exchange.AutoWithdrawCryptoWithAPIPermission | + exchange.AutoWithdrawFiatWithAPIPermission, + Kline: kline.ExchangeCapabilitiesSupported{ + DateRanges: true, + Intervals: true, + }, + }, + Enabled: exchange.FeaturesEnabled{ + AutoPairUpdates: true, + Kline: kline.ExchangeCapabilitiesEnabled{ + Intervals: kline.DeployExchangeIntervals( + kline.IntervalCapacity{Interval: kline.OneMin}, + kline.IntervalCapacity{Interval: kline.FiveMin}, + kline.IntervalCapacity{Interval: kline.FifteenMin}, + kline.IntervalCapacity{Interval: kline.ThirtyMin}, + kline.IntervalCapacity{Interval: kline.OneHour}, + kline.IntervalCapacity{Interval: kline.TwoHour}, + kline.IntervalCapacity{Interval: kline.SixHour}, + kline.IntervalCapacity{Interval: kline.OneDay}, + ), + GlobalResultLimit: 300, + }, + }, + Subscriptions: defaultSubscriptions.Clone(), + TradingRequirements: protocol.TradingRequirements{}, + } + if e.Requester, err = request.New(e.Name, common.NewHTTPClientWithTimeout(exchange.DefaultHTTPTimeout), request.WithLimiter(rateLimits)); err != nil { + log.Errorln(log.ExchangeSys, err) + } + e.API.Endpoints = e.NewEndpoints() + if err = e.API.Endpoints.SetDefaultEndpoints(map[exchange.URL]string{ + exchange.RestSpot: apiURL, + exchange.RestSandbox: sandboxAPIURL, + exchange.WebsocketSpot: coinbaseWebsocketURL, + exchange.RestSpotSupplementary: v1APIURL, + }); err != nil { + log.Errorln(log.ExchangeSys, err) + } + e.Websocket = websocket.NewManager() + e.WebsocketResponseMaxLimit = exchange.DefaultWebsocketResponseMaxLimit + e.WebsocketResponseCheckTimeout = exchange.DefaultWebsocketResponseCheckTimeout + e.WebsocketOrderbookBufferLimit = exchange.DefaultWebsocketOrderbookBufferLimit +} + +// Setup initialises the exchange parameters with the current configuration +func (e *Exchange) Setup(exch *config.Exchange) error { + if err := exch.Validate(); err != nil { + return err + } + if !exch.Enabled { + e.SetEnabled(false) + return nil + } + if err := e.SetupDefaults(exch); err != nil { + return err + } + e.checkSubscriptions() + wsRunningURL, err := e.API.Endpoints.GetURL(exchange.WebsocketSpot) + if err != nil { + return err + } + + if err := e.Websocket.Setup(&websocket.ManagerSetup{ + ExchangeConfig: exch, + DefaultURL: coinbaseWebsocketURL, + RunningURL: wsRunningURL, + Connector: e.WsConnect, + Subscriber: e.Subscribe, + Unsubscriber: e.Unsubscribe, + GenerateSubscriptions: e.generateSubscriptions, + Features: &e.Features.Supports.WebsocketCapabilities, + OrderbookBufferConfig: buffer.Config{ + SortBuffer: true, + }, + }); err != nil { + return err + } + + return e.Websocket.SetupNewConnection(&websocket.ConnectionSetup{ + ResponseCheckTimeout: exch.WebsocketResponseCheckTimeout, + ResponseMaxLimit: exch.WebsocketResponseMaxLimit, + }) +} + +// FetchTradablePairs returns a list of the exchanges tradable pairs +func (e *Exchange) FetchTradablePairs(ctx context.Context, a asset.Item) (currency.Pairs, error) { + aString := FormatAssetOutbound(a) + products, err := e.GetAllProducts(ctx, 0, 0, aString, "", "", "", nil, false, true, false) + if err != nil { + return nil, err + } + pairs := make([]currency.Pair, 0, len(products.Products)) + aliases := make(map[currency.Pair]currency.Pairs) + for x := range products.Products { + if products.Products[x].TradingDisabled { + continue + } + if products.Products[x].Price == 0 { + continue + } + pairs = append(pairs, products.Products[x].ID) + if !products.Products[x].Alias.IsEmpty() { + aliases[products.Products[x].Alias] = aliases[products.Products[x].Alias].Add(products.Products[x].ID) + } + if len(products.Products[x].AliasTo) > 0 { + aliases[products.Products[x].ID] = aliases[products.Products[x].ID].Add(products.Products[x].AliasTo...) + } + // Products need to be considered aliases of themselves for some code in websocket, and it seems better to add that here + aliases[products.Products[x].ID] = aliases[products.Products[x].ID].Add(products.Products[x].ID) + } + e.pairAliases.Load(aliases) + return pairs, nil +} + +// UpdateTradablePairs updates the exchanges available pairs and stores them in the exchanges config +func (e *Exchange) UpdateTradablePairs(ctx context.Context, forceUpdate bool) error { + assets := e.GetAssetTypes(false) + for i := range assets { + pairs, err := e.FetchTradablePairs(ctx, assets[i]) + if err != nil { + return err + } + if err := e.UpdatePairs(pairs, assets[i], false, forceUpdate); err != nil { + return err + } + } + return e.EnsureOnePairEnabled() +} + +// UpdateAccountInfo retrieves balances for all enabled currencies for the coinbase exchange +func (e *Exchange) UpdateAccountInfo(ctx context.Context, assetType asset.Item) (account.Holdings, error) { + var ( + response account.Holdings + accountBalance []Account + done bool + err error + cursor int64 + accountResp *AllAccountsResponse + ) + response.Exchange = e.Name + for !done { + if accountResp, err = e.ListAccounts(ctx, 250, cursor); err != nil { + return response, err + } + accountBalance = append(accountBalance, accountResp.Accounts...) + done = !accountResp.HasNext + cursor = int64(accountResp.Cursor) + } + accountCurrencies := make(map[string][]account.Balance) + for i := range accountBalance { + profileID := accountBalance[i].UUID + currencies := accountCurrencies[profileID] + accountCurrencies[profileID] = append(currencies, account.Balance{ + Currency: currency.NewCode(accountBalance[i].Currency), + Total: accountBalance[i].AvailableBalance.Value.Float64(), + Hold: accountBalance[i].Hold.Value.Float64(), + Free: accountBalance[i].AvailableBalance.Value.Float64() - accountBalance[i].Hold.Value.Float64(), + AvailableWithoutBorrow: accountBalance[i].AvailableBalance.Value.Float64(), + }) + } + if response.Accounts, err = account.CollectBalances(accountCurrencies, assetType); err != nil { + return account.Holdings{}, err + } + creds, err := e.GetCredentials(ctx) + if err != nil { + return account.Holdings{}, err + } + if err := account.Process(&response, creds); err != nil { + return account.Holdings{}, err + } + return response, nil +} + +// UpdateTickers updates all currency pairs of a given asset type +func (e *Exchange) UpdateTickers(context.Context, asset.Item) error { + return common.ErrFunctionNotSupported +} + +// UpdateTicker updates and returns the ticker for a currency pair +func (e *Exchange) UpdateTicker(ctx context.Context, p currency.Pair, a asset.Item) (*ticker.Price, error) { + fPair, err := e.FormatExchangeCurrency(p, a) + if err != nil { + return nil, err + } + if err := e.tickerHelper(ctx, fPair, a); err != nil { + return nil, err + } + return ticker.GetTicker(e.Name, p, a) +} + +// UpdateOrderbook updates and returns the orderbook for a currency pair +func (e *Exchange) UpdateOrderbook(ctx context.Context, p currency.Pair, assetType asset.Item) (*orderbook.Book, error) { + if p.IsEmpty() { + return nil, currency.ErrCurrencyPairEmpty + } + p, err := e.FormatExchangeCurrency(p, assetType) + if err != nil { + return nil, err + } + if err := e.CurrencyPairs.IsAssetEnabled(assetType); err != nil { + return nil, err + } + book := &orderbook.Book{ + Exchange: e.Name, + Pair: p, + Asset: assetType, + ValidateOrderbook: e.ValidateOrderbook, + } + var orderbookNew *ProductBookResp + if orderbookNew, err = e.GetProductBookV3(ctx, p, 1000, 0, false); err != nil { + return book, err + } + book.Bids = make(orderbook.Levels, len(orderbookNew.Pricebook.Bids)) + for x := range orderbookNew.Pricebook.Bids { + book.Bids[x] = orderbook.Level{ + Amount: orderbookNew.Pricebook.Bids[x].Size.Float64(), + Price: orderbookNew.Pricebook.Bids[x].Price.Float64(), + } + } + book.Asks = make(orderbook.Levels, len(orderbookNew.Pricebook.Asks)) + for x := range orderbookNew.Pricebook.Asks { + book.Asks[x] = orderbook.Level{ + Amount: orderbookNew.Pricebook.Asks[x].Size.Float64(), + Price: orderbookNew.Pricebook.Asks[x].Price.Float64(), + } + } + aliases := e.pairAliases.GetAlias(p) + var errs error + var validPairs currency.Pairs + for i := range aliases { + isEnabled, err := e.CurrencyPairs.IsPairEnabled(aliases[i], assetType) + if err != nil { + errs = common.AppendError(errs, err) + continue + } + if isEnabled { + book.Pair = aliases[i] + if err := book.Process(); err != nil { + errs = common.AppendError(errs, err) + continue + } + validPairs = append(validPairs, book.Pair) + } + } + if errs != nil { + return book, errs + } + if len(validPairs) == 0 { + return book, errPairsDisabledOrErrored + } + return orderbook.Get(e.Name, validPairs[0], assetType) +} + +// GetAccountFundingHistory returns funding history, deposits and withdrawals +func (e *Exchange) GetAccountFundingHistory(ctx context.Context) ([]exchange.FundingHistory, error) { + wallIDs, err := e.GetAllWallets(ctx, PaginationInp{}) + if err != nil { + return nil, err + } + if len(wallIDs.Data) == 0 { + return nil, errNoWalletsReturned + } + var accHistory []DeposWithdrData + for i := range wallIDs.Data { + tempAccHist, err := e.GetAllFiatTransfers(ctx, wallIDs.Data[i].ID, PaginationInp{}, FiatDeposit) + if err != nil { + return nil, err + } + accHistory = append(accHistory, tempAccHist.Data...) + if tempAccHist, err = e.GetAllFiatTransfers(ctx, wallIDs.Data[i].ID, PaginationInp{}, FiatWithdrawal); err != nil { + return nil, err + } + accHistory = append(accHistory, tempAccHist.Data...) + } + var cryptoHistory []TransactionData + for i := range wallIDs.Data { + tempCryptoHist, err := e.GetAllTransactions(ctx, wallIDs.Data[i].ID, PaginationInp{}) + if err != nil { + return nil, err + } + for j := range tempCryptoHist.Data { + if tempCryptoHist.Data[j].Type == "receive" || tempCryptoHist.Data[j].Type == "send" { + cryptoHistory = append(cryptoHistory, tempCryptoHist.Data[j]) + } + } + } + return e.processFundingData(accHistory, cryptoHistory) +} + +// GetWithdrawalsHistory returns previous withdrawals data +func (e *Exchange) GetWithdrawalsHistory(ctx context.Context, cur currency.Code, _ asset.Item) ([]exchange.WithdrawalHistory, error) { + tempWallIDs, err := e.GetAllWallets(ctx, PaginationInp{}) + if err != nil { + return nil, err + } + if len(tempWallIDs.Data) == 0 { + return nil, errNoWalletsReturned + } + var wallIDs []string + for i := range tempWallIDs.Data { + if tempWallIDs.Data[i].Currency.Code == cur.String() { + wallIDs = append(wallIDs, tempWallIDs.Data[i].ID) + } + } + if len(wallIDs) == 0 { + return nil, errNoMatchingWallets + } + var accHistory []DeposWithdrData + for i := range wallIDs { + tempAccHist, err := e.GetAllFiatTransfers(ctx, wallIDs[i], PaginationInp{}, FiatWithdrawal) + if err != nil { + return nil, err + } + accHistory = append(accHistory, tempAccHist.Data...) + } + var cryptoHistory []TransactionData + for i := range wallIDs { + tempCryptoHist, err := e.GetAllTransactions(ctx, wallIDs[i], PaginationInp{}) + if err != nil { + return nil, err + } + for j := range tempCryptoHist.Data { + if tempCryptoHist.Data[j].Type == "send" { + cryptoHistory = append(cryptoHistory, tempCryptoHist.Data[j]) + } + } + } + tempFundingData, err := e.processFundingData(accHistory, cryptoHistory) + if err != nil { + return nil, err + } + fundingData := make([]exchange.WithdrawalHistory, len(tempFundingData)) + for i := range tempFundingData { + fundingData[i] = exchange.WithdrawalHistory{ + Status: tempFundingData[i].Status, + TransferID: tempFundingData[i].TransferID, + Description: tempFundingData[i].Description, + Timestamp: tempFundingData[i].Timestamp, + Currency: tempFundingData[i].Currency, + Amount: tempFundingData[i].Amount, + Fee: tempFundingData[i].Fee, + TransferType: tempFundingData[i].TransferType, + CryptoToAddress: tempFundingData[i].CryptoToAddress, + CryptoTxID: tempFundingData[i].CryptoTxID, + CryptoChain: tempFundingData[i].CryptoChain, + BankTo: tempFundingData[i].BankTo, + } + } + return fundingData, nil +} + +// GetRecentTrades returns the most recent trades for a currency and asset +func (e *Exchange) GetRecentTrades(context.Context, currency.Pair, asset.Item) ([]trade.Data, error) { + return nil, common.ErrFunctionNotSupported +} + +// GetHistoricTrades returns historic trade data within the timeframe provided +func (e *Exchange) GetHistoricTrades(_ context.Context, _ currency.Pair, _ asset.Item, _, _ time.Time) ([]trade.Data, error) { + return nil, common.ErrFunctionNotSupported +} + +// SubmitOrder submits a new order +func (e *Exchange) SubmitOrder(ctx context.Context, s *order.Submit) (*order.SubmitResponse, error) { + if err := s.Validate(e.GetTradingRequirements()); err != nil { + return nil, err + } + fPair, err := e.FormatExchangeCurrency(s.Pair, s.AssetType) + if err != nil { + return nil, err + } + var stopDir string + if s.Type == order.StopLimit { + switch s.StopDirection { + case order.StopUp: + stopDir = "STOP_DIRECTION_STOP_UP" + case order.StopDown: + stopDir = "STOP_DIRECTION_STOP_DOWN" + } + } + resp, err := e.PlaceOrder(ctx, &PlaceOrderInfo{ + ClientOID: s.ClientOrderID, + ProductID: fPair.String(), + Side: s.Side.String(), + MarginType: s.MarginType.Upper(), + Leverage: s.Leverage, + OrderInfo: OrderInfo{ + StopDirection: stopDir, + OrderType: s.Type, + TimeInForce: s.TimeInForce, + BaseAmount: s.Amount, + QuoteAmount: s.QuoteAmount, + LimitPrice: s.Price, + StopPrice: s.TriggerPrice, + PostOnly: s.TimeInForce.Is(order.PostOnly), + RFQDisabled: s.RFQDisabled, + EndTime: s.EndTime, + }, + }) + if err != nil { + return nil, err + } + subResp, err := s.DeriveSubmitResponse(resp.SuccessResponse.OrderID) + if err != nil { + return nil, err + } + if s.RetrieveFees { + time.Sleep(s.RetrieveFeeDelay) + feeResp, err := e.GetOrderByID(ctx, resp.SuccessResponse.OrderID, s.ClientOrderID, currency.Code{}) + if err != nil { + return nil, err + } + subResp.Fee = feeResp.TotalFees.Float64() + } + return subResp, nil +} + +// ModifyOrder will allow of changing orderbook placement and limit to market conversion +func (e *Exchange) ModifyOrder(ctx context.Context, m *order.Modify) (*order.ModifyResponse, error) { + if m == nil { + return nil, common.ErrNilPointer + } + if err := m.Validate(); err != nil { + return nil, err + } + success, err := e.EditOrder(ctx, m.OrderID, m.Amount, m.Price) + if err != nil { + return nil, err + } + if !success { + return nil, errOrderModFailNoRet + } + return m.DeriveModifyResponse() +} + +// CancelOrder cancels an order by its corresponding ID number +func (e *Exchange) CancelOrder(ctx context.Context, o *order.Cancel) error { + if o == nil { + return common.ErrNilPointer + } + if err := o.Validate(o.StandardCancel()); err != nil { + return err + } + canSlice := []order.Cancel{*o} + resp, err := e.CancelBatchOrders(ctx, canSlice) + if err != nil { + return err + } + if resp.Status[o.OrderID] != order.Cancelled.String() { + return fmt.Errorf("%w %v", errOrderFailedToCancel, o.OrderID) + } + return nil +} + +// CancelBatchOrders cancels orders by their corresponding ID numbers +func (e *Exchange) CancelBatchOrders(ctx context.Context, o []order.Cancel) (*order.CancelBatchResponse, error) { + var status order.CancelBatchResponse + ordToCancel := len(o) + if ordToCancel == 0 { + return nil, order.ErrOrderIDNotSet + } + status.Status = make(map[string]string) + ordIDSlice := make([]string, ordToCancel) + for i := range o { + if err := o[i].Validate(o[i].StandardCancel()); err != nil { + return nil, err + } + ordIDSlice[i] = o[i].OrderID + status.Status[o[i].OrderID] = "Failed to cancel" + } + resp := struct { + Results []OrderCancelDetail `json:"results"` + }{} + for i := 0; i < ordToCancel; i += 100 { + var tempOrdIDSlice []string + if ordToCancel-i < 100 { + tempOrdIDSlice = ordIDSlice[i:] + } else { + tempOrdIDSlice = ordIDSlice[i : i+100] + } + tempResp, err := e.CancelOrders(ctx, tempOrdIDSlice) + if err != nil { + return nil, err + } + resp.Results = append(resp.Results, tempResp...) + } + for i := range resp.Results { + if resp.Results[i].Success { + status.Status[resp.Results[i].OrderID] = order.Cancelled.String() + } + } + return &status, nil +} + +// CancelAllOrders cancels all orders associated with a currency pair +func (e *Exchange) CancelAllOrders(context.Context, *order.Cancel) (order.CancelAllResponse, error) { + return order.CancelAllResponse{}, common.ErrFunctionNotSupported +} + +// GetOrderInfo returns order information based on order ID +func (e *Exchange) GetOrderInfo(ctx context.Context, orderID string, pair currency.Pair, assetItem asset.Item) (*order.Detail, error) { + genOrderDetail, err := e.GetOrderByID(ctx, orderID, "", currency.Code{}) + if err != nil { + return nil, err + } + response := e.getOrderRespToOrderDetail(genOrderDetail, pair, assetItem) + fillData, err := e.ListFills(ctx, []string{orderID}, nil, nil, 0, "", time.Time{}, time.Now(), defaultOrderFillCount) + if err != nil { + return nil, err + } + cursor := fillData.Cursor + for cursor != 0 { + tempFillData, err := e.ListFills(ctx, []string{orderID}, nil, nil, int64(cursor), "", time.Time{}, time.Now(), defaultOrderFillCount) + if err != nil { + return nil, err + } + fillData.Fills = append(fillData.Fills, tempFillData.Fills...) + cursor = tempFillData.Cursor + } + response.Trades = make([]order.TradeHistory, len(fillData.Fills)) + var orderSide order.Side + switch response.Side { + case order.Buy: + orderSide = order.Sell + case order.Sell: + orderSide = order.Buy + } + for i := range fillData.Fills { + response.Trades[i] = order.TradeHistory{ + Price: fillData.Fills[i].Price.Float64(), + Amount: fillData.Fills[i].Size.Float64(), + Fee: fillData.Fills[i].Commission.Float64(), + Exchange: e.GetName(), + TID: fillData.Fills[i].TradeID, + Side: orderSide, + Timestamp: fillData.Fills[i].TradeTime, + Total: fillData.Fills[i].Price.Float64() * fillData.Fills[i].Size.Float64(), + } + } + return response, nil +} + +// GetDepositAddress returns a deposit address for a specified currency +func (e *Exchange) GetDepositAddress(ctx context.Context, cryptocurrency currency.Code, _, _ string) (*deposit.Address, error) { + allWalResp, err := e.GetAllWallets(ctx, PaginationInp{}) + if err != nil { + return nil, err + } + var targetWalletID string + for i := range allWalResp.Data { + if allWalResp.Data[i].Currency.Code == cryptocurrency.String() { + targetWalletID = allWalResp.Data[i].ID + break + } + } + if targetWalletID == "" { + return nil, errNoWalletForCurrency + } + resp, err := e.GetAllAddresses(ctx, targetWalletID, PaginationInp{}) + if err != nil || len(resp.Data) == 0 { + resp2, err2 := e.CreateAddress(ctx, targetWalletID, "") + if err2 != nil { + return nil, common.AppendError(err, err2) + } + return &deposit.Address{ + Address: resp2.Address, + Tag: resp2.Name, + Chain: resp2.Network, + }, nil + } + return &deposit.Address{ + Address: resp.Data[0].Address, + Tag: resp.Data[0].Name, + Chain: resp.Data[0].Network, + }, nil +} + +// WithdrawCryptocurrencyFunds returns a withdrawal ID when a withdrawal is submitted +func (e *Exchange) WithdrawCryptocurrencyFunds(ctx context.Context, withdrawRequest *withdraw.Request) (*withdraw.ExchangeResponse, error) { + if err := withdrawRequest.Validate(); err != nil { + return nil, err + } + if withdrawRequest.WalletID == "" { + return nil, errWalletIDEmpty + } + travel := &TravelRule{ + BeneficiaryWalletType: withdrawRequest.Travel.BeneficiaryWalletType, + BeneficiaryName: withdrawRequest.Travel.BeneficiaryName, + BeneficiaryAddress: FullAddress{ + Address1: withdrawRequest.Travel.BeneficiaryAddress.Address1, + Address2: withdrawRequest.Travel.BeneficiaryAddress.Address2, + Address3: withdrawRequest.Travel.BeneficiaryAddress.Address3, + City: withdrawRequest.Travel.BeneficiaryAddress.City, + State: withdrawRequest.Travel.BeneficiaryAddress.State, + Country: withdrawRequest.Travel.BeneficiaryAddress.Country, + PostalCode: withdrawRequest.Travel.BeneficiaryAddress.PostalCode, + }, + BeneficiaryFinancialInstitution: withdrawRequest.Travel.BeneficiaryFinancialInstitution, + TransferPurpose: withdrawRequest.Travel.TransferPurpose, + } + if withdrawRequest.Travel.IsSelf { + travel.IsSelf = "IS_SELF_TRUE" + } else { + travel.IsSelf = "IS_SELF_FALSE" + } + resp, err := e.SendMoney(ctx, "send", withdrawRequest.WalletID, withdrawRequest.Crypto.Address, withdrawRequest.Description, withdrawRequest.IdempotencyToken, withdrawRequest.Crypto.AddressTag, "", withdrawRequest.Currency, withdrawRequest.Amount, false, travel) + if err != nil { + return nil, err + } + return &withdraw.ExchangeResponse{ID: resp.ID, Status: resp.Status}, nil +} + +// WithdrawFiatFunds returns a withdrawal ID when a withdrawal is submitted +func (e *Exchange) WithdrawFiatFunds(ctx context.Context, withdrawRequest *withdraw.Request) (*withdraw.ExchangeResponse, error) { + if err := withdrawRequest.Validate(); err != nil { + return nil, err + } + if withdrawRequest.WalletID == "" { + return nil, errWalletIDEmpty + } + paymentMethods, err := e.ListPaymentMethods(ctx) + if err != nil { + return nil, err + } + selectedWithdrawalMethod := PaymentMethodData{} + for i := range paymentMethods { + if withdrawRequest.Fiat.Bank.BankName == paymentMethods[i].Name { + selectedWithdrawalMethod = paymentMethods[i] + break + } + } + if selectedWithdrawalMethod.ID == "" { + return nil, fmt.Errorf("%w %v", errPayMethodNotFound, withdrawRequest.Fiat.Bank.BankName) + } + resp, err := e.FiatTransfer(ctx, withdrawRequest.WalletID, withdrawRequest.Currency.String(), selectedWithdrawalMethod.ID, withdrawRequest.Amount, true, FiatWithdrawal) + if err != nil { + return nil, err + } + return &withdraw.ExchangeResponse{ + Name: selectedWithdrawalMethod.Name, + ID: resp.ID, + Status: resp.Status, + }, nil +} + +// WithdrawFiatFundsToInternationalBank returns a withdrawal ID when a withdrawal is submitted +func (e *Exchange) WithdrawFiatFundsToInternationalBank(ctx context.Context, withdrawRequest *withdraw.Request) (*withdraw.ExchangeResponse, error) { + return e.WithdrawFiatFunds(ctx, withdrawRequest) +} + +// GetFeeByType returns an estimate of fee based on type of transaction +func (e *Exchange) GetFeeByType(ctx context.Context, feeBuilder *exchange.FeeBuilder) (float64, error) { + if feeBuilder == nil { + return 0, fmt.Errorf("%T %w", feeBuilder, common.ErrNilPointer) + } + if !e.AreCredentialsValid(ctx) && feeBuilder.FeeType == exchange.CryptocurrencyTradeFee { + feeBuilder.FeeType = exchange.OfflineTradeFee + } + return e.GetFee(ctx, feeBuilder) +} + +// GetActiveOrders retrieves any orders that are active/open +func (e *Exchange) GetActiveOrders(ctx context.Context, req *order.MultiOrderRequest) (order.FilteredOrders, error) { + if req == nil { + return nil, common.ErrNilPointer + } + err := req.Validate() + if err != nil { + return nil, err + } + var respOrders []GetOrderResponse + if respOrders, err = e.iterativeGetAllOrders(ctx, req.Pairs, req.Type.String(), req.Side.String(), req.AssetType.Upper(), openStatus, 1000, req.StartTime, req.EndTime); err != nil { + return nil, err + } + orders := make([]order.Detail, len(respOrders)) + for i := range respOrders { + orderRec := e.getOrderRespToOrderDetail(&respOrders[i], respOrders[i].ProductID, req.AssetType) + orders[i] = *orderRec + } + if len(req.Pairs) > 1 { + order.FilterOrdersByPairs(&orders, req.Pairs) + } + return req.Filter(e.Name, orders), nil +} + +// GetOrderHistory retrieves account order information. Can Limit response to specific order status +func (e *Exchange) GetOrderHistory(ctx context.Context, req *order.MultiOrderRequest) (order.FilteredOrders, error) { + err := req.Validate() + if err != nil { + return nil, err + } + for i := range req.Pairs { + req.Pairs[i], err = e.FormatExchangeCurrency(req.Pairs[i], req.AssetType) + if err != nil { + return nil, err + } + } + var ord []GetOrderResponse + interOrd, err := e.iterativeGetAllOrders(ctx, req.Pairs, req.Type.String(), req.Side.String(), req.AssetType.Upper(), closedStatuses, defaultOrderCount, req.StartTime, req.EndTime) + if err != nil { + return nil, err + } + ord = append(ord, interOrd...) + if interOrd, err = e.iterativeGetAllOrders(ctx, req.Pairs, req.Type.String(), req.Side.String(), req.AssetType.Upper(), openStatus, defaultOrderCount, req.StartTime, req.EndTime); err != nil { + return nil, err + } + ord = append(ord, interOrd...) + orders := make([]order.Detail, len(ord)) + for i := range ord { + singleOrder := e.getOrderRespToOrderDetail(&ord[i], ord[i].ProductID, req.AssetType) + orders[i] = *singleOrder + } + if len(req.Pairs) > 1 { + order.FilterOrdersByPairs(&orders, req.Pairs) + } + return req.Filter(e.Name, orders), nil +} + +// GetHistoricCandles returns a set of candle between two time periods for a designated time period +func (e *Exchange) GetHistoricCandles(ctx context.Context, pair currency.Pair, a asset.Item, interval kline.Interval, start, end time.Time) (*kline.Item, error) { + req, err := e.GetKlineRequest(pair, a, interval, start, end, false) + if err != nil { + return nil, err + } + timeSeries, err := e.GetHistoricKlines(ctx, req.RequestFormatted.String(), interval, start, end, false) + if err != nil { + return nil, err + } + return req.ProcessResponse(timeSeries) +} + +// GetHistoricCandlesExtended returns candles between a time period for a set time interval +func (e *Exchange) GetHistoricCandlesExtended(ctx context.Context, pair currency.Pair, a asset.Item, interval kline.Interval, start, end time.Time) (*kline.Item, error) { + req, err := e.GetKlineExtendedRequest(pair, a, interval, start, end) + if err != nil { + return nil, err + } + var timeSeries []kline.Candle + for x := range req.RangeHolder.Ranges { + hist, err := e.GetHistoricKlines(ctx, req.RequestFormatted.String(), interval, req.RangeHolder.Ranges[x].Start.Time.Add(-time.Nanosecond), req.RangeHolder.Ranges[x].End.Time.Add(-time.Nanosecond), false) + if err != nil { + return nil, err + } + timeSeries = append(timeSeries, hist...) + } + return req.ProcessResponse(timeSeries) +} + +// ValidateAPICredentials validates current credentials used for wrapper functionality +func (e *Exchange) ValidateAPICredentials(ctx context.Context, assetType asset.Item) error { + _, err := e.UpdateAccountInfo(ctx, assetType) + return e.CheckTransientError(err) +} + +// GetServerTime returns the current exchange server time. +func (e *Exchange) GetServerTime(ctx context.Context, _ asset.Item) (time.Time, error) { + st, err := e.GetV3Time(ctx) + if err != nil { + return time.Time{}, err + } + return st.Iso, nil +} + +// GetLatestFundingRates returns the latest funding rates data +func (e *Exchange) GetLatestFundingRates(ctx context.Context, r *fundingrate.LatestRateRequest) ([]fundingrate.LatestRateResponse, error) { + if r == nil { + return nil, common.ErrNilPointer + } + if !e.SupportsAsset(r.Asset) { + return nil, fmt.Errorf("%w %v", asset.ErrNotSupported, r.Asset) + } + products, perpStart, err := e.fetchFutures(ctx) + if err != nil { + return nil, err + } + funding := make([]fundingrate.LatestRateResponse, len(products.Products)) + for i := perpStart; i < len(products.Products); i++ { + funRate := fundingrate.Rate{ + Time: products.Products[i].FutureProductDetails.PerpetualDetails.FundingTime, + Rate: decimal.NewFromFloat(products.Products[i].FutureProductDetails.PerpetualDetails.FundingRate.Float64()), + } + funding[i] = fundingrate.LatestRateResponse{ + Exchange: e.Name, + Asset: r.Asset, + Pair: products.Products[i].ID, + LatestRate: funRate, + TimeChecked: time.Now(), + } + } + return funding, nil +} + +// GetFuturesContractDetails returns all contracts from the exchange by asset type +func (e *Exchange) GetFuturesContractDetails(ctx context.Context, item asset.Item) ([]futures.Contract, error) { + if !item.IsFutures() { + return nil, futures.ErrNotFuturesAsset + } + if !e.SupportsAsset(item) { + return nil, fmt.Errorf("%w %v", asset.ErrNotSupported, item) + } + products, perpStart, err := e.fetchFutures(ctx) + if err != nil { + return nil, err + } + contracts := make([]futures.Contract, len(products.Products)) + for i := range products.Products { + funRate := fundingrate.Rate{ + Time: products.Products[i].FutureProductDetails.PerpetualDetails.FundingTime, + Rate: decimal.NewFromFloat(products.Products[i].FutureProductDetails.PerpetualDetails.FundingRate.Float64()), + } + contracts[i] = futures.Contract{ + Exchange: e.Name, + Name: products.Products[i].ID, + Asset: item, + EndDate: products.Products[i].FutureProductDetails.ContractExpiry, + IsActive: !products.Products[i].IsDisabled, + Status: products.Products[i].Status, + SettlementCurrencies: currency.Currencies{products.Products[i].QuoteCurrencyID}, + Multiplier: products.Products[i].BaseIncrement.Float64(), + LatestRate: funRate, + } + if i < perpStart { + contracts[i].Type = futures.LongDated + } else { + contracts[i].Type = futures.Perpetual + } + } + return contracts, nil +} + +// UpdateOrderExecutionLimits updates order execution limits +func (e *Exchange) UpdateOrderExecutionLimits(ctx context.Context, a asset.Item) error { + if !e.SupportsAsset(a) { + return fmt.Errorf("%w %q", asset.ErrNotSupported, a) + } + aString := FormatAssetOutbound(a) + data, err := e.GetAllProducts(ctx, 0, 0, aString, "", "", "", nil, false, true, false) + if err != nil { + return err + } + lim := make([]limits.MinMaxLevel, len(data.Products)) + for i := range data.Products { + lim[i] = limits.MinMaxLevel{ + Key: key.NewExchangeAssetPair(e.Name, a, data.Products[i].ID), + MinPrice: data.Products[i].QuoteMinSize.Float64(), + MaxPrice: data.Products[i].QuoteMaxSize.Float64(), + PriceStepIncrementSize: data.Products[i].PriceIncrement.Float64(), + MinimumBaseAmount: data.Products[i].BaseMinSize.Float64(), + MaximumBaseAmount: data.Products[i].BaseMaxSize.Float64(), + MinimumQuoteAmount: data.Products[i].QuoteMinSize.Float64(), + MaximumQuoteAmount: data.Products[i].QuoteMaxSize.Float64(), + AmountStepIncrementSize: data.Products[i].BaseIncrement.Float64(), + QuoteStepIncrementSize: data.Products[i].QuoteIncrement.Float64(), + MaxTotalOrders: 1000, + } + } + return limits.Load(lim) +} + +// GetCurrencyTradeURL returns the URL to the exchange's trade page for the given asset and currency pair +func (e *Exchange) GetCurrencyTradeURL(_ context.Context, a asset.Item, cp currency.Pair) (string, error) { + if _, err := e.CurrencyPairs.IsPairEnabled(cp, a); err != nil { + return "", err + } + cp.Delimiter = currency.DashDelimiter + return tradeBaseURL + cp.Upper().String(), nil +} + +// fetchFutures is a helper function for GetLatestFundingRates and GetFuturesContractDetails that calls the List Products endpoint twice, to get both expiring futures and perpetual futures +func (e *Exchange) fetchFutures(ctx context.Context) (*AllProducts, int, error) { + products, err := e.GetAllProducts(ctx, 0, 0, "FUTURE", "", "", "", nil, false, false, false) + if err != nil { + return nil, 0, err + } + products2, err := e.GetAllProducts(ctx, 0, 0, "FUTURE", "PERPETUAL", "", "", nil, false, false, false) + if err != nil { + return nil, 0, err + } + perpStart := len(products.Products) + products.Products = append(products.Products, products2.Products...) + return products, perpStart, nil +} + +// processFundingData is a helper function for GetAccountFundingHistory and GetWithdrawalsHistory, transforming the data returned by the Coinbase API into a format suitable for the exchange package +func (e *Exchange) processFundingData(accHistory []DeposWithdrData, cryptoHistory []TransactionData) ([]exchange.FundingHistory, error) { + fundingData := make([]exchange.FundingHistory, len(accHistory)+len(cryptoHistory)) + for i := range accHistory { + fundingData[i] = exchange.FundingHistory{ + ExchangeName: e.Name, + Status: accHistory[i].Status, + TransferID: accHistory[i].ID, + Timestamp: accHistory[i].PayoutAt, + Currency: accHistory[i].Amount.Currency.String(), + Amount: accHistory[i].Amount.Value.Float64(), + Fee: accHistory[i].TotalFee.Amount.Value.Float64(), + } + switch accHistory[i].Type { + case "TRANSFER_TYPE_DEPOSIT": + fundingData[i].TransferType = "deposit" + case "TRANSFER_TYPE_WITHDRAWAL": + fundingData[i].TransferType = "withdrawal" + default: + return nil, fmt.Errorf("%w %v", errUnknownTransferType, accHistory[i].Type) + } + } + for i := range cryptoHistory { + fundingData[i+len(accHistory)] = exchange.FundingHistory{ + ExchangeName: e.Name, + Status: cryptoHistory[i].Status, + TransferID: cryptoHistory[i].ID, + Timestamp: cryptoHistory[i].CreatedAt, + Currency: cryptoHistory[i].Amount.Currency, + Amount: cryptoHistory[i].Amount.Amount.Float64(), + } + if cryptoHistory[i].Type == "receive" { + fundingData[i+len(accHistory)].TransferType = "deposit" + } + if cryptoHistory[i].Type == "send" { + fundingData[i+len(accHistory)].TransferType = "withdrawal" + } + } + return fundingData, nil +} + +// iterativeGetAllOrders is a helper function used in GetActiveOrders and GetOrderHistory to repeatedly call GetAllOrders until all orders have been retrieved +func (e *Exchange) iterativeGetAllOrders(ctx context.Context, productIDs currency.Pairs, orderType, orderSide, productType string, orderStatus []string, limit int32, startDate, endDate time.Time) ([]GetOrderResponse, error) { + hasNext := true + var resp []GetOrderResponse + var cursor int64 + if orderSide == "ANY" { + orderSide = "" + } + if orderType == "ANY" { + orderType = "" + } + if productType == "FUTURES" { + productType = "FUTURE" + } + orderTypeSlice := []string{orderType} + if orderType == "" { + orderTypeSlice = nil + } + for hasNext { + interResp, err := e.ListOrders(ctx, &ListOrdersReq{ + OrderStatus: orderStatus, + OrderTypes: orderTypeSlice, + ProductIDs: productIDs, + ProductType: productType, + OrderSide: orderSide, + Cursor: cursor, + Limit: limit, + StartDate: startDate, + EndDate: endDate, + }) + if err != nil { + return nil, err + } + resp = append(resp, interResp.Orders...) + hasNext = interResp.HasNext + cursor = int64(interResp.Cursor) + } + return resp, nil +} + +// getOrderRespToOrderDetail is a helper function used in GetOrderInfo, GetActiveOrders, and GetOrderHistory to convert data returned by the Coinbase API into a format suitable for the exchange package +func (e *Exchange) getOrderRespToOrderDetail(genOrderDetail *GetOrderResponse, pair currency.Pair, assetItem asset.Item) *order.Detail { + var amount float64 + var quoteAmount float64 + var orderType order.Type + if genOrderDetail.OrderConfiguration.MarketMarketIOC != nil { + quoteAmount = genOrderDetail.OrderConfiguration.MarketMarketIOC.QuoteSize.Float64() + amount = genOrderDetail.OrderConfiguration.MarketMarketIOC.BaseSize.Float64() + orderType = order.Market + } + var price float64 + var postOnly bool + if genOrderDetail.OrderConfiguration.LimitLimitGTC != nil { + amount = genOrderDetail.OrderConfiguration.LimitLimitGTC.BaseSize.Float64() + price = genOrderDetail.OrderConfiguration.LimitLimitGTC.LimitPrice.Float64() + postOnly = genOrderDetail.OrderConfiguration.LimitLimitGTC.PostOnly + orderType = order.Limit + } + if genOrderDetail.OrderConfiguration.LimitLimitGTD != nil { + amount = genOrderDetail.OrderConfiguration.LimitLimitGTD.BaseSize.Float64() + price = genOrderDetail.OrderConfiguration.LimitLimitGTD.LimitPrice.Float64() + postOnly = genOrderDetail.OrderConfiguration.LimitLimitGTD.PostOnly + orderType = order.Limit + } + var triggerPrice float64 + if genOrderDetail.OrderConfiguration.StopLimitStopLimitGTC != nil { + amount = genOrderDetail.OrderConfiguration.StopLimitStopLimitGTC.BaseSize.Float64() + price = genOrderDetail.OrderConfiguration.StopLimitStopLimitGTC.LimitPrice.Float64() + triggerPrice = genOrderDetail.OrderConfiguration.StopLimitStopLimitGTC.StopPrice.Float64() + orderType = order.StopLimit + } + if genOrderDetail.OrderConfiguration.StopLimitStopLimitGTD != nil { + amount = genOrderDetail.OrderConfiguration.StopLimitStopLimitGTD.BaseSize.Float64() + price = genOrderDetail.OrderConfiguration.StopLimitStopLimitGTD.LimitPrice.Float64() + triggerPrice = genOrderDetail.OrderConfiguration.StopLimitStopLimitGTD.StopPrice.Float64() + orderType = order.StopLimit + } + var remainingAmount float64 + if !genOrderDetail.SizeInQuote { + remainingAmount = amount - genOrderDetail.FilledSize.Float64() + } + var orderSide order.Side + switch genOrderDetail.Side { + case order.Buy.String(): + orderSide = order.Buy + case order.Sell.String(): + orderSide = order.Sell + } + var orderStatus order.Status + switch genOrderDetail.Status { + case order.Open.String(): + orderStatus = order.Open + case order.Filled.String(): + orderStatus = order.Filled + case order.Cancelled.String(): + orderStatus = order.Cancelled + case order.Expired.String(): + orderStatus = order.Expired + case "FAILED": + orderStatus = order.Rejected + case "UNKNOWN_ORDER_STATUS": + orderStatus = order.UnknownStatus + } + var closeTime time.Time + if genOrderDetail.Settled { + closeTime = genOrderDetail.LastFillTime + } + var lastUpdateTime time.Time + if len(genOrderDetail.EditHistory) > 0 { + lastUpdateTime = genOrderDetail.EditHistory[len(genOrderDetail.EditHistory)-1].ReplaceAcceptTimestamp + } + var tif order.TimeInForce + if postOnly { + tif = order.PostOnly + } + if genOrderDetail.OrderConfiguration.MarketMarketIOC != nil { + tif |= order.ImmediateOrCancel + } + response := order.Detail{ + TimeInForce: tif, + Price: price, + Amount: amount, + TriggerPrice: triggerPrice, + AverageExecutedPrice: genOrderDetail.AverageFilledPrice.Float64(), + QuoteAmount: quoteAmount, + ExecutedAmount: genOrderDetail.FilledSize.Float64(), + RemainingAmount: remainingAmount, + Cost: genOrderDetail.TotalValueAfterFees.Float64(), + Fee: genOrderDetail.TotalFees.Float64(), + Exchange: e.GetName(), + OrderID: genOrderDetail.OrderID, + ClientOrderID: genOrderDetail.ClientOID, + ClientID: genOrderDetail.UserID, + Type: orderType, + Side: orderSide, + Status: orderStatus, + AssetType: assetItem, + Date: genOrderDetail.CreatedTime, + CloseTime: closeTime, + LastUpdated: lastUpdateTime, + Pair: pair, + } + return &response +} + +// tickerHelper fetches the ticker for a given currency pair, used by UpdateTicker +func (e *Exchange) tickerHelper(ctx context.Context, name currency.Pair, assetType asset.Item) error { + newTick := &ticker.Price{ + Pair: name, + ExchangeName: e.Name, + AssetType: assetType, + } + ticks, err := e.GetTicker(ctx, name, 1, time.Time{}, time.Time{}, false) + if err != nil { + return err + } + var last float64 + if len(ticks.Trades) != 0 { + last = ticks.Trades[0].Price.Float64() + } + newTick.Last = last + newTick.Bid = ticks.BestBid.Float64() + newTick.Ask = ticks.BestAsk.Float64() + return ticker.ProcessTicker(newTick) +} + +// FormatAssetOutbound formats asset items for outbound requests +func FormatAssetOutbound(a asset.Item) string { + if a == asset.Futures { + return "FUTURE" + } + return a.Upper() +} + +// GetAlias returns the aliases for a currency pair +func (a *pairAliases) GetAlias(p currency.Pair) currency.Pairs { + a.m.RLock() + defer a.m.RUnlock() + return slices.Clone(a.associatedAliases[p]) +} + +// GetAliases returns a map of all aliases associated with all pairs +func (a *pairAliases) GetAliases() map[currency.Pair]currency.Pairs { + a.m.RLock() + defer a.m.RUnlock() + return maps.Clone(a.associatedAliases) +} + +// Load adds a batch of aliases to the alias map +func (a *pairAliases) Load(aliases map[currency.Pair]currency.Pairs) { + a.m.Lock() + defer a.m.Unlock() + if a.associatedAliases == nil { + a.associatedAliases = make(map[currency.Pair]currency.Pairs) + } + for k, v := range aliases { + a.associatedAliases[k] = a.associatedAliases[k].Add(v...) + } +} diff --git a/exchanges/coinbase/ratelimit.go b/exchanges/coinbase/ratelimit.go new file mode 100644 index 00000000..29fea8e6 --- /dev/null +++ b/exchanges/coinbase/ratelimit.go @@ -0,0 +1,24 @@ +package coinbase + +import ( + "time" + + "github.com/thrasher-corp/gocryptotrader/exchanges/request" +) + +// Coinbase pro rate limits +const ( + V2Rate request.EndpointLimit = iota + V3Rate + WSAuthRate + WSUnauthRate + PubRate +) + +var rateLimits = request.RateLimitDefinitions{ + V2Rate: request.NewRateLimitWithWeight(time.Hour, 10000, 1), + V3Rate: request.NewRateLimitWithWeight(time.Second, 27, 1), + WSAuthRate: request.NewRateLimitWithWeight(time.Second, 750, 1), + WSUnauthRate: request.NewRateLimitWithWeight(time.Second, 8, 1), + PubRate: request.NewRateLimitWithWeight(time.Second, 10, 1), +} diff --git a/exchanges/coinbasepro/coinbasepro.go b/exchanges/coinbasepro/coinbasepro.go deleted file mode 100644 index e390a1ed..00000000 --- a/exchanges/coinbasepro/coinbasepro.go +++ /dev/null @@ -1,810 +0,0 @@ -package coinbasepro - -import ( - "bytes" - "context" - "encoding/base64" - "errors" - "fmt" - "net/http" - "net/url" - "slices" - "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/order" - "github.com/thrasher-corp/gocryptotrader/exchanges/request" -) - -const ( - coinbaseproAPIURL = "https://api.pro.coinbase.com/" - coinbaseproSandboxAPIURL = "https://api-public.sandbox.pro.coinbase.com/" - tradeBaseURL = "https://www.coinbase.com/advanced-trade/spot/" - coinbaseproAPIVersion = "0" - coinbaseproProducts = "products" - coinbaseproOrderbook = "book" - coinbaseproTicker = "ticker" - coinbaseproTrades = "trades" - coinbaseproHistory = "candles" - coinbaseproStats = "stats" - coinbaseproCurrencies = "currencies" - coinbaseproAccounts = "accounts" - coinbaseproLedger = "ledger" - coinbaseproHolds = "holds" - coinbaseproOrders = "orders" - coinbaseproFills = "fills" - coinbaseproTransfers = "transfers" - coinbaseproReports = "reports" - coinbaseproTime = "time" - coinbaseproMarginTransfer = "profiles/margin-transfer" - coinbaseproPosition = "position" - coinbaseproPositionClose = "position/close" - coinbaseproPaymentMethod = "payment-methods" - coinbaseproPaymentMethodDeposit = "deposits/payment-method" - coinbaseproDepositCoinbase = "deposits/coinbase-account" - coinbaseproWithdrawalPaymentMethod = "withdrawals/payment-method" - coinbaseproWithdrawalCoinbase = "withdrawals/coinbase" - coinbaseproWithdrawalCrypto = "withdrawals/crypto" - coinbaseproCoinbaseAccounts = "coinbase-accounts" - coinbaseproTrailingVolume = "users/self/trailing-volume" -) - -// Exchange implements exchange.IBotExchange and contains additional specific api methods for interacting with CoinbasePro -type Exchange struct { - exchange.Base -} - -// GetProducts returns supported currency pairs on the exchange with specific -// information about the pair -func (e *Exchange) GetProducts(ctx context.Context) ([]Product, error) { - var products []Product - - return products, e.SendHTTPRequest(ctx, exchange.RestSpot, coinbaseproProducts, &products) -} - -// GetOrderbook returns orderbook by currency pair and level -func (e *Exchange) GetOrderbook(ctx context.Context, symbol string, level int) (any, error) { - orderbook := OrderbookResponse{} - - path := fmt.Sprintf("%s/%s/%s", coinbaseproProducts, symbol, coinbaseproOrderbook) - if level > 0 { - levelStr := strconv.Itoa(level) - path = fmt.Sprintf("%s/%s/%s?level=%s", coinbaseproProducts, symbol, coinbaseproOrderbook, levelStr) - } - - if err := e.SendHTTPRequest(ctx, exchange.RestSpot, path, &orderbook); err != nil { - return nil, err - } - - if level == 3 { - ob := OrderbookL3{ - Sequence: orderbook.Sequence, - Bids: make([]OrderL3, len(orderbook.Bids)), - Asks: make([]OrderL3, len(orderbook.Asks)), - } - ob.Sequence = orderbook.Sequence - for x := range orderbook.Asks { - ob.Asks[x].Price = orderbook.Asks[x][0].Float64() - ob.Asks[x].Amount = orderbook.Asks[x][1].Float64() - ob.Asks[x].OrderID = orderbook.Asks[x][2].String() - } - for x := range orderbook.Bids { - ob.Bids[x].Price = orderbook.Bids[x][0].Float64() - ob.Bids[x].Amount = orderbook.Bids[x][1].Float64() - ob.Bids[x].OrderID = orderbook.Bids[x][2].String() - } - return ob, nil - } - ob := OrderbookL1L2{ - Sequence: orderbook.Sequence, - Bids: make([]OrderL1L2, len(orderbook.Bids)), - Asks: make([]OrderL1L2, len(orderbook.Asks)), - } - for x := range orderbook.Asks { - ob.Asks[x].Price = orderbook.Asks[x][0].Float64() - ob.Asks[x].Amount = orderbook.Asks[x][1].Float64() - ob.Asks[x].NumOrders = orderbook.Asks[x][2].Float64() - } - for x := range orderbook.Bids { - ob.Bids[x].Price = orderbook.Bids[x][0].Float64() - ob.Bids[x].Amount = orderbook.Bids[x][1].Float64() - ob.Bids[x].NumOrders = orderbook.Bids[x][2].Float64() - } - return ob, nil -} - -// GetTicker returns ticker by currency pair -// currencyPair - example "BTC-USD" -func (e *Exchange) GetTicker(ctx context.Context, currencyPair string) (Ticker, error) { - tick := Ticker{} - path := fmt.Sprintf( - "%s/%s/%s", coinbaseproProducts, currencyPair, coinbaseproTicker) - return tick, e.SendHTTPRequest(ctx, exchange.RestSpot, path, &tick) -} - -// GetTrades listd the latest trades for a product -// currencyPair - example "BTC-USD" -func (e *Exchange) GetTrades(ctx context.Context, currencyPair string) ([]Trade, error) { - var trades []Trade - path := fmt.Sprintf( - "%s/%s/%s", coinbaseproProducts, currencyPair, coinbaseproTrades) - return trades, e.SendHTTPRequest(ctx, exchange.RestSpot, path, &trades) -} - -// GetHistoricRates returns historic rates for a product. Rates are returned in -// grouped buckets based on requested granularity. -func (e *Exchange) GetHistoricRates(ctx context.Context, currencyPair, start, end string, granularity int64) ([]History, error) { - values := url.Values{} - - if start != "" { - values.Set("start", start) - } else { - values.Set("start", "") - } - - if end != "" { - values.Set("end", end) - } else { - values.Set("end", "") - } - - allowedGranularities := []int64{60, 300, 900, 3600, 21600, 86400} - if !slices.Contains(allowedGranularities, granularity) { - return nil, errors.New("Invalid granularity value: " + strconv.FormatInt(granularity, 10) + ". Allowed values are {60, 300, 900, 3600, 21600, 86400}") - } - if granularity > 0 { - values.Set("granularity", strconv.FormatInt(granularity, 10)) - } - - var resp []History - path := common.EncodeURLValues( - fmt.Sprintf("%s/%s/%s", coinbaseproProducts, currencyPair, coinbaseproHistory), - values) - return resp, e.SendHTTPRequest(ctx, exchange.RestSpot, path, &resp) -} - -// GetStats returns a 24 hr stat for the product. Volume is in base currency -// units. open, high, low are in quote currency units. -func (e *Exchange) GetStats(ctx context.Context, currencyPair string) (Stats, error) { - stats := Stats{} - path := fmt.Sprintf( - "%s/%s/%s", coinbaseproProducts, currencyPair, coinbaseproStats) - - return stats, e.SendHTTPRequest(ctx, exchange.RestSpot, path, &stats) -} - -// GetCurrencies returns a list of supported currency on the exchange -// Warning: Not all currencies may be currently in use for tradinc. -func (e *Exchange) GetCurrencies(ctx context.Context) ([]Currency, error) { - var currencies []Currency - - return currencies, e.SendHTTPRequest(ctx, exchange.RestSpot, coinbaseproCurrencies, ¤cies) -} - -// GetCurrentServerTime returns the API server time -func (e *Exchange) GetCurrentServerTime(ctx context.Context) (ServerTime, error) { - serverTime := ServerTime{} - return serverTime, e.SendHTTPRequest(ctx, exchange.RestSpot, coinbaseproTime, &serverTime) -} - -// GetAccounts returns a list of trading accounts associated with the APIKEYS -func (e *Exchange) GetAccounts(ctx context.Context) ([]AccountResponse, error) { - var resp []AccountResponse - - return resp, - e.SendAuthenticatedHTTPRequest(ctx, exchange.RestSpot, http.MethodGet, coinbaseproAccounts, nil, &resp) -} - -// GetAccount returns information for a single account. Use this endpoint when -// account_id is known -func (e *Exchange) GetAccount(ctx context.Context, accountID string) (AccountResponse, error) { - resp := AccountResponse{} - path := fmt.Sprintf("%s/%s", coinbaseproAccounts, accountID) - - return resp, e.SendAuthenticatedHTTPRequest(ctx, exchange.RestSpot, http.MethodGet, path, nil, &resp) -} - -// GetAccountHistory returns a list of account activity. Account activity either -// increases or decreases your account balance. Items are paginated and sorted -// latest first. -func (e *Exchange) GetAccountHistory(ctx context.Context, accountID string) ([]AccountLedgerResponse, error) { - var resp []AccountLedgerResponse - path := fmt.Sprintf("%s/%s/%s", coinbaseproAccounts, accountID, coinbaseproLedger) - - return resp, e.SendAuthenticatedHTTPRequest(ctx, exchange.RestSpot, http.MethodGet, path, nil, &resp) -} - -// GetHolds returns the holds that are placed on an account for any active -// orders or pending withdraw requests. As an order is filled, the hold amount -// is updated. If an order is canceled, any remaining hold is removed. For a -// withdraw, once it is completed, the hold is removed. -func (e *Exchange) GetHolds(ctx context.Context, accountID string) ([]AccountHolds, error) { - var resp []AccountHolds - path := fmt.Sprintf("%s/%s/%s", coinbaseproAccounts, accountID, coinbaseproHolds) - - return resp, e.SendAuthenticatedHTTPRequest(ctx, exchange.RestSpot, http.MethodGet, path, nil, &resp) -} - -// PlaceLimitOrder places a new limit order. Orders can only be placed if the -// account has sufficient funds. Once an order is placed, account funds -// will be put on hold for the duration of the order. How much and which funds -// are put on hold depends on the order type and parameters specified. -// -// GENERAL PARAMS -// clientRef - [optional] Order ID selected by you to identify your order -// side - buy or sell -// productID - A valid product id -// stp - [optional] Self-trade prevention flag -// -// LIMIT ORDER PARAMS -// price - Price per bitcoin -// amount - Amount of BTC to buy or sell -// timeInforce - [optional] GTC, GTT, IOC, or FOK (default is GTC) -// cancelAfter - [optional] min, hour, day * Requires time_in_force to be GTT -// postOnly - [optional] Post only flag Invalid when time_in_force is IOC or FOK -func (e *Exchange) PlaceLimitOrder(ctx context.Context, clientRef string, price, amount float64, side, timeInforce, cancelAfter, productID, stp string, postOnly bool) (string, error) { - req := make(map[string]any) - req["type"] = order.Limit.Lower() - req["price"] = strconv.FormatFloat(price, 'f', -1, 64) - req["size"] = strconv.FormatFloat(amount, 'f', -1, 64) - req["side"] = side - req["product_id"] = productID - if cancelAfter != "" { - req["cancel_after"] = cancelAfter - } - if timeInforce != "" { - req["time_in_force"] = timeInforce - } - if clientRef != "" { - req["client_oid"] = clientRef - } - if stp != "" { - req["stp"] = stp - } - if postOnly { - req["post_only"] = postOnly - } - resp := GeneralizedOrderResponse{} - return resp.ID, e.SendAuthenticatedHTTPRequest(ctx, exchange.RestSpot, http.MethodPost, coinbaseproOrders, req, &resp) -} - -// PlaceMarketOrder places a new market order. -// Orders can only be placed if the account has sufficient funds. Once an order -// is placed, account funds will be put on hold for the duration of the order. -// How much and which funds are put on hold depends on the order type and -// parameters specified. -// -// GENERAL PARAMS -// clientRef - [optional] Order ID selected by you to identify your order -// side - buy or sell -// productID - A valid product id -// stp - [optional] Self-trade prevention flag -// -// MARKET ORDER PARAMS -// size - [optional]* Desired amount in BTC -// funds [optional]* Desired amount of quote currency to use -// * One of size or funds is required. -func (e *Exchange) PlaceMarketOrder(ctx context.Context, clientRef string, size, funds float64, side, productID, stp string) (string, error) { - resp := GeneralizedOrderResponse{} - req := make(map[string]any) - req["side"] = side - req["product_id"] = productID - req["type"] = order.Market.Lower() - - if size != 0 { - req["size"] = strconv.FormatFloat(size, 'f', -1, 64) - } - if funds != 0 { - req["funds"] = strconv.FormatFloat(funds, 'f', -1, 64) - } - if clientRef != "" { - req["client_oid"] = clientRef - } - if stp != "" { - req["stp"] = stp - } - - err := e.SendAuthenticatedHTTPRequest(ctx, exchange.RestSpot, http.MethodPost, coinbaseproOrders, req, &resp) - if err != nil { - return "", err - } - - return resp.ID, nil -} - -// PlaceMarginOrder places a new market order. -// Orders can only be placed if the account has sufficient funds. Once an order -// is placed, account funds will be put on hold for the duration of the order. -// How much and which funds are put on hold depends on the order type and -// parameters specified. -// -// GENERAL PARAMS -// clientRef - [optional] Order ID selected by you to identify your order -// side - buy or sell -// productID - A valid product id -// stp - [optional] Self-trade prevention flag -// -// MARGIN ORDER PARAMS -// size - [optional]* Desired amount in BTC -// funds - [optional]* Desired amount of quote currency to use -func (e *Exchange) PlaceMarginOrder(ctx context.Context, clientRef string, size, funds float64, side, productID, stp string) (string, error) { - resp := GeneralizedOrderResponse{} - req := make(map[string]any) - req["side"] = side - req["product_id"] = productID - req["type"] = "margin" - - if size != 0 { - req["size"] = strconv.FormatFloat(size, 'f', -1, 64) - } - if funds != 0 { - req["funds"] = strconv.FormatFloat(funds, 'f', -1, 64) - } - if clientRef != "" { - req["client_oid"] = clientRef - } - if stp != "" { - req["stp"] = stp - } - - err := e.SendAuthenticatedHTTPRequest(ctx, exchange.RestSpot, http.MethodPost, coinbaseproOrders, req, &resp) - if err != nil { - return "", err - } - - return resp.ID, nil -} - -// CancelExistingOrder cancels order by orderID -func (e *Exchange) CancelExistingOrder(ctx context.Context, orderID string) error { - path := fmt.Sprintf("%s/%s", coinbaseproOrders, orderID) - - return e.SendAuthenticatedHTTPRequest(ctx, exchange.RestSpot, http.MethodDelete, path, nil, nil) -} - -// CancelAllExistingOrders cancels all open orders on the exchange and returns -// and array of order IDs -// currencyPair - [optional] all orders for a currencyPair string will be -// canceled -func (e *Exchange) CancelAllExistingOrders(ctx context.Context, currencyPair string) ([]string, error) { - var resp []string - req := make(map[string]any) - - if currencyPair != "" { - req["product_id"] = currencyPair - } - return resp, e.SendAuthenticatedHTTPRequest(ctx, exchange.RestSpot, http.MethodDelete, coinbaseproOrders, req, &resp) -} - -// GetOrders lists current open orders. Only open or un-settled orders are -// returned. As soon as an order is no longer open and settled, it will no -// longer appear in the default request. -// status - can be a range of "open", "pending", "done" or "active" -// currencyPair - [optional] for example "BTC-USD" -func (e *Exchange) GetOrders(ctx context.Context, status []string, currencyPair string) ([]GeneralizedOrderResponse, error) { - var resp []GeneralizedOrderResponse - params := url.Values{} - - for _, individualStatus := range status { - params.Add("status", individualStatus) - } - if currencyPair != "" { - params.Set("product_id", currencyPair) - } - - path := common.EncodeURLValues(coinbaseproOrders, params) - return resp, - e.SendAuthenticatedHTTPRequest(ctx, exchange.RestSpot, http.MethodGet, path, nil, &resp) -} - -// GetOrder returns a single order by order id. -func (e *Exchange) GetOrder(ctx context.Context, orderID string) (GeneralizedOrderResponse, error) { - resp := GeneralizedOrderResponse{} - path := fmt.Sprintf("%s/%s", coinbaseproOrders, orderID) - - return resp, e.SendAuthenticatedHTTPRequest(ctx, exchange.RestSpot, http.MethodGet, path, nil, &resp) -} - -// GetFills returns a list of recent fills -func (e *Exchange) GetFills(ctx context.Context, orderID, currencyPair string) ([]FillResponse, error) { - var resp []FillResponse - params := url.Values{} - - if orderID != "" { - params.Set("order_id", orderID) - } - if currencyPair != "" { - params.Set("product_id", currencyPair) - } - if params.Get("order_id") == "" && params.Get("product_id") == "" { - return resp, errors.New("no parameters set") - } - - path := common.EncodeURLValues(coinbaseproFills, params) - return resp, - e.SendAuthenticatedHTTPRequest(ctx, exchange.RestSpot, http.MethodGet, path, nil, &resp) -} - -// MarginTransfer sends funds between a standard/default profile and a margin -// profile. -// A deposit will transfer funds from the default profile into the margin -// profile. A withdraw will transfer funds from the margin profile to the -// default profile. Withdraws will fail if they would set your margin ratio -// below the initial margin ratio requirement. -// -// amount - the amount to transfer between the default and margin profile -// transferType - either "deposit" or "withdraw" -// profileID - The id of the margin profile to deposit or withdraw from -// currency - currency to transfer, currently on "BTC" or "USD" -func (e *Exchange) MarginTransfer(ctx context.Context, amount float64, transferType, profileID, ccy string) (MarginTransfer, error) { - resp := MarginTransfer{} - req := make(map[string]any) - req["type"] = transferType - req["amount"] = strconv.FormatFloat(amount, 'f', -1, 64) - req["currency"] = ccy - req["margin_profile_id"] = profileID - - return resp, - e.SendAuthenticatedHTTPRequest(ctx, exchange.RestSpot, http.MethodPost, coinbaseproMarginTransfer, req, &resp) -} - -// GetPosition returns an overview of account profile. -func (e *Exchange) GetPosition(ctx context.Context) (AccountOverview, error) { - resp := AccountOverview{} - - return resp, - e.SendAuthenticatedHTTPRequest(ctx, exchange.RestSpot, http.MethodGet, coinbaseproPosition, nil, &resp) -} - -// ClosePosition closes a position and allowing you to repay position as well -// repayOnly - allows the position to be repaid -func (e *Exchange) ClosePosition(ctx context.Context, repayOnly bool) (AccountOverview, error) { - resp := AccountOverview{} - req := make(map[string]any) - req["repay_only"] = repayOnly - - return resp, - e.SendAuthenticatedHTTPRequest(ctx, exchange.RestSpot, http.MethodPost, coinbaseproPositionClose, req, &resp) -} - -// GetPayMethods returns a full list of payment methods -func (e *Exchange) GetPayMethods(ctx context.Context) ([]PaymentMethod, error) { - var resp []PaymentMethod - - return resp, - e.SendAuthenticatedHTTPRequest(ctx, exchange.RestSpot, http.MethodGet, coinbaseproPaymentMethod, nil, &resp) -} - -// DepositViaPaymentMethod deposits funds from a payment method. See the Payment -// Methods section for retrieving your payment methods. -// -// amount - The amount to deposit -// currency - The type of currency -// paymentID - ID of the payment method -func (e *Exchange) DepositViaPaymentMethod(ctx context.Context, amount float64, ccy, paymentID string) (DepositWithdrawalInfo, error) { - resp := DepositWithdrawalInfo{} - req := make(map[string]any) - req["amount"] = amount - req["currency"] = ccy - req["payment_method_id"] = paymentID - - return resp, - e.SendAuthenticatedHTTPRequest(ctx, exchange.RestSpot, http.MethodPost, coinbaseproPaymentMethodDeposit, req, &resp) -} - -// DepositViaCoinbase deposits funds from a coinbase account. Move funds between -// a Coinbase account and coinbasepro trading account within daily limits. Moving -// funds between Coinbase and coinbasepro is instant and free. See the Coinbase -// Accounts section for retrieving your Coinbase accounts. -// -// amount - The amount to deposit -// currency - The type of currency -// accountID - ID of the coinbase account -func (e *Exchange) DepositViaCoinbase(ctx context.Context, amount float64, ccy, accountID string) (DepositWithdrawalInfo, error) { - resp := DepositWithdrawalInfo{} - req := make(map[string]any) - req["amount"] = amount - req["currency"] = ccy - req["coinbase_account_id"] = accountID - - return resp, - e.SendAuthenticatedHTTPRequest(ctx, exchange.RestSpot, http.MethodPost, coinbaseproDepositCoinbase, req, &resp) -} - -// WithdrawViaPaymentMethod withdraws funds to a payment method -// -// amount - The amount to withdraw -// currency - The type of currency -// paymentID - ID of the payment method -func (e *Exchange) WithdrawViaPaymentMethod(ctx context.Context, amount float64, ccy, paymentID string) (DepositWithdrawalInfo, error) { - resp := DepositWithdrawalInfo{} - req := make(map[string]any) - req["amount"] = amount - req["currency"] = ccy - req["payment_method_id"] = paymentID - - return resp, - e.SendAuthenticatedHTTPRequest(ctx, exchange.RestSpot, http.MethodPost, coinbaseproWithdrawalPaymentMethod, req, &resp) -} - -// /////////////////////// NO ROUTE FOUND ERROR //////////////////////////////// -// WithdrawViaCoinbase withdraws funds to a coinbase account. -// -// amount - The amount to withdraw -// currency - The type of currency -// accountID - ID of the coinbase account -// func (c *CoinbasePro) WithdrawViaCoinbase(amount float64, currency, accountID string) (DepositWithdrawalInfo, error) { -// resp := DepositWithdrawalInfo{} -// req := make(map[string]any) -// req["amount"] = amount -// req["currency"] = currency -// req["coinbase_account_id"] = accountID -// -// return resp, -// c.SendAuthenticatedHTTPRequest(ctx,http.MethodPost, coinbaseproWithdrawalCoinbase, req, &resp) -// } - -// WithdrawCrypto withdraws funds to a crypto address -// -// amount - The amount to withdraw -// currency - The type of currency -// cryptoAddress - A crypto address of the recipient -func (e *Exchange) WithdrawCrypto(ctx context.Context, amount float64, ccy, cryptoAddress string) (DepositWithdrawalInfo, error) { - resp := DepositWithdrawalInfo{} - req := make(map[string]any) - req["amount"] = amount - req["currency"] = ccy - req["crypto_address"] = cryptoAddress - - return resp, - e.SendAuthenticatedHTTPRequest(ctx, exchange.RestSpot, http.MethodPost, coinbaseproWithdrawalCrypto, req, &resp) -} - -// GetCoinbaseAccounts returns a list of coinbase accounts -func (e *Exchange) GetCoinbaseAccounts(ctx context.Context) ([]CoinbaseAccounts, error) { - var resp []CoinbaseAccounts - - return resp, - e.SendAuthenticatedHTTPRequest(ctx, exchange.RestSpot, http.MethodGet, coinbaseproCoinbaseAccounts, nil, &resp) -} - -// GetReport returns batches of historic information about your account in -// various human and machine readable forms. -// -// reportType - "fills" or "account" -// startDate - Starting date for the report (inclusive) -// endDate - Ending date for the report (inclusive) -// currencyPair - ID of the product to generate a fills report for. -// E.c. BTC-USD. *Required* if type is fills -// accountID - ID of the account to generate an account report for. *Required* -// if type is account -// format - pdf or csv (default is pdf) -// email - [optional] Email address to send the report to -func (e *Exchange) GetReport(ctx context.Context, reportType, startDate, endDate, currencyPair, accountID, format, email string) (Report, error) { - resp := Report{} - req := make(map[string]any) - req["type"] = reportType - req["start_date"] = startDate - req["end_date"] = endDate - req["format"] = "pdf" - - if currencyPair != "" { - req["product_id"] = currencyPair - } - if accountID != "" { - req["account_id"] = accountID - } - if format == "csv" { - req["format"] = format - } - if email != "" { - req["email"] = email - } - - return resp, - e.SendAuthenticatedHTTPRequest(ctx, exchange.RestSpot, http.MethodPost, coinbaseproReports, req, &resp) -} - -// GetReportStatus once a report request has been accepted for processing, the -// status is available by polling the report resource endpoint. -func (e *Exchange) GetReportStatus(ctx context.Context, reportID string) (Report, error) { - resp := Report{} - path := fmt.Sprintf("%s/%s", coinbaseproReports, reportID) - - return resp, e.SendAuthenticatedHTTPRequest(ctx, exchange.RestSpot, http.MethodGet, path, nil, &resp) -} - -// GetTrailingVolume this request will return your 30-day trailing volume for -// all products. -func (e *Exchange) GetTrailingVolume(ctx context.Context) ([]Volume, error) { - var resp []Volume - - return resp, - e.SendAuthenticatedHTTPRequest(ctx, exchange.RestSpot, http.MethodGet, coinbaseproTrailingVolume, nil, &resp) -} - -// GetTransfers returns a history of withdrawal and or deposit transactions -func (e *Exchange) GetTransfers(ctx context.Context, profileID, transferType string, limit int64, start, end time.Time) ([]TransferHistory, error) { - if !start.IsZero() && !end.IsZero() { - err := common.StartEndTimeCheck(start, end) - if err != nil { - return nil, err - } - } - req := make(map[string]any) - if profileID != "" { - req["profile_id"] = profileID - } - if !start.IsZero() { - req["before"] = start.Format(time.RFC3339) - } - if !end.IsZero() { - req["after"] = end.Format(time.RFC3339) - } - if limit > 0 { - req["limit"] = limit - } - if transferType != "" { - req["type"] = transferType - } - var resp []TransferHistory - return resp, e.SendAuthenticatedHTTPRequest(ctx, exchange.RestSpot, http.MethodGet, coinbaseproTransfers, req, &resp) -} - -// SendHTTPRequest sends an unauthenticated HTTP request -func (e *Exchange) SendHTTPRequest(ctx context.Context, ep exchange.URL, path string, result any) error { - endpoint, err := e.API.Endpoints.GetURL(ep) - if err != nil { - return err - } - - item := &request.Item{ - Method: http.MethodGet, - Path: endpoint + path, - Result: result, - Verbose: e.Verbose, - HTTPDebugging: e.HTTPDebugging, - HTTPRecording: e.HTTPRecording, - HTTPMockDataSliceLimit: e.HTTPMockDataSliceLimit, - } - - return e.SendPayload(ctx, request.UnAuth, func() (*request.Item, error) { - return item, nil - }, request.UnauthenticatedRequest) -} - -// SendAuthenticatedHTTPRequest sends an authenticated HTTP request -func (e *Exchange) SendAuthenticatedHTTPRequest(ctx context.Context, ep exchange.URL, method, path string, params map[string]any, result any) (err error) { - creds, err := e.GetCredentials(ctx) - if err != nil { - return err - } - endpoint, err := e.API.Endpoints.GetURL(ep) - if err != nil { - return err - } - - newRequest := func() (*request.Item, error) { - payload := []byte("") - if params != nil { - payload, err = json.Marshal(params) - if err != nil { - return nil, err - } - } - - n := strconv.FormatInt(time.Now().Unix(), 10) - message := n + method + "/" + path + string(payload) - - hmac, err := crypto.GetHMAC(crypto.HashSHA256, []byte(message), []byte(creds.Secret)) - if err != nil { - return nil, err - } - - headers := make(map[string]string) - headers["CB-ACCESS-SIGN"] = base64.StdEncoding.EncodeToString(hmac) - headers["CB-ACCESS-TIMESTAMP"] = n - headers["CB-ACCESS-KEY"] = creds.Key - headers["CB-ACCESS-PASSPHRASE"] = creds.ClientID - headers["Content-Type"] = "application/json" - - return &request.Item{ - Method: method, - Path: endpoint + path, - Headers: headers, - Body: bytes.NewBuffer(payload), - Result: result, - Verbose: e.Verbose, - HTTPDebugging: e.HTTPDebugging, - HTTPRecording: e.HTTPRecording, - HTTPMockDataSliceLimit: e.HTTPMockDataSliceLimit, - }, nil - } - return e.SendPayload(ctx, request.Unset, 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: - trailingVolume, err := e.GetTrailingVolume(ctx) - if err != nil { - return 0, err - } - fee = e.calculateTradingFee(trailingVolume, - feeBuilder.Pair.Base, - feeBuilder.Pair.Quote, - feeBuilder.Pair.Delimiter, - feeBuilder.PurchasePrice, - feeBuilder.Amount, - feeBuilder.IsMaker) - case exchange.InternationalBankWithdrawalFee: - fee = getInternationalBankWithdrawalFee(feeBuilder.FiatCurrency) - case exchange.InternationalBankDepositFee: - fee = getInternationalBankDepositFee(feeBuilder.FiatCurrency) - case exchange.OfflineTradeFee: - fee = getOfflineTradeFee(feeBuilder.PurchasePrice, feeBuilder.Amount) - } - - if fee < 0 { - fee = 0 - } - - return fee, nil -} - -// getOfflineTradeFee calculates the worst case-scenario trading fee -func getOfflineTradeFee(price, amount float64) float64 { - return 0.0025 * price * amount -} - -func (e *Exchange) calculateTradingFee(trailingVolume []Volume, base, quote currency.Code, delimiter string, purchasePrice, amount float64, isMaker bool) float64 { - var fee float64 - for _, i := range trailingVolume { - if strings.EqualFold(i.ProductID, base.String()+delimiter+quote.String()) { - switch { - case isMaker: - fee = 0 - case i.Volume <= 10000000: - fee = 0.003 - case i.Volume > 10000000 && i.Volume <= 100000000: - fee = 0.002 - case i.Volume > 100000000: - fee = 0.001 - } - break - } - } - return fee * amount * purchasePrice -} - -func getInternationalBankWithdrawalFee(c currency.Code) float64 { - var fee float64 - - if c.Equal(currency.USD) { - fee = 25 - } else if c.Equal(currency.EUR) { - fee = 0.15 - } - - return fee -} - -func getInternationalBankDepositFee(c currency.Code) float64 { - var fee float64 - - if c.Equal(currency.USD) { - fee = 10 - } else if c.Equal(currency.EUR) { - fee = 0.15 - } - - return fee -} diff --git a/exchanges/coinbasepro/coinbasepro_test.go b/exchanges/coinbasepro/coinbasepro_test.go deleted file mode 100644 index c9c48420..00000000 --- a/exchanges/coinbasepro/coinbasepro_test.go +++ /dev/null @@ -1,1041 +0,0 @@ -package coinbasepro - -import ( - "net/http" - "os" - "testing" - "time" - - gws "github.com/gorilla/websocket" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/thrasher-corp/gocryptotrader/common" - "github.com/thrasher-corp/gocryptotrader/core" - "github.com/thrasher-corp/gocryptotrader/currency" - "github.com/thrasher-corp/gocryptotrader/encoding/json" - "github.com/thrasher-corp/gocryptotrader/exchange/websocket" - exchange "github.com/thrasher-corp/gocryptotrader/exchanges" - "github.com/thrasher-corp/gocryptotrader/exchanges/asset" - "github.com/thrasher-corp/gocryptotrader/exchanges/kline" - "github.com/thrasher-corp/gocryptotrader/exchanges/order" - "github.com/thrasher-corp/gocryptotrader/exchanges/sharedtestvalues" - "github.com/thrasher-corp/gocryptotrader/exchanges/subscription" - testexch "github.com/thrasher-corp/gocryptotrader/internal/testing/exchange" - "github.com/thrasher-corp/gocryptotrader/portfolio/banking" - "github.com/thrasher-corp/gocryptotrader/portfolio/withdraw" - "github.com/thrasher-corp/gocryptotrader/types" -) - -var ( - e *Exchange - testPair = currency.NewPairWithDelimiter(currency.BTC.String(), currency.USD.String(), "-") -) - -// Please supply your APIKeys here for better testing -const ( - apiKey = "" - apiSecret = "" - clientID = "" // passphrase you made at API CREATION - canManipulateRealOrders = false -) - -func TestMain(_ *testing.M) { - os.Exit(0) // Disable full test suite until PR #1381 is merged as more API endpoints have been deprecated over time -} - -func TestGetProducts(t *testing.T) { - _, err := e.GetProducts(t.Context()) - if err != nil { - t.Errorf("Coinbase, GetProducts() Error: %s", err) - } -} - -func TestGetOrderbook(t *testing.T) { - _, err := e.GetOrderbook(t.Context(), testPair.String(), 2) - if err != nil { - t.Error(err) - } - _, err = e.GetOrderbook(t.Context(), testPair.String(), 3) - if err != nil { - t.Error(err) - } -} - -func TestGetTicker(t *testing.T) { - _, err := e.GetTicker(t.Context(), testPair.String()) - if err != nil { - t.Error("GetTicker() error", err) - } -} - -func TestGetTrades(t *testing.T) { - _, err := e.GetTrades(t.Context(), testPair.String()) - if err != nil { - t.Error("GetTrades() error", err) - } -} - -func TestHistoryUnmarshalJSON(t *testing.T) { - t.Parallel() - data := []byte(`[[1746649200,96269.22,96307.18,96275.58,96307.18,1.85952049],[1746649140,96256.39,96297.31,96296,96273.29,3.41045323],[1746649080,96256.01,96365.73,96365.73,96299.99,3.56073877]]`) - var resp []History - err := json.Unmarshal(data, &resp) - require.NoError(t, err) - require.Len(t, resp, 3) - assert.Equal(t, History{ - Time: types.Time(time.Unix(1746649200, 0)), - Low: 96269.22, - High: 96307.18, - Open: 96275.58, - Close: 96307.18, - Volume: 1.85952049, - }, resp[0]) -} - -func TestGetHistoricRates(t *testing.T) { - t.Parallel() - result, err := e.GetHistoricRates(t.Context(), "BTC-USD", "", "", 0) - require.NoError(t, err) - assert.NotNil(t, result) -} - -func TestGetHistoricRatesGranularityCheck(t *testing.T) { - end := time.Now() - start := end.Add(-time.Hour * 2) - _, err := e.GetHistoricCandles(t.Context(), - testPair, asset.Spot, kline.OneHour, start, end) - if err != nil { - t.Fatal(err) - } -} - -func TestGetHistoricCandlesExtended(t *testing.T) { - t.Parallel() - _, err := e.GetHistoricCandlesExtended(t.Context(), testPair, asset.Spot, kline.OneDay, time.Unix(1546300800, 0), time.Unix(1577836799, 0)) - assert.NoError(t, err, "GetHistoricCandlesExtended should not error") -} - -func TestGetStats(t *testing.T) { - _, err := e.GetStats(t.Context(), testPair.String()) - if err != nil { - t.Error("GetStats() error", err) - } -} - -func TestGetCurrencies(t *testing.T) { - _, err := e.GetCurrencies(t.Context()) - if err != nil { - t.Error("GetCurrencies() error", err) - } -} - -func TestGetCurrentServerTime(t *testing.T) { - _, err := e.GetCurrentServerTime(t.Context()) - if err != nil { - t.Error("GetServerTime() error", err) - } -} - -func TestWrapperGetServerTime(t *testing.T) { - t.Parallel() - st, err := e.GetServerTime(t.Context(), asset.Spot) - require.NoError(t, err) - - if st.IsZero() { - t.Fatal("expected a time") - } -} - -func TestAuthRequests(t *testing.T) { - t.Parallel() - sharedtestvalues.SkipTestIfCredentialsUnset(t, e) - - _, err := e.GetAccounts(t.Context()) - if err != nil { - t.Error("GetAccounts() error", err) - } - accountResponse, err := e.GetAccount(t.Context(), - "13371337-1337-1337-1337-133713371337") - if accountResponse.ID != "" { - t.Error("Expecting no data returned") - } - if err == nil { - t.Error("Expecting error") - } - accountHistoryResponse, err := e.GetAccountHistory(t.Context(), - "13371337-1337-1337-1337-133713371337") - if len(accountHistoryResponse) > 0 { - t.Error("Expecting no data returned") - } - if err == nil { - t.Error("Expecting error") - } - getHoldsResponse, err := e.GetHolds(t.Context(), - "13371337-1337-1337-1337-133713371337") - if len(getHoldsResponse) > 0 { - t.Error("Expecting no data returned") - } - if err == nil { - t.Error("Expecting error") - } - orderResponse, err := e.PlaceLimitOrder(t.Context(), - "", 0.001, 0.001, - order.Buy.Lower(), "", "", testPair.String(), "", false) - if orderResponse != "" { - t.Error("Expecting no data returned") - } - if err == nil { - t.Error("Expecting error") - } - marketOrderResponse, err := e.PlaceMarketOrder(t.Context(), - "", 1, 0, - order.Buy.Lower(), testPair.String(), "") - if marketOrderResponse != "" { - t.Error("Expecting no data returned") - } - if err == nil { - t.Error("Expecting error") - } - fillsResponse, err := e.GetFills(t.Context(), - "1337", testPair.String()) - if len(fillsResponse) > 0 { - t.Error("Expecting no data returned") - } - if err == nil { - t.Error("Expecting error") - } - _, err = e.GetFills(t.Context(), "", "") - if err == nil { - t.Error("Expecting error") - } - marginTransferResponse, err := e.MarginTransfer(t.Context(), - 1, "withdraw", "13371337-1337-1337-1337-133713371337", "BTC") - if marginTransferResponse.ID != "" { - t.Error("Expecting no data returned") - } - if err == nil { - t.Error("Expecting error") - } - _, err = e.GetPosition(t.Context()) - if err == nil { - t.Error("Expecting error") - } - _, err = e.ClosePosition(t.Context(), false) - if err == nil { - t.Error("Expecting error") - } - _, err = e.GetPayMethods(t.Context()) - if err != nil { - t.Error("GetPayMethods() error", err) - } - _, err = e.GetCoinbaseAccounts(t.Context()) - if err != nil { - t.Error("GetCoinbaseAccounts() error", err) - } -} - -func setFeeBuilder() *exchange.FeeBuilder { - return &exchange.FeeBuilder{ - Amount: 1, - FeeType: exchange.CryptocurrencyTradeFee, - Pair: testPair, - PurchasePrice: 1, - } -} - -func TestGetFeeByTypeOfflineTradeFee(t *testing.T) { - feeBuilder := setFeeBuilder() - _, err := e.GetFeeByType(t.Context(), feeBuilder) - if err != nil { - t.Fatal(err) - } - if !sharedtestvalues.AreAPICredentialsSet(e) { - if feeBuilder.FeeType != exchange.OfflineTradeFee { - t.Errorf("Expected %v, received %v", exchange.OfflineTradeFee, feeBuilder.FeeType) - } - } else { - if feeBuilder.FeeType != exchange.CryptocurrencyTradeFee { - t.Errorf("Expected %v, received %v", exchange.CryptocurrencyTradeFee, feeBuilder.FeeType) - } - } -} - -func TestGetFee(t *testing.T) { - feeBuilder := setFeeBuilder() - - if sharedtestvalues.AreAPICredentialsSet(e) { - // CryptocurrencyTradeFee Basic - if _, err := e.GetFee(t.Context(), feeBuilder); err != nil { - t.Error(err) - } - - // CryptocurrencyTradeFee High quantity - feeBuilder = setFeeBuilder() - feeBuilder.Amount = 1000 - feeBuilder.PurchasePrice = 1000 - if _, err := e.GetFee(t.Context(), feeBuilder); err != nil { - t.Error(err) - } - - // CryptocurrencyTradeFee IsMaker - feeBuilder = setFeeBuilder() - feeBuilder.IsMaker = true - if _, err := e.GetFee(t.Context(), feeBuilder); err != nil { - t.Error(err) - } - - // CryptocurrencyTradeFee Negative purchase price - feeBuilder = setFeeBuilder() - feeBuilder.PurchasePrice = -1000 - if _, err := e.GetFee(t.Context(), feeBuilder); err != nil { - t.Error(err) - } - } - - // CryptocurrencyWithdrawalFee Basic - feeBuilder = setFeeBuilder() - feeBuilder.FeeType = exchange.CryptocurrencyWithdrawalFee - if _, err := e.GetFee(t.Context(), feeBuilder); err != nil { - t.Error(err) - } - - // CryptocurrencyDepositFee Basic - feeBuilder = setFeeBuilder() - feeBuilder.FeeType = exchange.CryptocurrencyDepositFee - if _, err := e.GetFee(t.Context(), feeBuilder); err != nil { - t.Error(err) - } - - // InternationalBankDepositFee Basic - feeBuilder = setFeeBuilder() - feeBuilder.FeeType = exchange.InternationalBankDepositFee - feeBuilder.FiatCurrency = currency.EUR - if _, err := e.GetFee(t.Context(), feeBuilder); err != nil { - t.Error(err) - } - - // InternationalBankWithdrawalFee Basic - feeBuilder = setFeeBuilder() - feeBuilder.FeeType = exchange.InternationalBankWithdrawalFee - feeBuilder.FiatCurrency = currency.USD - if _, err := e.GetFee(t.Context(), feeBuilder); err != nil { - t.Error(err) - } -} - -func TestCalculateTradingFee(t *testing.T) { - t.Parallel() - // uppercase - volume := []Volume{ - { - ProductID: "BTC_USD", - Volume: 100, - }, - } - - if resp := e.calculateTradingFee(volume, currency.BTC, currency.USD, "_", 1, 1, false); resp != float64(0.003) { - t.Errorf("GetFee() error. Expected: %f, Received: %f", float64(0.003), resp) - } - - // lowercase - volume = []Volume{ - { - ProductID: "btc_usd", - Volume: 100, - }, - } - - if resp := e.calculateTradingFee(volume, currency.BTC, currency.USD, "_", 1, 1, false); resp != float64(0.003) { - t.Errorf("GetFee() error. Expected: %f, Received: %f", float64(0.003), resp) - } - - // mixedCase - volume = []Volume{ - { - ProductID: "btc_USD", - Volume: 100, - }, - } - - if resp := e.calculateTradingFee(volume, currency.BTC, currency.USD, "_", 1, 1, false); resp != float64(0.003) { - t.Errorf("GetFee() error. Expected: %f, Received: %f", float64(0.003), resp) - } - - // medium volume - volume = []Volume{ - { - ProductID: "btc_USD", - Volume: 10000001, - }, - } - - if resp := e.calculateTradingFee(volume, currency.BTC, currency.USD, "_", 1, 1, false); resp != float64(0.002) { - t.Errorf("GetFee() error. Expected: %f, Received: %f", float64(0.002), resp) - } - - // high volume - volume = []Volume{ - { - ProductID: "btc_USD", - Volume: 100000010000, - }, - } - - if resp := e.calculateTradingFee(volume, currency.BTC, currency.USD, "_", 1, 1, false); resp != float64(0.001) { - t.Errorf("GetFee() error. Expected: %f, Received: %f", float64(0.001), resp) - } - - // no match - volume = []Volume{ - { - ProductID: "btc_beeteesee", - Volume: 100000010000, - }, - } - - if resp := e.calculateTradingFee(volume, currency.BTC, currency.USD, "_", 1, 1, false); resp != float64(0) { - t.Errorf("GetFee() error. Expected: %f, Received: %f", float64(0), resp) - } - - // taker - volume = []Volume{ - { - ProductID: "btc_USD", - Volume: 100000010000, - }, - } - - if resp := e.calculateTradingFee(volume, currency.BTC, currency.USD, "_", 1, 1, true); resp != float64(0) { - t.Errorf("GetFee() error. Expected: %f, Received: %f", float64(0), resp) - } -} - -func TestFormatWithdrawPermissions(t *testing.T) { - expectedResult := exchange.AutoWithdrawCryptoWithAPIPermissionText + " & " + exchange.AutoWithdrawFiatWithAPIPermissionText - withdrawPermissions := e.FormatWithdrawPermissions() - if withdrawPermissions != expectedResult { - t.Errorf("Expected: %s, Received: %s", expectedResult, withdrawPermissions) - } -} - -func TestGetActiveOrders(t *testing.T) { - getOrdersRequest := order.MultiOrderRequest{ - Type: order.AnyType, - AssetType: asset.Spot, - Pairs: []currency.Pair{testPair}, - Side: order.AnySide, - } - - _, err := e.GetActiveOrders(t.Context(), &getOrdersRequest) - if sharedtestvalues.AreAPICredentialsSet(e) && err != nil { - t.Errorf("Could not get open orders: %s", err) - } else if !sharedtestvalues.AreAPICredentialsSet(e) && err == nil { - t.Error("Expecting an error when no keys are set") - } -} - -func TestGetOrderHistory(t *testing.T) { - getOrdersRequest := order.MultiOrderRequest{ - Type: order.AnyType, - AssetType: asset.Spot, - Pairs: []currency.Pair{testPair}, - Side: order.AnySide, - } - - _, err := e.GetOrderHistory(t.Context(), &getOrdersRequest) - if sharedtestvalues.AreAPICredentialsSet(e) && err != nil { - t.Errorf("Could not get order history: %s", err) - } else if !sharedtestvalues.AreAPICredentialsSet(e) && err == nil { - t.Error("Expecting an error when no keys are set") - } - - getOrdersRequest.Pairs = []currency.Pair{} - _, err = e.GetOrderHistory(t.Context(), &getOrdersRequest) - if sharedtestvalues.AreAPICredentialsSet(e) && err != nil { - t.Errorf("Could not get order history: %s", err) - } else if !sharedtestvalues.AreAPICredentialsSet(e) && err == nil { - t.Error("Expecting an error when no keys are set") - } - - getOrdersRequest.Pairs = nil - _, err = e.GetOrderHistory(t.Context(), &getOrdersRequest) - if sharedtestvalues.AreAPICredentialsSet(e) && err != nil { - t.Errorf("Could not get order history: %s", err) - } else if !sharedtestvalues.AreAPICredentialsSet(e) && err == nil { - t.Error("Expecting an error when no keys are set") - } -} - -// Any tests below this line have the ability to impact your orders on the exchange. Enable canManipulateRealOrders to run them -// ---------------------------------------------------------------------------------------------------------------------------- - -func TestSubmitOrder(t *testing.T) { - t.Parallel() - sharedtestvalues.SkipTestIfCannotManipulateOrders(t, e, canManipulateRealOrders) - - // limit order - orderSubmission := &order.Submit{ - Exchange: e.Name, - Pair: currency.Pair{ - Delimiter: "-", - Base: currency.BTC, - Quote: currency.USD, - }, - Side: order.Buy, - Type: order.Limit, - Price: 1, - Amount: 0.001, - ClientID: "meowOrder", - AssetType: asset.Spot, - } - response, err := e.SubmitOrder(t.Context(), orderSubmission) - if sharedtestvalues.AreAPICredentialsSet(e) && (err != nil || response.Status != order.New) { - t.Errorf("Order failed to be placed: %v", err) - } else if !sharedtestvalues.AreAPICredentialsSet(e) && err == nil { - t.Error("Expecting an error when no keys are set") - } - - // market order from amount - orderSubmission = &order.Submit{ - Exchange: e.Name, - Pair: currency.Pair{ - Delimiter: "-", - Base: currency.BTC, - Quote: currency.USD, - }, - Side: order.Buy, - Type: order.Market, - Amount: 0.001, - ClientID: "meowOrder", - AssetType: asset.Spot, - } - response, err = e.SubmitOrder(t.Context(), orderSubmission) - if sharedtestvalues.AreAPICredentialsSet(e) && (err != nil || response.Status != order.New) { - t.Errorf("Order failed to be placed: %v", err) - } else if !sharedtestvalues.AreAPICredentialsSet(e) && err == nil { - t.Error("Expecting an error when no keys are set") - } - - // market order from quote amount - orderSubmission = &order.Submit{ - Exchange: e.Name, - Pair: currency.Pair{ - Delimiter: "-", - Base: currency.BTC, - Quote: currency.USD, - }, - Side: order.Buy, - Type: order.Market, - QuoteAmount: 1, - ClientID: "meowOrder", - AssetType: asset.Spot, - } - response, err = e.SubmitOrder(t.Context(), orderSubmission) - if sharedtestvalues.AreAPICredentialsSet(e) && (err != nil || response.Status != order.New) { - t.Errorf("Order failed to be placed: %v", err) - } else if !sharedtestvalues.AreAPICredentialsSet(e) && err == nil { - t.Error("Expecting an error when no keys are set") - } -} - -func TestCancelExchangeOrder(t *testing.T) { - t.Parallel() - sharedtestvalues.SkipTestIfCannotManipulateOrders(t, e, canManipulateRealOrders) - - orderCancellation := &order.Cancel{ - OrderID: "1", - AccountID: "1", - Pair: testPair, - AssetType: asset.Spot, - } - - err := e.CancelOrder(t.Context(), orderCancellation) - if !sharedtestvalues.AreAPICredentialsSet(e) && err == nil { - t.Error("Expecting an error when no keys are set") - } - if sharedtestvalues.AreAPICredentialsSet(e) && err != nil { - t.Errorf("Could not cancel orders: %v", err) - } -} - -func TestCancelAllExchangeOrders(t *testing.T) { - t.Parallel() - sharedtestvalues.SkipTestIfCannotManipulateOrders(t, e, canManipulateRealOrders) - - orderCancellation := &order.Cancel{ - OrderID: "1", - AccountID: "1", - Pair: testPair, - AssetType: asset.Spot, - } - - resp, err := e.CancelAllOrders(t.Context(), orderCancellation) - - if !sharedtestvalues.AreAPICredentialsSet(e) && err == nil { - t.Error("Expecting an error when no keys are set") - } - if sharedtestvalues.AreAPICredentialsSet(e) && err != nil { - t.Errorf("Could not cancel orders: %v", err) - } - - if len(resp.Status) > 0 { - t.Errorf("%v orders failed to cancel", len(resp.Status)) - } -} - -func TestModifyOrder(t *testing.T) { - t.Parallel() - sharedtestvalues.SkipTestIfCannotManipulateOrders(t, e, canManipulateRealOrders) - - _, err := e.ModifyOrder(t.Context(), - &order.Modify{AssetType: asset.Spot}) - if err == nil { - t.Error("ModifyOrder() Expected error") - } -} - -func TestWithdraw(t *testing.T) { - t.Parallel() - sharedtestvalues.SkipTestIfCannotManipulateOrders(t, e, canManipulateRealOrders) - - withdrawCryptoRequest := withdraw.Request{ - Exchange: e.Name, - Amount: -1, - Currency: currency.BTC, - Description: "WITHDRAW IT ALL", - Crypto: withdraw.CryptoRequest{ - Address: core.BitcoinDonationAddress, - }, - } - - _, err := e.WithdrawCryptocurrencyFunds(t.Context(), - &withdrawCryptoRequest) - if !sharedtestvalues.AreAPICredentialsSet(e) && err == nil { - t.Error("Expecting an error when no keys are set") - } - if sharedtestvalues.AreAPICredentialsSet(e) && err != nil { - t.Errorf("Withdraw failed to be placed: %v", err) - } -} - -func TestWithdrawFiat(t *testing.T) { - t.Parallel() - sharedtestvalues.SkipTestIfCannotManipulateOrders(t, e, canManipulateRealOrders) - - withdrawFiatRequest := withdraw.Request{ - Amount: 100, - Currency: currency.USD, - Fiat: withdraw.FiatRequest{ - Bank: banking.Account{ - BankName: "Federal Reserve Bank", - }, - }, - } - - _, err := e.WithdrawFiatFunds(t.Context(), &withdrawFiatRequest) - if !sharedtestvalues.AreAPICredentialsSet(e) && err == nil { - t.Error("Expecting an error when no keys are set") - } - if sharedtestvalues.AreAPICredentialsSet(e) && err != nil { - t.Errorf("Withdraw failed to be placed: %v", err) - } -} - -func TestWithdrawInternationalBank(t *testing.T) { - t.Parallel() - sharedtestvalues.SkipTestIfCannotManipulateOrders(t, e, canManipulateRealOrders) - - withdrawFiatRequest := withdraw.Request{ - Amount: 100, - Currency: currency.USD, - Fiat: withdraw.FiatRequest{ - Bank: banking.Account{ - BankName: "Federal Reserve Bank", - }, - }, - } - - _, err := e.WithdrawFiatFundsToInternationalBank(t.Context(), - &withdrawFiatRequest) - if !sharedtestvalues.AreAPICredentialsSet(e) && err == nil { - t.Error("Expecting an error when no keys are set") - } - if sharedtestvalues.AreAPICredentialsSet(e) && err != nil { - t.Errorf("Withdraw failed to be placed: %v", err) - } -} - -func TestGetDepositAddress(t *testing.T) { - _, err := e.GetDepositAddress(t.Context(), currency.BTC, "", "") - if err == nil { - t.Error("GetDepositAddress() error", err) - } -} - -func TestWsAuth(t *testing.T) { - if !e.Websocket.IsEnabled() && !e.API.AuthenticatedWebsocketSupport || !sharedtestvalues.AreAPICredentialsSet(e) { - t.Skip(websocket.ErrWebsocketNotEnabled.Error()) - } - var dialer gws.Dialer - err := e.Websocket.Conn.Dial(t.Context(), &dialer, http.Header{}) - require.NoError(t, err, "Dial must not error") - go e.wsReadData(t.Context()) - - err = e.Subscribe(subscription.List{{Channel: "user", Pairs: currency.Pairs{testPair}}}) - require.NoError(t, err, "Subscribe must not error") - timer := time.NewTimer(sharedtestvalues.WebsocketResponseDefaultTimeout) - select { - case badResponse := <-e.Websocket.DataHandler: - t.Error(badResponse) - case <-timer.C: - } - timer.Stop() -} - -func TestWsSubscribe(t *testing.T) { - pressXToJSON := []byte(`{ - "type": "subscriptions", - "channels": [ - { - "name": "level2", - "product_ids": [ - "ETH-USD", - "ETH-EUR" - ] - }, - { - "name": "heartbeat", - "product_ids": [ - "ETH-USD", - "ETH-EUR" - ] - }, - { - "name": "ticker", - "product_ids": [ - "ETH-USD", - "ETH-EUR", - "ETH-BTC" - ] - } - ] - }`) - err := e.wsHandleData(t.Context(), pressXToJSON) - if err != nil { - t.Error(err) - } -} - -func TestWsHeartbeat(t *testing.T) { - pressXToJSON := []byte(`{ - "type": "heartbeat", - "sequence": 90, - "last_trade_id": 20, - "product_id": "BTC-USD", - "time": "2014-11-07T08:19:28.464459Z" - }`) - err := e.wsHandleData(t.Context(), pressXToJSON) - if err != nil { - t.Error(err) - } -} - -func TestWsStatus(t *testing.T) { - pressXToJSON := []byte(`{ - "type": "status", - "products": [ - { - "id": "BTC-USD", - "base_currency": "BTC", - "quote_currency": "USD", - "base_min_size": "0.001", - "base_max_size": "70", - "base_increment": "0.00000001", - "quote_increment": "0.01", - "display_name": "BTC/USD", - "status": "online", - "status_message": null, - "min_market_funds": "10", - "max_market_funds": "1000000", - "post_only": false, - "limit_only": false, - "cancel_only": false - } - ], - "currencies": [ - { - "id": "USD", - "name": "United States Dollar", - "min_size": "0.01000000", - "status": "online", - "status_message": null, - "max_precision": "0.01", - "convertible_to": ["USDC"], "details": {} - }, - { - "id": "USDC", - "name": "USD Coin", - "min_size": "0.00000100", - "status": "online", - "status_message": null, - "max_precision": "0.000001", - "convertible_to": ["USD"], "details": {} - }, - { - "id": "BTC", - "name": "Bitcoin", - "min_size": "0.00000001", - "status": "online", - "status_message": null, - "max_precision": "0.00000001", - "convertible_to": [] - } - ] -}`) - err := e.wsHandleData(t.Context(), pressXToJSON) - if err != nil { - t.Error(err) - } -} - -func TestWsTicker(t *testing.T) { - pressXToJSON := []byte(`{ - "type": "ticker", - "trade_id": 20153558, - "sequence": 3262786978, - "time": "2017-09-02T17:05:49.250000Z", - "product_id": "BTC-USD", - "price": "4388.01000000", - "side": "buy", - "last_size": "0.03000000", - "best_bid": "4388", - "best_ask": "4388.01" -}`) - err := e.wsHandleData(t.Context(), pressXToJSON) - if err != nil { - t.Error(err) - } -} - -func TestWsOrderbook(t *testing.T) { - pressXToJSON := []byte(`{ - "type": "snapshot", - "product_id": "BTC-USD", - "bids": [["10101.10", "0.45054140"]], - "asks": [["10102.55", "0.57753524"]], - "time":"2023-08-15T06:46:55.376250Z" -}`) - err := e.wsHandleData(t.Context(), pressXToJSON) - if err != nil { - t.Error(err) - } - - pressXToJSON = []byte(`{ - "type": "l2update", - "product_id": "BTC-USD", - "time": "2023-08-15T06:46:57.933713Z", - "changes": [ - [ - "buy", - "10101.80000000", - "0.162567" - ] - ] -}`) - err = e.wsHandleData(t.Context(), pressXToJSON) - if err != nil { - t.Error(err) - } -} - -func TestWsOrders(t *testing.T) { - pressXToJSON := []byte(`{ - "type": "received", - "time": "2014-11-07T08:19:27.028459Z", - "product_id": "BTC-USD", - "sequence": 10, - "order_id": "d50ec984-77a8-460a-b958-66f114b0de9b", - "size": "1.34", - "price": "502.1", - "side": "buy", - "order_type": "limit" -}`) - err := e.wsHandleData(t.Context(), pressXToJSON) - if err != nil { - t.Error(err) - } - - pressXToJSON = []byte(`{ - "type": "received", - "time": "2014-11-09T08:19:27.028459Z", - "product_id": "BTC-USD", - "sequence": 12, - "order_id": "dddec984-77a8-460a-b958-66f114b0de9b", - "funds": "3000.234", - "side": "buy", - "order_type": "market" -}`) - err = e.wsHandleData(t.Context(), pressXToJSON) - if err != nil { - t.Error(err) - } - - pressXToJSON = []byte(`{ - "type": "open", - "time": "2014-11-07T08:19:27.028459Z", - "product_id": "BTC-USD", - "sequence": 10, - "order_id": "d50ec984-77a8-460a-b958-66f114b0de9b", - "price": "200.2", - "remaining_size": "1.00", - "side": "sell" -}`) - err = e.wsHandleData(t.Context(), pressXToJSON) - if err != nil { - t.Error(err) - } - - pressXToJSON = []byte(`{ - "type": "done", - "time": "2014-11-07T08:19:27.028459Z", - "product_id": "BTC-USD", - "sequence": 10, - "price": "200.2", - "order_id": "d50ec984-77a8-460a-b958-66f114b0de9b", - "reason": "filled", - "side": "sell", - "remaining_size": "0" -}`) - err = e.wsHandleData(t.Context(), pressXToJSON) - if err != nil { - t.Error(err) - } - - pressXToJSON = []byte(`{ - "type": "match", - "trade_id": 10, - "sequence": 50, - "maker_order_id": "ac928c66-ca53-498f-9c13-a110027a60e8", - "taker_order_id": "132fb6ae-456b-4654-b4e0-d681ac05cea1", - "time": "2014-11-07T08:19:27.028459Z", - "product_id": "BTC-USD", - "size": "5.23512", - "price": "400.23", - "side": "sell" -}`) - err = e.wsHandleData(t.Context(), pressXToJSON) - if err != nil { - t.Error(err) - } - - pressXToJSON = []byte(`{ - "type": "change", - "time": "2014-11-07T08:19:27.028459Z", - "sequence": 80, - "order_id": "ac928c66-ca53-498f-9c13-a110027a60e8", - "product_id": "BTC-USD", - "new_size": "5.23512", - "old_size": "12.234412", - "price": "400.23", - "side": "sell" -}`) - err = e.wsHandleData(t.Context(), pressXToJSON) - if err != nil { - t.Error(err) - } - pressXToJSON = []byte(`{ - "type": "change", - "time": "2014-11-07T08:19:27.028459Z", - "sequence": 80, - "order_id": "ac928c66-ca53-498f-9c13-a110027a60e8", - "product_id": "BTC-USD", - "new_funds": "5.23512", - "old_funds": "12.234412", - "price": "400.23", - "side": "sell" -}`) - err = e.wsHandleData(t.Context(), pressXToJSON) - if err != nil { - t.Error(err) - } - pressXToJSON = []byte(`{ - "type": "activate", - "product_id": "BTC-USD", - "timestamp": "1483736448.299000", - "user_id": "12", - "profile_id": "30000727-d308-cf50-7b1c-c06deb1934fc", - "order_id": "7b52009b-64fd-0a2a-49e6-d8a939753077", - "stop_type": "entry", - "side": "buy", - "stop_price": "80", - "size": "2", - "funds": "50", - "taker_fee_rate": "0.0025", - "private": true -}`) - err = e.wsHandleData(t.Context(), pressXToJSON) - if err != nil { - t.Error(err) - } -} - -func TestStatusToStandardStatus(t *testing.T) { - type TestCases struct { - Case string - Result order.Status - } - testCases := []TestCases{ - {Case: "received", Result: order.New}, - {Case: "open", Result: order.Active}, - {Case: "done", Result: order.Filled}, - {Case: "match", Result: order.PartiallyFilled}, - {Case: "change", Result: order.Active}, - {Case: "activate", Result: order.Active}, - {Case: "LOL", Result: order.UnknownStatus}, - } - for i := range testCases { - result, _ := statusToStandardStatus(testCases[i].Case) - if result != testCases[i].Result { - t.Errorf("Expected: %v, received: %v", testCases[i].Result, result) - } - } -} - -func TestGetRecentTrades(t *testing.T) { - t.Parallel() - _, err := e.GetRecentTrades(t.Context(), testPair, asset.Spot) - if err != nil { - t.Error(err) - } -} - -func TestGetHistoricTrades(t *testing.T) { - t.Parallel() - _, err := e.GetHistoricTrades(t.Context(), - testPair, asset.Spot, time.Now().Add(-time.Minute*15), time.Now()) - if err != nil && err != common.ErrFunctionNotSupported { - t.Error(err) - } -} - -func TestGetTransfers(t *testing.T) { - t.Parallel() - sharedtestvalues.SkipTestIfCredentialsUnset(t, e) - _, err := e.GetTransfers(t.Context(), "", "", 100, time.Time{}, time.Time{}) - if err != nil { - t.Error(err) - } -} - -func TestGetCurrencyTradeURL(t *testing.T) { - t.Parallel() - testexch.UpdatePairsOnce(t, e) - for _, a := range e.GetAssetTypes(false) { - pairs, err := e.CurrencyPairs.GetPairs(a, false) - require.NoErrorf(t, err, "cannot get pairs for %s", a) - require.NotEmptyf(t, pairs, "no pairs for %s", a) - resp, err := e.GetCurrencyTradeURL(t.Context(), a, pairs[0]) - require.NoError(t, err) - assert.NotEmpty(t, resp) - } -} diff --git a/exchanges/coinbasepro/coinbasepro_types.go b/exchanges/coinbasepro/coinbasepro_types.go deleted file mode 100644 index 1fbd3c65..00000000 --- a/exchanges/coinbasepro/coinbasepro_types.go +++ /dev/null @@ -1,514 +0,0 @@ -package coinbasepro - -import ( - "time" - - "github.com/thrasher-corp/gocryptotrader/currency" - "github.com/thrasher-corp/gocryptotrader/encoding/json" - "github.com/thrasher-corp/gocryptotrader/types" -) - -// Product holds product information -type Product struct { - ID string `json:"id"` - BaseCurrency string `json:"base_currency"` - QuoteCurrency string `json:"quote_currency"` - QuoteIncrement float64 `json:"quote_increment,string"` - BaseIncrement float64 `json:"base_increment,string"` - DisplayName string `json:"display_name"` - MinimumMarketFunds float64 `json:"min_market_funds,string"` - MarginEnabled bool `json:"margin_enabled"` - PostOnly bool `json:"post_only"` - LimitOnly bool `json:"limit_only"` - CancelOnly bool `json:"cancel_only"` - Status string `json:"status"` - StatusMessage string `json:"status_message"` - TradingDisabled bool `json:"trading_disabled"` - ForeignExchangeStableCoin bool `json:"fx_stablecoin"` - MaxSlippagePercentage float64 `json:"max_slippage_percentage,string"` - AuctionMode bool `json:"auction_mode"` -} - -// Ticker holds basic ticker information -type Ticker struct { - TradeID int64 `json:"trade_id"` - Ask float64 `json:"ask,string"` - Bid float64 `json:"bid,string"` - Price float64 `json:"price,string"` - Size float64 `json:"size,string"` - Volume float64 `json:"volume,string"` - Time time.Time `json:"time"` -} - -// Trade holds executed trade information -type Trade struct { - TradeID int64 `json:"trade_id"` - Price float64 `json:"price,string"` - Size float64 `json:"size,string"` - Time time.Time `json:"time"` - Side string `json:"side"` -} - -// History holds historic rate information -type History struct { - Time types.Time - Low float64 - High float64 - Open float64 - Close float64 - Volume float64 -} - -// UnmarshalJSON deserilizes kline data from a JSON array into History fields. -func (h *History) UnmarshalJSON(data []byte) error { - return json.Unmarshal(data, &[6]any{&h.Time, &h.Low, &h.High, &h.Open, &h.Close, &h.Volume}) -} - -// Stats holds last 24 hr data for coinbasepro -type Stats struct { - Open float64 `json:"open,string"` - High float64 `json:"high,string"` - Low float64 `json:"low,string"` - Volume float64 `json:"volume,string"` - Last float64 `json:"last,string"` - Volume30Day float64 `json:"volume_30day,string"` -} - -// Currency holds singular currency product information -type Currency struct { - ID string - Name string - MinSize float64 `json:"min_size,string"` -} - -// ServerTime holds current requested server time information -type ServerTime struct { - ISO time.Time `json:"iso"` - Epoch float64 `json:"epoch"` -} - -// AccountResponse holds the details for the trading accounts -type AccountResponse struct { - ID string `json:"id"` - Currency string `json:"currency"` - Balance float64 `json:"balance,string"` - Available float64 `json:"available,string"` - Hold float64 `json:"hold,string"` - ProfileID string `json:"profile_id"` - MarginEnabled bool `json:"margin_enabled"` - FundedAmount float64 `json:"funded_amount,string"` - DefaultAmount float64 `json:"default_amount,string"` -} - -// AccountLedgerResponse holds account history information -type AccountLedgerResponse struct { - ID string `json:"id"` - CreatedAt time.Time `json:"created_at"` - Amount float64 `json:"amount,string"` - Balance float64 `json:"balance,string"` - Type string `json:"type"` - Details any `json:"details"` -} - -// AccountHolds contains the hold information about an account -type AccountHolds struct { - ID string `json:"id"` - AccountID string `json:"account_id"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt string `json:"updated_at"` - Amount float64 `json:"amount,string"` - Type string `json:"type"` - Reference string `json:"ref"` -} - -// GeneralizedOrderResponse is the generalized return type across order -// placement and information collation -type GeneralizedOrderResponse struct { - ID string `json:"id"` - Price float64 `json:"price,string"` - Size float64 `json:"size,string"` - ProductID string `json:"product_id"` - Side string `json:"side"` - Stp string `json:"stp"` - Type string `json:"type"` - TimeInForce string `json:"time_in_force"` - PostOnly bool `json:"post_only"` - CreatedAt time.Time `json:"created_at"` - FillFees float64 `json:"fill_fees,string"` - FilledSize float64 `json:"filled_size,string"` - ExecutedValue float64 `json:"executed_value,string"` - Status string `json:"status"` - Settled bool `json:"settled"` - Funds float64 `json:"funds,string"` - SpecifiedFunds float64 `json:"specified_funds,string"` - DoneReason string `json:"done_reason"` - DoneAt time.Time `json:"done_at"` -} - -// Funding holds funding data -type Funding struct { - ID string `json:"id"` - OrderID string `json:"order_id"` - ProfileID string `json:"profile_id"` - Amount float64 `json:"amount,string"` - Status string `json:"status"` - CreatedAt time.Time `json:"created_at"` - Currency string `json:"currency"` - RepaidAmount float64 `json:"repaid_amount"` - DefaultAmount float64 `json:"default_amount,string"` - RepaidDefault bool `json:"repaid_default"` -} - -// MarginTransfer holds margin transfer details -type MarginTransfer struct { - CreatedAt time.Time `json:"created_at"` - ID string `json:"id"` - UserID string `json:"user_id"` - ProfileID string `json:"profile_id"` - MarginProfileID string `json:"margin_profile_id"` - Type string `json:"type"` - Amount float64 `json:"amount,string"` - Currency string `json:"currency"` - AccountID string `json:"account_id"` - MarginAccountID string `json:"margin_account_id"` - MarginProductID string `json:"margin_product_id"` - Status string `json:"status"` - Nonce int `json:"nonce"` -} - -// AccountOverview holds account information returned from position -type AccountOverview struct { - Status string `json:"status"` - Funding struct { - MaxFundingValue float64 `json:"max_funding_value,string"` - FundingValue float64 `json:"funding_value,string"` - OldestOutstanding struct { - ID string `json:"id"` - OrderID string `json:"order_id"` - CreatedAt time.Time `json:"created_at"` - Currency string `json:"currency"` - AccountID string `json:"account_id"` - Amount float64 `json:"amount,string"` - } `json:"oldest_outstanding"` - } `json:"funding"` - Accounts struct { - LTC Account `json:"LTC"` - ETH Account `json:"ETH"` - USD Account `json:"USD"` - BTC Account `json:"BTC"` - } `json:"accounts"` - MarginCall struct { - Active bool `json:"active"` - Price float64 `json:"price,string"` - Side string `json:"side"` - Size float64 `json:"size,string"` - Funds float64 `json:"funds,string"` - } `json:"margin_call"` - UserID string `json:"user_id"` - ProfileID string `json:"profile_id"` - Position struct { - Type string `json:"type"` - Size float64 `json:"size,string"` - Complement float64 `json:"complement,string"` - MaxSize float64 `json:"max_size,string"` - } `json:"position"` - ProductID string `json:"product_id"` -} - -// Account is a sub-type for account overview -type Account struct { - ID string `json:"id"` - Balance float64 `json:"balance,string"` - Hold float64 `json:"hold,string"` - FundedAmount float64 `json:"funded_amount,string"` - DefaultAmount float64 `json:"default_amount,string"` -} - -// PaymentMethod holds payment method information -type PaymentMethod struct { - ID string `json:"id"` - Type string `json:"type"` - Name string `json:"name"` - Currency string `json:"currency"` - PrimaryBuy bool `json:"primary_buy"` - PrimarySell bool `json:"primary_sell"` - AllowBuy bool `json:"allow_buy"` - AllowSell bool `json:"allow_sell"` - AllowDeposits bool `json:"allow_deposits"` - AllowWithdraw bool `json:"allow_withdraw"` - Limits struct { - Buy []LimitInfo `json:"buy"` - InstantBuy []LimitInfo `json:"instant_buy"` - Sell []LimitInfo `json:"sell"` - Deposit []LimitInfo `json:"deposit"` - } `json:"limits"` -} - -// LimitInfo is a sub-type for payment method -type LimitInfo struct { - PeriodInDays int `json:"period_in_days"` - Total struct { - Amount float64 `json:"amount,string"` - Currency string `json:"currency"` - } `json:"total"` -} - -// DepositWithdrawalInfo holds returned deposit information -type DepositWithdrawalInfo struct { - ID string `json:"id"` - Amount float64 `json:"amount,string"` - Currency string `json:"currency"` - PayoutAt time.Time `json:"payout_at"` -} - -// CoinbaseAccounts holds coinbase account information -type CoinbaseAccounts struct { - ID string `json:"id"` - Name string `json:"name"` - Balance float64 `json:"balance,string"` - Currency string `json:"currency"` - Type string `json:"type"` - Primary bool `json:"primary"` - Active bool `json:"active"` - WireDepositInformation struct { - AccountNumber string `json:"account_number"` - RoutingNumber string `json:"routing_number"` - BankName string `json:"bank_name"` - BankAddress string `json:"bank_address"` - BankCountry struct { - Code string `json:"code"` - Name string `json:"name"` - } `json:"bank_country"` - AccountName string `json:"account_name"` - AccountAddress string `json:"account_address"` - Reference string `json:"reference"` - } `json:"wire_deposit_information"` - SepaDepositInformation struct { - Iban string `json:"iban"` - Swift string `json:"swift"` - BankName string `json:"bank_name"` - BankAddress string `json:"bank_address"` - BankCountryName string `json:"bank_country_name"` - AccountName string `json:"account_name"` - AccountAddress string `json:"account_address"` - Reference string `json:"reference"` - } `json:"sep_deposit_information"` -} - -// Report holds historical information -type Report struct { - ID string `json:"id"` - Type string `json:"type"` - Status string `json:"status"` - CreatedAt time.Time `json:"created_at"` - CompletedAt time.Time `json:"completed_at"` - ExpiresAt time.Time `json:"expires_at"` - FileURL string `json:"file_url"` - Params struct { - StartDate time.Time `json:"start_date"` - EndDate time.Time `json:"end_date"` - } `json:"params"` -} - -// Volume type contains trailing volume information -type Volume struct { - ProductID string `json:"product_id"` - ExchangeVolume float64 `json:"exchange_volume,string"` - Volume float64 `json:"volume,string"` - RecordedAt string `json:"recorded_at"` -} - -// OrderL1L2 is a type used in layer conversion -type OrderL1L2 struct { - Price float64 - Amount float64 - NumOrders float64 -} - -// OrderL3 is a type used in layer conversion -type OrderL3 struct { - Price float64 - Amount float64 - OrderID string -} - -// OrderbookL1L2 holds level 1 and 2 order book information -type OrderbookL1L2 struct { - Sequence int64 `json:"sequence"` - Bids []OrderL1L2 `json:"bids"` - Asks []OrderL1L2 `json:"asks"` -} - -// OrderbookL3 holds level 3 order book information -type OrderbookL3 struct { - Sequence int64 `json:"sequence"` - Bids []OrderL3 `json:"bids"` - Asks []OrderL3 `json:"asks"` -} - -// OrderbookResponse is a generalized response for order books -type OrderbookResponse struct { - Sequence int64 `json:"sequence"` - Bids [][3]types.Number `json:"bids"` - Asks [][3]types.Number `json:"asks"` -} - -// FillResponse contains fill information from the exchange -type FillResponse struct { - TradeID int64 `json:"trade_id"` - ProductID string `json:"product_id"` - Price float64 `json:"price,string"` - Size float64 `json:"size,string"` - OrderID string `json:"order_id"` - CreatedAt time.Time `json:"created_at"` - Liquidity string `json:"liquidity"` - Fee float64 `json:"fee,string"` - Settled bool `json:"settled"` - Side string `json:"side"` -} - -// WebsocketSubscribe takes in subscription information -type WebsocketSubscribe struct { - Type string `json:"type"` - ProductIDs []string `json:"product_ids,omitempty"` - Channels []any `json:"channels,omitempty"` - Signature string `json:"signature,omitempty"` - Key string `json:"key,omitempty"` - Passphrase string `json:"passphrase,omitempty"` - Timestamp string `json:"timestamp,omitempty"` -} - -// WsChannel defines a websocket subscription channel -type WsChannel struct { - Name string `json:"name"` - ProductIDs []string `json:"product_ids,omitempty"` -} - -// wsOrderReceived holds websocket received values -type wsOrderReceived struct { - Type string `json:"type"` - OrderID string `json:"order_id"` - OrderType string `json:"order_type"` - Size float64 `json:"size,string"` - Price float64 `json:"price,omitempty,string"` - Funds float64 `json:"funds,omitempty,string"` - Side string `json:"side"` - ClientOID string `json:"client_oid"` - ProductID string `json:"product_id"` - Sequence int64 `json:"sequence"` - Time time.Time `json:"time"` - RemainingSize float64 `json:"remaining_size,string"` - NewSize float64 `json:"new_size,string"` - OldSize float64 `json:"old_size,string"` - Reason string `json:"reason"` - Timestamp types.Time `json:"timestamp"` - UserID string `json:"user_id"` - ProfileID string `json:"profile_id"` - StopType string `json:"stop_type"` - StopPrice float64 `json:"stop_price,string"` - TakerFeeRate float64 `json:"taker_fee_rate,string"` - Private bool `json:"private"` - TradeID int64 `json:"trade_id"` - MakerOrderID string `json:"maker_order_id"` - TakerOrderID string `json:"taker_order_id"` - TakerUserID string `json:"taker_user_id"` -} - -// WebsocketHeartBeat defines JSON response for a heart beat message -type WebsocketHeartBeat struct { - Type string `json:"type"` - Sequence int64 `json:"sequence"` - LastTradeID int64 `json:"last_trade_id"` - ProductID string `json:"product_id"` - Time time.Time `json:"time"` -} - -// WebsocketTicker defines ticker websocket response -type WebsocketTicker struct { - Type string `json:"type"` - Sequence int64 `json:"sequence"` - ProductID currency.Pair `json:"product_id"` - Price float64 `json:"price,string"` - Open24H float64 `json:"open_24h,string"` - Volume24H float64 `json:"volume_24h,string"` - Low24H float64 `json:"low_24h,string"` - High24H float64 `json:"high_24h,string"` - Volume30D float64 `json:"volume_30d,string"` - BestBid float64 `json:"best_bid,string"` - BestAsk float64 `json:"best_ask,string"` - Side string `json:"side"` - Time time.Time `json:"time"` - TradeID int64 `json:"trade_id"` - LastSize float64 `json:"last_size,string"` -} - -// WebsocketOrderbookSnapshot defines a snapshot response -type WebsocketOrderbookSnapshot struct { - ProductID string `json:"product_id"` - Type string `json:"type"` - Bids [][2]types.Number `json:"bids"` - Asks [][2]types.Number `json:"asks"` - Time time.Time `json:"time"` -} - -// WebsocketL2Update defines an update on the L2 orderbooks -type WebsocketL2Update struct { - Type string `json:"type"` - ProductID string `json:"product_id"` - Time time.Time `json:"time"` - Changes [][3]string `json:"changes"` -} - -type wsMsgType struct { - Type string `json:"type"` - Sequence int64 `json:"sequence"` - ProductID string `json:"product_id"` -} - -type wsStatus struct { - Currencies []struct { - ConvertibleTo []string `json:"convertible_to"` - Details struct{} `json:"details"` - ID string `json:"id"` - MaxPrecision float64 `json:"max_precision,string"` - MinSize float64 `json:"min_size,string"` - Name string `json:"name"` - Status string `json:"status"` - StatusMessage any `json:"status_message"` - } `json:"currencies"` - Products []struct { - BaseCurrency string `json:"base_currency"` - BaseIncrement float64 `json:"base_increment,string"` - BaseMaxSize float64 `json:"base_max_size,string"` - BaseMinSize float64 `json:"base_min_size,string"` - CancelOnly bool `json:"cancel_only"` - DisplayName string `json:"display_name"` - ID string `json:"id"` - LimitOnly bool `json:"limit_only"` - MaxMarketFunds float64 `json:"max_market_funds,string"` - MinMarketFunds float64 `json:"min_market_funds,string"` - PostOnly bool `json:"post_only"` - QuoteCurrency string `json:"quote_currency"` - QuoteIncrement float64 `json:"quote_increment,string"` - Status string `json:"status"` - StatusMessage any `json:"status_message"` - } `json:"products"` - Type string `json:"type"` -} - -// TransferHistory returns wallet transfer history -type TransferHistory struct { - ID string `json:"id"` - Type string `json:"type"` - CreatedAt string `json:"created_at"` - CompletedAt string `json:"completed_at"` - CanceledAt time.Time `json:"canceled_at"` - ProcessedAt time.Time `json:"processed_at"` - UserNonce int64 `json:"user_nonce"` - Amount string `json:"amount"` - Details struct { - CoinbaseAccountID string `json:"coinbase_account_id"` - CoinbaseTransactionID string `json:"coinbase_transaction_id"` - CoinbasePaymentMethodID string `json:"coinbase_payment_method_id"` - } `json:"details"` -} diff --git a/exchanges/coinbasepro/coinbasepro_websocket.go b/exchanges/coinbasepro/coinbasepro_websocket.go deleted file mode 100644 index c77c264e..00000000 --- a/exchanges/coinbasepro/coinbasepro_websocket.go +++ /dev/null @@ -1,455 +0,0 @@ -package coinbasepro - -import ( - "context" - "encoding/base64" - "errors" - "fmt" - "net/http" - "strconv" - "time" - - gws "github.com/gorilla/websocket" - "github.com/thrasher-corp/gocryptotrader/common/crypto" - "github.com/thrasher-corp/gocryptotrader/currency" - "github.com/thrasher-corp/gocryptotrader/encoding/json" - "github.com/thrasher-corp/gocryptotrader/exchange/websocket" - "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/request" - "github.com/thrasher-corp/gocryptotrader/exchanges/subscription" - "github.com/thrasher-corp/gocryptotrader/exchanges/ticker" - "github.com/thrasher-corp/gocryptotrader/exchanges/trade" -) - -const ( - coinbaseproWebsocketURL = "wss://ws-feed.pro.coinbase.com" -) - -// WsConnect initiates a websocket connection -func (e *Exchange) WsConnect() error { - ctx := context.TODO() - if !e.Websocket.IsEnabled() || !e.IsEnabled() { - return websocket.ErrWebsocketNotEnabled - } - var dialer gws.Dialer - err := e.Websocket.Conn.Dial(ctx, &dialer, http.Header{}) - if err != nil { - return err - } - - e.Websocket.Wg.Add(1) - go e.wsReadData(ctx) - return nil -} - -// wsReadData receives and passes on websocket messages for processing -func (e *Exchange) wsReadData(ctx context.Context) { - defer e.Websocket.Wg.Done() - - for { - resp := e.Websocket.Conn.ReadMessage() - if resp.Raw == nil { - return - } - err := e.wsHandleData(ctx, resp.Raw) - if err != nil { - e.Websocket.DataHandler <- err - } - } -} - -func (e *Exchange) wsHandleData(ctx context.Context, respRaw []byte) error { - msgType := wsMsgType{} - err := json.Unmarshal(respRaw, &msgType) - if err != nil { - return err - } - - if msgType.Type == "subscriptions" || msgType.Type == "heartbeat" { - return nil - } - - switch msgType.Type { - case "status": - var status wsStatus - err = json.Unmarshal(respRaw, &status) - if err != nil { - return err - } - e.Websocket.DataHandler <- status - case "error": - e.Websocket.DataHandler <- errors.New(string(respRaw)) - case "ticker": - wsTicker := WebsocketTicker{} - err := json.Unmarshal(respRaw, &wsTicker) - if err != nil { - return err - } - - e.Websocket.DataHandler <- &ticker.Price{ - LastUpdated: wsTicker.Time, - Pair: wsTicker.ProductID, - AssetType: asset.Spot, - ExchangeName: e.Name, - Open: wsTicker.Open24H, - High: wsTicker.High24H, - Low: wsTicker.Low24H, - Last: wsTicker.Price, - Volume: wsTicker.Volume24H, - Bid: wsTicker.BestBid, - Ask: wsTicker.BestAsk, - } - - case "snapshot": - var snapshot WebsocketOrderbookSnapshot - err := json.Unmarshal(respRaw, &snapshot) - if err != nil { - return err - } - - err = e.ProcessSnapshot(&snapshot) - if err != nil { - return err - } - case "l2update": - var update WebsocketL2Update - err := json.Unmarshal(respRaw, &update) - if err != nil { - return err - } - - err = e.ProcessOrderbookUpdate(&update) - if err != nil { - return err - } - case "received", "open", "done", "change", "activate": - var wsOrder wsOrderReceived - err := json.Unmarshal(respRaw, &wsOrder) - if err != nil { - return err - } - var oType order.Type - var oSide order.Side - var oStatus order.Status - oType, err = order.StringToOrderType(wsOrder.OrderType) - if err != nil { - e.Websocket.DataHandler <- order.ClassificationError{ - Exchange: e.Name, - OrderID: wsOrder.OrderID, - Err: err, - } - } - oSide, err = order.StringToOrderSide(wsOrder.Side) - if err != nil { - e.Websocket.DataHandler <- order.ClassificationError{ - Exchange: e.Name, - OrderID: wsOrder.OrderID, - Err: err, - } - } - oStatus, err = statusToStandardStatus(wsOrder.Type) - if err != nil { - e.Websocket.DataHandler <- order.ClassificationError{ - Exchange: e.Name, - OrderID: wsOrder.OrderID, - Err: err, - } - } - if wsOrder.Reason == "canceled" { - oStatus = order.Cancelled - } - ts := wsOrder.Time - if wsOrder.Type == "activate" { - ts = wsOrder.Timestamp.Time() - } - - creds, err := e.GetCredentials(ctx) - if err != nil { - e.Websocket.DataHandler <- order.ClassificationError{ - Exchange: e.Name, - OrderID: wsOrder.OrderID, - Err: err, - } - } - - clientID := "" - if creds != nil { - clientID = creds.ClientID - } - - if wsOrder.UserID != "" { - var p currency.Pair - var a asset.Item - p, a, err = e.GetRequestFormattedPairAndAssetType(wsOrder.ProductID) - if err != nil { - return err - } - e.Websocket.DataHandler <- &order.Detail{ - HiddenOrder: wsOrder.Private, - Price: wsOrder.Price, - Amount: wsOrder.Size, - TriggerPrice: wsOrder.StopPrice, - ExecutedAmount: wsOrder.Size - wsOrder.RemainingSize, - RemainingAmount: wsOrder.RemainingSize, - Fee: wsOrder.TakerFeeRate, - Exchange: e.Name, - OrderID: wsOrder.OrderID, - AccountID: wsOrder.ProfileID, - ClientID: clientID, - Type: oType, - Side: oSide, - Status: oStatus, - AssetType: a, - Date: ts, - Pair: p, - } - } - case "match", "last_match": - var wsOrder wsOrderReceived - err := json.Unmarshal(respRaw, &wsOrder) - if err != nil { - return err - } - oSide, err := order.StringToOrderSide(wsOrder.Side) - if err != nil { - e.Websocket.DataHandler <- order.ClassificationError{ - Exchange: e.Name, - Err: err, - } - } - var p currency.Pair - var a asset.Item - p, a, err = e.GetRequestFormattedPairAndAssetType(wsOrder.ProductID) - if err != nil { - return err - } - - if wsOrder.UserID != "" { - e.Websocket.DataHandler <- &order.Detail{ - OrderID: wsOrder.OrderID, - Pair: p, - AssetType: a, - Trades: []order.TradeHistory{ - { - Price: wsOrder.Price, - Amount: wsOrder.Size, - Exchange: e.Name, - TID: strconv.FormatInt(wsOrder.TradeID, 10), - Side: oSide, - Timestamp: wsOrder.Time, - IsMaker: wsOrder.TakerUserID == "", - }, - }, - } - } else { - if !e.IsSaveTradeDataEnabled() { - return nil - } - return trade.AddTradesToBuffer(trade.Data{ - Timestamp: wsOrder.Time, - Exchange: e.Name, - CurrencyPair: p, - AssetType: a, - Price: wsOrder.Price, - Amount: wsOrder.Size, - Side: oSide, - TID: strconv.FormatInt(wsOrder.TradeID, 10), - }) - } - default: - e.Websocket.DataHandler <- websocket.UnhandledMessageWarning{Message: e.Name + websocket.UnhandledMessage + string(respRaw)} - return nil - } - return nil -} - -func statusToStandardStatus(stat string) (order.Status, error) { - switch stat { - case "received": - return order.New, nil - case "open": - return order.Active, nil - case "done": - return order.Filled, nil - case "match": - return order.PartiallyFilled, nil - case "change", "activate": - return order.Active, nil - default: - return order.UnknownStatus, fmt.Errorf("%s not recognised as status type", stat) - } -} - -// ProcessSnapshot processes the initial orderbook snap shot -func (e *Exchange) ProcessSnapshot(snapshot *WebsocketOrderbookSnapshot) error { - pair, err := currency.NewPairFromString(snapshot.ProductID) - if err != nil { - return err - } - - ob := &orderbook.Book{ - Pair: pair, - Bids: make(orderbook.Levels, len(snapshot.Bids)), - Asks: make(orderbook.Levels, len(snapshot.Asks)), - Asset: asset.Spot, - Exchange: e.Name, - ValidateOrderbook: e.ValidateOrderbook, - LastUpdated: snapshot.Time, - } - - for i := range snapshot.Bids { - ob.Bids[i].Price = snapshot.Bids[i][0].Float64() - ob.Bids[i].Amount = snapshot.Bids[i][1].Float64() - } - for i := range snapshot.Asks { - ob.Asks[i].Price = snapshot.Asks[i][0].Float64() - ob.Asks[i].Amount = snapshot.Asks[i][1].Float64() - } - return e.Websocket.Orderbook.LoadSnapshot(ob) -} - -// ProcessOrderbookUpdate updates the orderbook local cache -func (e *Exchange) ProcessOrderbookUpdate(update *WebsocketL2Update) error { - if len(update.Changes) == 0 { - return errors.New("no data in websocket update") - } - - p, err := currency.NewPairFromString(update.ProductID) - if err != nil { - return err - } - - asks := make(orderbook.Levels, 0, len(update.Changes)) - bids := make(orderbook.Levels, 0, len(update.Changes)) - - for i := range update.Changes { - price, err := strconv.ParseFloat(update.Changes[i][1], 64) - if err != nil { - return err - } - volume, err := strconv.ParseFloat(update.Changes[i][2], 64) - if err != nil { - return err - } - if update.Changes[i][0] == order.Buy.Lower() { - bids = append(bids, orderbook.Level{Price: price, Amount: volume}) - } else { - asks = append(asks, orderbook.Level{Price: price, Amount: volume}) - } - } - - return e.Websocket.Orderbook.Update(&orderbook.Update{ - Bids: bids, - Asks: asks, - Pair: p, - UpdateTime: update.Time, - Asset: asset.Spot, - }) -} - -// generateSubscriptions returns a list of subscriptions from the configured subscriptions feature -func (e *Exchange) generateSubscriptions() (subscription.List, error) { - pairs, err := e.GetEnabledPairs(asset.Spot) - if err != nil { - return nil, err - } - pairFmt, err := e.GetPairFormat(asset.Spot, true) - if err != nil { - return nil, err - } - pairs = pairs.Format(pairFmt) - authed := e.IsWebsocketAuthenticationSupported() - subs := make(subscription.List, 0, len(e.Features.Subscriptions)) - for _, baseSub := range e.Features.Subscriptions { - if !authed && baseSub.Authenticated { - continue - } - - s := baseSub.Clone() - s.Asset = asset.Spot - s.Pairs = pairs - subs = append(subs, s) - } - return subs, nil -} - -// Subscribe sends a websocket message to receive data from the channel -func (e *Exchange) Subscribe(subs subscription.List) error { - ctx := context.TODO() - r := &WebsocketSubscribe{ - Type: "subscribe", - Channels: make([]any, 0, len(subs)), - } - // See if we have a consistent Pair list for all the subs that we can use globally - // If all the subs have the same pairs then we can use the top level ProductIDs field - // Otherwise each and every sub needs to have it's own list - for i, s := range subs { - if i == 0 { - r.ProductIDs = s.Pairs.Strings() - } else if !subs[0].Pairs.Equal(s.Pairs) { - r.ProductIDs = nil - break - } - } - for _, s := range subs { - if s.Authenticated && r.Key == "" && e.IsWebsocketAuthenticationSupported() { - if err := e.authWsSubscibeReq(ctx, r); err != nil { - return err - } - } - if len(r.ProductIDs) == 0 { - r.Channels = append(r.Channels, WsChannel{ - Name: s.Channel, - ProductIDs: s.Pairs.Strings(), - }) - } else { - // Coinbase does not support using [WsChannel{Name:"x"}] unless each ProductIDs field is populated - // Therefore we have to use Channels as an array of strings - r.Channels = append(r.Channels, s.Channel) - } - } - err := e.Websocket.Conn.SendJSONMessage(ctx, request.Unset, r) - if err == nil { - err = e.Websocket.AddSuccessfulSubscriptions(e.Websocket.Conn, subs...) - } - return err -} - -func (e *Exchange) authWsSubscibeReq(ctx context.Context, r *WebsocketSubscribe) error { - creds, err := e.GetCredentials(ctx) - if err != nil { - return err - } - r.Timestamp = strconv.FormatInt(time.Now().Unix(), 10) - message := r.Timestamp + http.MethodGet + "/users/self/verify" - hmac, err := crypto.GetHMAC(crypto.HashSHA256, []byte(message), []byte(creds.Secret)) - if err != nil { - return err - } - r.Signature = base64.StdEncoding.EncodeToString(hmac) - r.Key = creds.Key - r.Passphrase = creds.ClientID - return nil -} - -// Unsubscribe sends a websocket message to stop receiving data from the channel -func (e *Exchange) Unsubscribe(subs subscription.List) error { - ctx := context.TODO() - r := &WebsocketSubscribe{ - Type: "unsubscribe", - Channels: make([]any, 0, len(subs)), - } - for _, s := range subs { - r.Channels = append(r.Channels, WsChannel{ - Name: s.Channel, - ProductIDs: s.Pairs.Strings(), - }) - } - err := e.Websocket.Conn.SendJSONMessage(ctx, request.Unset, r) - if err == nil { - err = e.Websocket.RemoveSubscriptions(e.Websocket.Conn, subs...) - } - return err -} diff --git a/exchanges/coinbasepro/coinbasepro_wrapper.go b/exchanges/coinbasepro/coinbasepro_wrapper.go deleted file mode 100644 index 28fc7348..00000000 --- a/exchanges/coinbasepro/coinbasepro_wrapper.go +++ /dev/null @@ -1,873 +0,0 @@ -package coinbasepro - -import ( - "context" - "fmt" - "sort" - "strconv" - "time" - - "github.com/thrasher-corp/gocryptotrader/common" - "github.com/thrasher-corp/gocryptotrader/config" - "github.com/thrasher-corp/gocryptotrader/currency" - "github.com/thrasher-corp/gocryptotrader/exchange/websocket" - "github.com/thrasher-corp/gocryptotrader/exchange/websocket/buffer" - exchange "github.com/thrasher-corp/gocryptotrader/exchanges" - "github.com/thrasher-corp/gocryptotrader/exchanges/account" - "github.com/thrasher-corp/gocryptotrader/exchanges/asset" - "github.com/thrasher-corp/gocryptotrader/exchanges/deposit" - "github.com/thrasher-corp/gocryptotrader/exchanges/fundingrate" - "github.com/thrasher-corp/gocryptotrader/exchanges/futures" - "github.com/thrasher-corp/gocryptotrader/exchanges/kline" - "github.com/thrasher-corp/gocryptotrader/exchanges/order" - "github.com/thrasher-corp/gocryptotrader/exchanges/orderbook" - "github.com/thrasher-corp/gocryptotrader/exchanges/protocol" - "github.com/thrasher-corp/gocryptotrader/exchanges/request" - "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" - "github.com/thrasher-corp/gocryptotrader/portfolio/withdraw" -) - -// SetDefaults sets default values for the exchange -func (e *Exchange) SetDefaults() { - e.Name = "CoinbasePro" - e.Enabled = true - e.Verbose = true - e.API.CredentialsValidator.RequiresKey = true - e.API.CredentialsValidator.RequiresSecret = true - e.API.CredentialsValidator.RequiresClientID = true - e.API.CredentialsValidator.RequiresBase64DecodeSecret = true - - requestFmt := ¤cy.PairFormat{Delimiter: currency.DashDelimiter, Uppercase: true} - configFmt := ¤cy.PairFormat{Delimiter: currency.DashDelimiter, Uppercase: true} - err := e.SetGlobalPairsManager(requestFmt, configFmt, asset.Spot) - if err != nil { - log.Errorln(log.ExchangeSys, err) - } - - e.Features = exchange.Features{ - Supports: exchange.FeaturesSupported{ - REST: true, - Websocket: true, - RESTCapabilities: protocol.Features{ - TickerFetching: true, - KlineFetching: true, - TradeFetching: true, - OrderbookFetching: true, - AutoPairUpdates: true, - AccountInfo: true, - GetOrder: true, - GetOrders: true, - CancelOrders: true, - CancelOrder: true, - SubmitOrder: true, - DepositHistory: true, - WithdrawalHistory: true, - UserTradeHistory: true, - CryptoDeposit: true, - CryptoWithdrawal: true, - FiatDeposit: true, - FiatWithdraw: true, - TradeFee: true, - FiatDepositFee: true, - FiatWithdrawalFee: true, - CandleHistory: true, - }, - WebsocketCapabilities: protocol.Features{ - TickerFetching: true, - OrderbookFetching: true, - Subscribe: true, - Unsubscribe: true, - AuthenticatedEndpoints: true, - MessageSequenceNumbers: true, - GetOrders: true, - GetOrder: true, - }, - WithdrawPermissions: exchange.AutoWithdrawCryptoWithAPIPermission | - exchange.AutoWithdrawFiatWithAPIPermission, - Kline: kline.ExchangeCapabilitiesSupported{ - DateRanges: true, - Intervals: true, - }, - }, - Enabled: exchange.FeaturesEnabled{ - AutoPairUpdates: true, - Kline: kline.ExchangeCapabilitiesEnabled{ - Intervals: kline.DeployExchangeIntervals( - kline.IntervalCapacity{Interval: kline.OneMin}, - kline.IntervalCapacity{Interval: kline.FiveMin}, - kline.IntervalCapacity{Interval: kline.FifteenMin}, - kline.IntervalCapacity{Interval: kline.OneHour}, - kline.IntervalCapacity{Interval: kline.SixHour}, - kline.IntervalCapacity{Interval: kline.OneDay}, - ), - GlobalResultLimit: 300, - }, - }, - Subscriptions: subscription.List{ - {Enabled: true, Channel: "heartbeat"}, - {Enabled: true, Channel: "level2_batch"}, // Other orderbook feeds require authentication; This is batched in 50ms lots - {Enabled: true, Channel: "ticker"}, - {Enabled: true, Channel: "user", Authenticated: true}, - {Enabled: true, Channel: "matches"}, - }, - } - - e.Requester, err = request.New(e.Name, - common.NewHTTPClientWithTimeout(exchange.DefaultHTTPTimeout), - request.WithLimiter(GetRateLimit())) - if err != nil { - log.Errorln(log.ExchangeSys, err) - } - e.API.Endpoints = e.NewEndpoints() - err = e.API.Endpoints.SetDefaultEndpoints(map[exchange.URL]string{ - exchange.RestSpot: coinbaseproAPIURL, - exchange.RestSandbox: coinbaseproSandboxAPIURL, - exchange.WebsocketSpot: coinbaseproWebsocketURL, - }) - if err != nil { - log.Errorln(log.ExchangeSys, err) - } - e.Websocket = websocket.NewManager() - e.WebsocketResponseMaxLimit = exchange.DefaultWebsocketResponseMaxLimit - e.WebsocketResponseCheckTimeout = exchange.DefaultWebsocketResponseCheckTimeout - e.WebsocketOrderbookBufferLimit = exchange.DefaultWebsocketOrderbookBufferLimit -} - -// Setup initialises the exchange parameters with the current configuration -func (e *Exchange) Setup(exch *config.Exchange) error { - err := exch.Validate() - if err != nil { - return err - } - if !exch.Enabled { - e.SetEnabled(false) - return nil - } - err = e.SetupDefaults(exch) - if err != nil { - return err - } - - wsRunningURL, err := e.API.Endpoints.GetURL(exchange.WebsocketSpot) - if err != nil { - return err - } - - err = e.Websocket.Setup(&websocket.ManagerSetup{ - ExchangeConfig: exch, - DefaultURL: coinbaseproWebsocketURL, - RunningURL: wsRunningURL, - Connector: e.WsConnect, - Subscriber: e.Subscribe, - Unsubscriber: e.Unsubscribe, - GenerateSubscriptions: e.generateSubscriptions, - Features: &e.Features.Supports.WebsocketCapabilities, - OrderbookBufferConfig: buffer.Config{ - SortBuffer: true, - }, - }) - if err != nil { - return err - } - - return e.Websocket.SetupNewConnection(&websocket.ConnectionSetup{ - ResponseCheckTimeout: exch.WebsocketResponseCheckTimeout, - ResponseMaxLimit: exch.WebsocketResponseMaxLimit, - }) -} - -// FetchTradablePairs returns a list of the exchanges tradable pairs -func (e *Exchange) FetchTradablePairs(ctx context.Context, _ asset.Item) (currency.Pairs, error) { - products, err := e.GetProducts(ctx) - if err != nil { - return nil, err - } - - pairs := make([]currency.Pair, 0, len(products)) - for x := range products { - if products[x].TradingDisabled { - continue - } - var pair currency.Pair - pair, err = currency.NewPairDelimiter(products[x].ID, currency.DashDelimiter) - if err != nil { - return nil, err - } - pairs = append(pairs, pair) - } - return pairs, nil -} - -// UpdateTradablePairs updates the exchanges available pairs and stores -// them in the exchanges config -func (e *Exchange) UpdateTradablePairs(ctx context.Context, forceUpdate bool) error { - pairs, err := e.FetchTradablePairs(ctx, asset.Spot) - if err != nil { - return err - } - err = e.UpdatePairs(pairs, asset.Spot, false, forceUpdate) - if err != nil { - return err - } - return e.EnsureOnePairEnabled() -} - -// UpdateAccountInfo retrieves balances for all enabled currencies for the -// coinbasepro exchange -func (e *Exchange) UpdateAccountInfo(ctx context.Context, assetType asset.Item) (account.Holdings, error) { - var response account.Holdings - response.Exchange = e.Name - accountBalance, err := e.GetAccounts(ctx) - if err != nil { - return response, err - } - - accountCurrencies := make(map[string][]account.Balance) - for i := range accountBalance { - profileID := accountBalance[i].ProfileID - currencies := accountCurrencies[profileID] - accountCurrencies[profileID] = append(currencies, account.Balance{ - Currency: currency.NewCode(accountBalance[i].Currency), - Total: accountBalance[i].Balance, - Hold: accountBalance[i].Hold, - Free: accountBalance[i].Available, - AvailableWithoutBorrow: accountBalance[i].Available - accountBalance[i].FundedAmount, - Borrowed: accountBalance[i].FundedAmount, - }) - } - - if response.Accounts, err = account.CollectBalances(accountCurrencies, assetType); err != nil { - return account.Holdings{}, err - } - - creds, err := e.GetCredentials(ctx) - if err != nil { - return account.Holdings{}, err - } - err = account.Process(&response, creds) - if err != nil { - return account.Holdings{}, err - } - - return response, nil -} - -// UpdateTickers updates the ticker for all currency pairs of a given asset type -func (e *Exchange) UpdateTickers(_ context.Context, _ asset.Item) error { - return common.ErrFunctionNotSupported -} - -// UpdateTicker updates and returns the ticker for a currency pair -func (e *Exchange) UpdateTicker(ctx context.Context, p currency.Pair, a asset.Item) (*ticker.Price, error) { - fPair, err := e.FormatExchangeCurrency(p, a) - if err != nil { - return nil, err - } - - tick, err := e.GetTicker(ctx, fPair.String()) - if err != nil { - return nil, err - } - stats, err := e.GetStats(ctx, fPair.String()) - if err != nil { - return nil, err - } - - tickerPrice := &ticker.Price{ - Last: stats.Last, - High: stats.High, - Low: stats.Low, - Bid: tick.Bid, - Ask: tick.Ask, - Volume: tick.Volume, - Open: stats.Open, - Pair: p, - LastUpdated: tick.Time, - ExchangeName: e.Name, - AssetType: a, - } - - err = ticker.ProcessTicker(tickerPrice) - if err != nil { - return tickerPrice, err - } - - return ticker.GetTicker(e.Name, p, a) -} - -// UpdateOrderbook updates and returns the orderbook for a currency pair -func (e *Exchange) UpdateOrderbook(ctx context.Context, p currency.Pair, assetType asset.Item) (*orderbook.Book, error) { - if p.IsEmpty() { - return nil, currency.ErrCurrencyPairEmpty - } - if err := e.CurrencyPairs.IsAssetEnabled(assetType); err != nil { - return nil, err - } - book := &orderbook.Book{ - Exchange: e.Name, - Pair: p, - Asset: assetType, - ValidateOrderbook: e.ValidateOrderbook, - } - fPair, err := e.FormatExchangeCurrency(p, assetType) - if err != nil { - return book, err - } - - orderbookNew, err := e.GetOrderbook(ctx, fPair.String(), 2) - if err != nil { - return book, err - } - - obNew, ok := orderbookNew.(OrderbookL1L2) - if !ok { - return book, common.GetTypeAssertError("OrderbookL1L2", orderbookNew) - } - - book.Bids = make(orderbook.Levels, len(obNew.Bids)) - for x := range obNew.Bids { - book.Bids[x] = orderbook.Level{ - Amount: obNew.Bids[x].Amount, - Price: obNew.Bids[x].Price, - } - } - - book.Asks = make(orderbook.Levels, len(obNew.Asks)) - for x := range obNew.Asks { - book.Asks[x] = orderbook.Level{ - Amount: obNew.Asks[x].Amount, - Price: obNew.Asks[x].Price, - } - } - err = book.Process() - if err != nil { - return book, err - } - return orderbook.Get(e.Name, p, assetType) -} - -// GetAccountFundingHistory returns funding history, deposits and -// withdrawals -func (e *Exchange) GetAccountFundingHistory(_ context.Context) ([]exchange.FundingHistory, error) { - return nil, common.ErrFunctionNotSupported -} - -// GetWithdrawalsHistory returns previous withdrawals data -func (e *Exchange) GetWithdrawalsHistory(_ context.Context, _ currency.Code, _ asset.Item) ([]exchange.WithdrawalHistory, error) { - // while fetching withdrawal history is possible, the API response lacks any useful information - // like the currency withdrawn and thus is unsupported. If that position changes, use GetTransfers(...) - return nil, common.ErrFunctionNotSupported -} - -// GetRecentTrades returns the most recent trades for a currency and asset -func (e *Exchange) GetRecentTrades(ctx context.Context, p currency.Pair, assetType asset.Item) ([]trade.Data, error) { - var err error - p, err = e.FormatExchangeCurrency(p, assetType) - if err != nil { - return nil, err - } - var tradeData []Trade - tradeData, err = e.GetTrades(ctx, p.String()) - if err != nil { - return nil, err - } - resp := make([]trade.Data, len(tradeData)) - for i := range tradeData { - var side order.Side - side, err = order.StringToOrderSide(tradeData[i].Side) - if err != nil { - return nil, err - } - resp[i] = trade.Data{ - Exchange: e.Name, - TID: strconv.FormatInt(tradeData[i].TradeID, 10), - CurrencyPair: p, - AssetType: assetType, - Side: side, - Price: tradeData[i].Price, - Amount: tradeData[i].Size, - Timestamp: tradeData[i].Time, - } - } - - err = e.AddTradesToBuffer(resp...) - if err != nil { - return nil, err - } - - sort.Sort(trade.ByDate(resp)) - return resp, nil -} - -// GetHistoricTrades returns historic trade data within the timeframe provided -func (e *Exchange) GetHistoricTrades(_ context.Context, _ currency.Pair, _ asset.Item, _, _ time.Time) ([]trade.Data, error) { - return nil, common.ErrFunctionNotSupported -} - -// SubmitOrder submits a new order -func (e *Exchange) SubmitOrder(ctx context.Context, s *order.Submit) (*order.SubmitResponse, error) { - if err := s.Validate(e.GetTradingRequirements()); err != nil { - return nil, err - } - - fPair, err := e.FormatExchangeCurrency(s.Pair, asset.Spot) - if err != nil { - return nil, err - } - - var orderID string - switch s.Type { - case order.Market: - orderID, err = e.PlaceMarketOrder(ctx, - "", - s.Amount, - s.QuoteAmount, - s.Side.Lower(), - fPair.String(), - "") - case order.Limit: - timeInForce := order.GoodTillCancel.String() - if s.TimeInForce == order.ImmediateOrCancel { - timeInForce = order.ImmediateOrCancel.String() - } - orderID, err = e.PlaceLimitOrder(ctx, - "", - s.Price, - s.Amount, - s.Side.Lower(), - timeInForce, - "", - fPair.String(), - "", - false) - default: - err = fmt.Errorf("%w %v", order.ErrUnsupportedOrderType, s.Type) - } - if err != nil { - return nil, err - } - return s.DeriveSubmitResponse(orderID) -} - -// ModifyOrder will allow of changing orderbook placement and limit to -// market conversion -func (e *Exchange) ModifyOrder(_ context.Context, _ *order.Modify) (*order.ModifyResponse, error) { - return nil, common.ErrFunctionNotSupported -} - -// CancelOrder cancels an order by its corresponding ID number -func (e *Exchange) CancelOrder(ctx context.Context, o *order.Cancel) error { - if err := o.Validate(o.StandardCancel()); err != nil { - return err - } - return e.CancelExistingOrder(ctx, o.OrderID) -} - -// CancelBatchOrders cancels an orders by their corresponding ID numbers -func (e *Exchange) CancelBatchOrders(_ context.Context, _ []order.Cancel) (*order.CancelBatchResponse, error) { - return nil, common.ErrFunctionNotSupported -} - -// CancelAllOrders cancels all orders associated with a currency pair -func (e *Exchange) CancelAllOrders(ctx context.Context, _ *order.Cancel) (order.CancelAllResponse, error) { - // CancellAllExisting orders returns a list of successful cancellations, we're only interested in failures - _, err := e.CancelAllExistingOrders(ctx, "") - return order.CancelAllResponse{}, err -} - -// GetOrderInfo returns order information based on order ID -func (e *Exchange) GetOrderInfo(ctx context.Context, orderID string, _ currency.Pair, _ asset.Item) (*order.Detail, error) { - genOrderDetail, err := e.GetOrder(ctx, orderID) - if err != nil { - return nil, fmt.Errorf("error retrieving order %s : %w", orderID, err) - } - orderStatus, err := order.StringToOrderStatus(genOrderDetail.Status) - if err != nil { - return nil, fmt.Errorf("error parsing order status: %w", err) - } - orderType, err := order.StringToOrderType(genOrderDetail.Type) - if err != nil { - return nil, fmt.Errorf("error parsing order type: %w", err) - } - orderSide, err := order.StringToOrderSide(genOrderDetail.Side) - if err != nil { - return nil, fmt.Errorf("error parsing order side: %w", err) - } - pair, err := currency.NewPairDelimiter(genOrderDetail.ProductID, "-") - if err != nil { - return nil, fmt.Errorf("error parsing order pair: %w", err) - } - - response := order.Detail{ - Exchange: e.GetName(), - OrderID: genOrderDetail.ID, - Pair: pair, - Side: orderSide, - Type: orderType, - Date: genOrderDetail.DoneAt, - Status: orderStatus, - Price: genOrderDetail.Price, - Amount: genOrderDetail.Size, - ExecutedAmount: genOrderDetail.FilledSize, - RemainingAmount: genOrderDetail.Size - genOrderDetail.FilledSize, - Fee: genOrderDetail.FillFees, - } - fillResponse, err := e.GetFills(ctx, orderID, genOrderDetail.ProductID) - if err != nil { - return nil, fmt.Errorf("error retrieving the order fills: %w", err) - } - for i := range fillResponse { - var fillSide order.Side - fillSide, err = order.StringToOrderSide(fillResponse[i].Side) - if err != nil { - return nil, fmt.Errorf("error parsing order Side: %w", err) - } - response.Trades = append(response.Trades, order.TradeHistory{ - Timestamp: fillResponse[i].CreatedAt, - TID: strconv.FormatInt(fillResponse[i].TradeID, 10), - Price: fillResponse[i].Price, - Amount: fillResponse[i].Size, - Exchange: e.GetName(), - Type: orderType, - Side: fillSide, - Fee: fillResponse[i].Fee, - }) - } - return &response, nil -} - -// GetDepositAddress returns a deposit address for a specified currency -func (e *Exchange) GetDepositAddress(_ context.Context, _ currency.Code, _, _ string) (*deposit.Address, error) { - return nil, common.ErrFunctionNotSupported -} - -// WithdrawCryptocurrencyFunds returns a withdrawal ID when a withdrawal is -// submitted -func (e *Exchange) WithdrawCryptocurrencyFunds(ctx context.Context, withdrawRequest *withdraw.Request) (*withdraw.ExchangeResponse, error) { - if err := withdrawRequest.Validate(); err != nil { - return nil, err - } - resp, err := e.WithdrawCrypto(ctx, - withdrawRequest.Amount, - withdrawRequest.Currency.String(), - withdrawRequest.Crypto.Address) - if err != nil { - return nil, err - } - return &withdraw.ExchangeResponse{ - ID: resp.ID, - }, err -} - -// WithdrawFiatFunds returns a withdrawal ID when a withdrawal is -// submitted -func (e *Exchange) WithdrawFiatFunds(ctx context.Context, withdrawRequest *withdraw.Request) (*withdraw.ExchangeResponse, error) { - if err := withdrawRequest.Validate(); err != nil { - return nil, err - } - paymentMethods, err := e.GetPayMethods(ctx) - if err != nil { - return nil, err - } - - selectedWithdrawalMethod := PaymentMethod{} - for i := range paymentMethods { - if withdrawRequest.Fiat.Bank.BankName == paymentMethods[i].Name { - selectedWithdrawalMethod = paymentMethods[i] - break - } - } - if selectedWithdrawalMethod.ID == "" { - return nil, fmt.Errorf("could not find payment method '%v'. Check the name via the website and try again", withdrawRequest.Fiat.Bank.BankName) - } - - resp, err := e.WithdrawViaPaymentMethod(ctx, - withdrawRequest.Amount, - withdrawRequest.Currency.String(), - selectedWithdrawalMethod.ID) - if err != nil { - return nil, err - } - - return &withdraw.ExchangeResponse{ - Status: resp.ID, - }, nil -} - -// WithdrawFiatFundsToInternationalBank returns a withdrawal ID when a -// withdrawal is submitted -func (e *Exchange) WithdrawFiatFundsToInternationalBank(ctx context.Context, withdrawRequest *withdraw.Request) (*withdraw.ExchangeResponse, error) { - if err := withdrawRequest.Validate(); err != nil { - return nil, err - } - v, err := e.WithdrawFiatFunds(ctx, withdrawRequest) - if err != nil { - return nil, err - } - return &withdraw.ExchangeResponse{ - ID: v.ID, - Status: v.Status, - }, nil -} - -// GetFeeByType returns an estimate of fee based on type of transaction -func (e *Exchange) GetFeeByType(ctx context.Context, feeBuilder *exchange.FeeBuilder) (float64, error) { - if feeBuilder == nil { - return 0, fmt.Errorf("%T %w", feeBuilder, common.ErrNilPointer) - } - if !e.AreCredentialsValid(ctx) && // Todo check connection status - feeBuilder.FeeType == exchange.CryptocurrencyTradeFee { - feeBuilder.FeeType = exchange.OfflineTradeFee - } - return e.GetFee(ctx, feeBuilder) -} - -// GetActiveOrders retrieves any orders that are active/open -func (e *Exchange) GetActiveOrders(ctx context.Context, req *order.MultiOrderRequest) (order.FilteredOrders, error) { - err := req.Validate() - if err != nil { - return nil, err - } - var respOrders []GeneralizedOrderResponse - var fPair currency.Pair - for i := range req.Pairs { - fPair, err = e.FormatExchangeCurrency(req.Pairs[i], asset.Spot) - if err != nil { - return nil, err - } - - var resp []GeneralizedOrderResponse - resp, err = e.GetOrders(ctx, - []string{"open", "pending", "active"}, - fPair.String()) - if err != nil { - return nil, err - } - respOrders = append(respOrders, resp...) - } - - format, err := e.GetPairFormat(asset.Spot, false) - if err != nil { - return nil, err - } - - orders := make([]order.Detail, len(respOrders)) - for i := range respOrders { - var curr currency.Pair - curr, err = currency.NewPairDelimiter(respOrders[i].ProductID, - format.Delimiter) - if err != nil { - return nil, err - } - var side order.Side - side, err = order.StringToOrderSide(respOrders[i].Side) - if err != nil { - return nil, err - } - var orderType order.Type - orderType, err = order.StringToOrderType(respOrders[i].Type) - if err != nil { - log.Errorf(log.ExchangeSys, "%s %v", e.Name, err) - } - orders[i] = order.Detail{ - OrderID: respOrders[i].ID, - Amount: respOrders[i].Size, - ExecutedAmount: respOrders[i].FilledSize, - Type: orderType, - Date: respOrders[i].CreatedAt, - Side: side, - Pair: curr, - Exchange: e.Name, - } - } - return req.Filter(e.Name, orders), nil -} - -// GetOrderHistory retrieves account order information -// Can Limit response to specific order status -func (e *Exchange) GetOrderHistory(ctx context.Context, req *order.MultiOrderRequest) (order.FilteredOrders, error) { - err := req.Validate() - if err != nil { - return nil, err - } - var respOrders []GeneralizedOrderResponse - if len(req.Pairs) > 0 { - var fPair currency.Pair - var resp []GeneralizedOrderResponse - for i := range req.Pairs { - fPair, err = e.FormatExchangeCurrency(req.Pairs[i], asset.Spot) - if err != nil { - return nil, err - } - resp, err = e.GetOrders(ctx, []string{"done"}, fPair.String()) - if err != nil { - return nil, err - } - respOrders = append(respOrders, resp...) - } - } else { - respOrders, err = e.GetOrders(ctx, []string{"done"}, "") - if err != nil { - return nil, err - } - } - - format, err := e.GetPairFormat(asset.Spot, false) - if err != nil { - return nil, err - } - - orders := make([]order.Detail, len(respOrders)) - for i := range respOrders { - var curr currency.Pair - curr, err = currency.NewPairDelimiter(respOrders[i].ProductID, - format.Delimiter) - if err != nil { - return nil, err - } - var side order.Side - side, err = order.StringToOrderSide(respOrders[i].Side) - if err != nil { - return nil, err - } - var orderStatus order.Status - orderStatus, err = order.StringToOrderStatus(respOrders[i].Status) - if err != nil { - log.Errorf(log.ExchangeSys, "%s %v", e.Name, err) - } - var orderType order.Type - orderType, err = order.StringToOrderType(respOrders[i].Type) - if err != nil { - log.Errorf(log.ExchangeSys, "%s %v", e.Name, err) - } - detail := order.Detail{ - OrderID: respOrders[i].ID, - Amount: respOrders[i].Size, - ExecutedAmount: respOrders[i].FilledSize, - RemainingAmount: respOrders[i].Size - respOrders[i].FilledSize, - Cost: respOrders[i].ExecutedValue, - CostAsset: curr.Quote, - Type: orderType, - Date: respOrders[i].CreatedAt, - CloseTime: respOrders[i].DoneAt, - Fee: respOrders[i].FillFees, - FeeAsset: curr.Quote, - Side: side, - Status: orderStatus, - Pair: curr, - Price: respOrders[i].Price, - Exchange: e.Name, - } - detail.InferCostsAndTimes() - orders[i] = detail - } - return req.Filter(e.Name, orders), nil -} - -// GetHistoricCandles returns a set of candle between two time periods for a -// designated time period -func (e *Exchange) GetHistoricCandles(ctx context.Context, pair currency.Pair, a asset.Item, interval kline.Interval, start, end time.Time) (*kline.Item, error) { - req, err := e.GetKlineRequest(pair, a, interval, start, end, false) - if err != nil { - return nil, err - } - - history, err := e.GetHistoricRates(ctx, - req.RequestFormatted.String(), - start.Format(time.RFC3339), - end.Format(time.RFC3339), - int64(req.ExchangeInterval.Duration().Seconds())) - if err != nil { - return nil, err - } - - timeSeries := make([]kline.Candle, len(history)) - for x := range history { - timeSeries[x] = kline.Candle{ - Time: history[x].Time.Time(), - Low: history[x].Low, - High: history[x].High, - Open: history[x].Open, - Close: history[x].Close, - Volume: history[x].Volume, - } - } - return req.ProcessResponse(timeSeries) -} - -// GetHistoricCandlesExtended returns candles between a time period for a set time interval -func (e *Exchange) GetHistoricCandlesExtended(ctx context.Context, pair currency.Pair, a asset.Item, interval kline.Interval, start, end time.Time) (*kline.Item, error) { - req, err := e.GetKlineExtendedRequest(pair, a, interval, start, end) - if err != nil { - return nil, err - } - - timeSeries := make([]kline.Candle, 0, req.Size()) - for x := range req.RangeHolder.Ranges { - var history []History - history, err = e.GetHistoricRates(ctx, - req.RequestFormatted.String(), - req.RangeHolder.Ranges[x].Start.Time.Format(time.RFC3339), - req.RangeHolder.Ranges[x].End.Time.Format(time.RFC3339), - int64(req.ExchangeInterval.Duration().Seconds())) - if err != nil { - return nil, err - } - - for i := range history { - timeSeries = append(timeSeries, kline.Candle{ - Time: history[i].Time.Time(), - Low: history[i].Low, - High: history[i].High, - Open: history[i].Open, - Close: history[i].Close, - Volume: history[i].Volume, - }) - } - } - return req.ProcessResponse(timeSeries) -} - -// ValidateAPICredentials validates current credentials used for wrapper -// functionality -func (e *Exchange) ValidateAPICredentials(ctx context.Context, assetType asset.Item) error { - _, err := e.UpdateAccountInfo(ctx, assetType) - return e.CheckTransientError(err) -} - -// GetServerTime returns the current exchange server time. -func (e *Exchange) GetServerTime(ctx context.Context, _ asset.Item) (time.Time, error) { - st, err := e.GetCurrentServerTime(ctx) - if err != nil { - return time.Time{}, err - } - return st.ISO, nil -} - -// GetLatestFundingRates returns the latest funding rates data -func (e *Exchange) GetLatestFundingRates(context.Context, *fundingrate.LatestRateRequest) ([]fundingrate.LatestRateResponse, error) { - return nil, common.ErrFunctionNotSupported -} - -// GetFuturesContractDetails returns all contracts from the exchange by asset type -func (e *Exchange) GetFuturesContractDetails(context.Context, asset.Item) ([]futures.Contract, error) { - return nil, common.ErrFunctionNotSupported -} - -// UpdateOrderExecutionLimits updates order execution limits -func (e *Exchange) UpdateOrderExecutionLimits(_ context.Context, _ asset.Item) error { - return common.ErrNotYetImplemented -} - -// GetCurrencyTradeURL returns the URL to the exchange's trade page for the given asset and currency pair -func (e *Exchange) GetCurrencyTradeURL(_ context.Context, a asset.Item, cp currency.Pair) (string, error) { - _, err := e.CurrencyPairs.IsPairEnabled(cp, a) - if err != nil { - return "", err - } - cp.Delimiter = currency.DashDelimiter - return tradeBaseURL + cp.Upper().String(), nil -} diff --git a/exchanges/coinbasepro/ratelimit.go b/exchanges/coinbasepro/ratelimit.go deleted file mode 100644 index e636f666..00000000 --- a/exchanges/coinbasepro/ratelimit.go +++ /dev/null @@ -1,22 +0,0 @@ -package coinbasepro - -import ( - "time" - - "github.com/thrasher-corp/gocryptotrader/exchanges/request" -) - -// Coinbasepro rate limit conts -const ( - coinbaseproRateInterval = time.Second - coinbaseproAuthRate = 5 - coinbaseproUnauthRate = 2 -) - -// GetRateLimit returns the rate limit for the exchange -func GetRateLimit() request.RateLimitDefinitions { - return request.RateLimitDefinitions{ - request.Auth: request.NewRateLimitWithWeight(coinbaseproRateInterval, coinbaseproAuthRate, 1), - request.UnAuth: request.NewRateLimitWithWeight(coinbaseproRateInterval, coinbaseproUnauthRate, 1), - } -} diff --git a/exchanges/exchange.go b/exchanges/exchange.go index f6681e39..d25b9aab 100644 --- a/exchanges/exchange.go +++ b/exchanges/exchange.go @@ -53,8 +53,12 @@ const ( DefaultWebsocketOrderbookBufferLimit = 5 ) -// ErrSymbolCannotBeMatched returned on symbol matching failure -var ErrSymbolCannotBeMatched = errors.New("symbol cannot be matched") +// Public Errors +var ( + ErrSettingProxyAddress = errors.New("error setting proxy address") + ErrEndpointPathNotFound = errors.New("no endpoint path found for the given key") + ErrSymbolNotMatched = errors.New("symbol cannot be matched") +) var ( errEndpointStringNotFound = errors.New("endpoint string not found") @@ -81,8 +85,7 @@ func (b *Base) SetClientProxyAddress(addr string) error { } proxy, err := url.Parse(addr) if err != nil { - return fmt.Errorf("setting proxy address error %s", - err) + return fmt.Errorf("%w %w", ErrSettingProxyAddress, err) } err = b.Requester.SetProxy(proxy) @@ -204,7 +207,7 @@ func (b *Base) GetLastPairsUpdateTime() int64 { return b.CurrencyPairs.LastUpdated } -// GetAssetTypes returns the either the enabled or available asset types for an +// GetAssetTypes returns either the enabled or available asset types for an // individual exchange func (b *Base) GetAssetTypes(enabled bool) asset.Items { return b.CurrencyPairs.GetAssetTypes(enabled) @@ -251,7 +254,7 @@ func (b *Base) GetPairAndAssetTypeRequestFormatted(symbol string) (currency.Pair } } } - return currency.EMPTYPAIR, asset.Empty, ErrSymbolCannotBeMatched + return currency.EMPTYPAIR, asset.Empty, ErrSymbolNotMatched } // GetClientBankAccounts returns banking details associated with @@ -696,6 +699,10 @@ func (b *Base) UpdatePairs(incoming currency.Pairs, a asset.Item, enabled, force diff.Remove) } } + err = common.NilGuard(b.Config, b.Config.CurrencyPairs) + if err != nil { + return err + } err = b.Config.CurrencyPairs.StorePairs(a, incoming, enabled) if err != nil { return err @@ -1278,7 +1285,7 @@ func (e *Endpoints) GetURL(endpoint URL) (string, error) { defer e.mu.RUnlock() val, ok := e.defaults[endpoint.String()] if !ok { - return "", fmt.Errorf("no endpoint path found for the given key: %v", endpoint) + return "", fmt.Errorf("%w %v", ErrEndpointPathNotFound, endpoint) } return val, nil } diff --git a/exchanges/exchange_test.go b/exchanges/exchange_test.go index 0d3e3eb4..ab0a0804 100644 --- a/exchanges/exchange_test.go +++ b/exchanges/exchange_test.go @@ -1998,10 +1998,10 @@ func TestGetPairAndAssetTypeRequestFormatted(t *testing.T) { require.ErrorIs(t, err, currency.ErrCurrencyPairEmpty) _, _, err = b.GetPairAndAssetTypeRequestFormatted("BTCAUD") - require.ErrorIs(t, err, ErrSymbolCannotBeMatched) + require.ErrorIs(t, err, ErrSymbolNotMatched) _, _, err = b.GetPairAndAssetTypeRequestFormatted("BTCUSDT") - require.ErrorIs(t, err, ErrSymbolCannotBeMatched) + require.ErrorIs(t, err, ErrSymbolNotMatched) p, a, err := b.GetPairAndAssetTypeRequestFormatted("BTC-USDT") require.NoError(t, err) diff --git a/exchanges/futures/futures.go b/exchanges/futures/futures.go index 3dab2cc3..cb8d6778 100644 --- a/exchanges/futures/futures.go +++ b/exchanges/futures/futures.go @@ -245,7 +245,7 @@ func SetupMultiPositionTracker(setup *MultiPositionTrackerSetup) (*MultiPosition return nil, errNilSetup } if setup.Exchange == "" { - return nil, errExchangeNameEmpty + return nil, common.ErrExchangeNameNotSet } var err error setup.Exchange, err = checkTrackerPrerequisitesLowerExchange(setup.Exchange, setup.Asset, setup.Pair) @@ -1076,7 +1076,7 @@ func CheckFundingRatePrerequisites(getFundingData, includePredicted, includePaym // checkTrackerPrerequisitesLowerExchange is a common set of checks for futures position tracking func checkTrackerPrerequisitesLowerExchange(exch string, item asset.Item, cp currency.Pair) (string, error) { if exch == "" { - return "", errExchangeNameEmpty + return "", common.ErrExchangeNameNotSet } exch = strings.ToLower(exch) if !item.IsFutures() { diff --git a/exchanges/futures/futures_test.go b/exchanges/futures/futures_test.go index d160b3fb..f5e0c88d 100644 --- a/exchanges/futures/futures_test.go +++ b/exchanges/futures/futures_test.go @@ -88,7 +88,7 @@ func TestTrackNewOrder(t *testing.T) { assert.ErrorIs(t, err, common.ErrNilPointer) err = c.TrackNewOrder(&order.Detail{}, false) - assert.ErrorIs(t, err, errExchangeNameEmpty) + assert.ErrorIs(t, err, common.ErrExchangeNameNotSet) od := &order.Detail{ Exchange: exch, @@ -206,7 +206,7 @@ func TestSetupMultiPositionTracker(t *testing.T) { setup := &MultiPositionTrackerSetup{} _, err = SetupMultiPositionTracker(setup) - assert.ErrorIs(t, err, errExchangeNameEmpty) + assert.ErrorIs(t, err, common.ErrExchangeNameNotSet) setup.Exchange = testExchange _, err = SetupMultiPositionTracker(setup) @@ -249,7 +249,7 @@ func TestMultiPositionTrackerTrackNewOrder(t *testing.T) { ExchangePNLCalculation: &FakePNL{}, } _, err := SetupMultiPositionTracker(setup) - assert.ErrorIs(t, err, errExchangeNameEmpty) + assert.ErrorIs(t, err, common.ErrExchangeNameNotSet) setup.Exchange = testExchange resp, err := SetupMultiPositionTracker(setup) @@ -264,7 +264,7 @@ func TestMultiPositionTrackerTrackNewOrder(t *testing.T) { OrderID: "1", Amount: 1, }) - assert.ErrorIs(t, err, errExchangeNameEmpty) + assert.ErrorIs(t, err, common.ErrExchangeNameNotSet) err = resp.TrackNewOrder(&order.Detail{ Date: tt, @@ -417,7 +417,7 @@ func TestPositionControllerTestTrackNewOrder(t *testing.T) { Side: order.Long, OrderID: "lol", }) - assert.ErrorIs(t, err, errExchangeNameEmpty) + assert.ErrorIs(t, err, common.ErrExchangeNameNotSet) err = pc.TrackNewOrder(&order.Detail{ Exchange: testExchange, @@ -524,7 +524,7 @@ func TestGetPositionsForExchange(t *testing.T) { p := currency.NewBTCUSDT() _, err := c.GetPositionsForExchange("", asset.Futures, p) - assert.ErrorIs(t, err, errExchangeNameEmpty) + assert.ErrorIs(t, err, common.ErrExchangeNameNotSet) pos, err := c.GetPositionsForExchange(testExchange, asset.Futures, p) assert.ErrorIs(t, err, ErrPositionNotFound) @@ -581,7 +581,7 @@ func TestClearPositionsForExchange(t *testing.T) { c := &PositionController{} p := currency.NewBTCUSDT() err := c.ClearPositionsForExchange("", asset.Futures, p) - assert.ErrorIs(t, err, errExchangeNameEmpty) + assert.ErrorIs(t, err, common.ErrExchangeNameNotSet) err = c.ClearPositionsForExchange(testExchange, asset.Futures, p) assert.ErrorIs(t, err, ErrPositionNotFound) @@ -656,7 +656,7 @@ func TestSetupPositionTracker(t *testing.T) { p, err = SetupPositionTracker(&PositionTrackerSetup{ Asset: asset.Spot, }) - assert.ErrorIs(t, err, errExchangeNameEmpty) + assert.ErrorIs(t, err, common.ErrExchangeNameNotSet) if p != nil { t.Error("expected nil") @@ -758,7 +758,7 @@ func TestUpdateOpenPositionUnrealisedPNL(t *testing.T) { pc := SetupPositionController() _, err := pc.UpdateOpenPositionUnrealisedPNL("", asset.Futures, currency.NewBTCUSDT(), 2, time.Now()) - assert.ErrorIs(t, err, errExchangeNameEmpty) + assert.ErrorIs(t, err, common.ErrExchangeNameNotSet) _, err = pc.UpdateOpenPositionUnrealisedPNL("hi", asset.Futures, currency.NewBTCUSDT(), 2, time.Now()) assert.ErrorIs(t, err, ErrPositionNotFound) @@ -803,7 +803,7 @@ func TestSetCollateralCurrency(t *testing.T) { t.Parallel() pc := SetupPositionController() err := pc.SetCollateralCurrency("", asset.Spot, currency.EMPTYPAIR, currency.Code{}) - assert.ErrorIs(t, err, errExchangeNameEmpty) + assert.ErrorIs(t, err, common.ErrExchangeNameNotSet) err = pc.SetCollateralCurrency("hi", asset.Spot, currency.EMPTYPAIR, currency.Code{}) assert.ErrorIs(t, err, ErrNotFuturesAsset) @@ -905,7 +905,7 @@ func TestMPTLiquidate(t *testing.T) { Asset: item, } _, err = SetupPositionTracker(setup) - assert.ErrorIs(t, err, errExchangeNameEmpty) + assert.ErrorIs(t, err, common.ErrExchangeNameNotSet) setup.Exchange = "exch" _, err = SetupPositionTracker(setup) @@ -995,7 +995,7 @@ func TestGetOpenPosition(t *testing.T) { tn := time.Now() _, err := pc.GetOpenPosition("", asset.Futures, cp) - assert.ErrorIs(t, err, errExchangeNameEmpty) + assert.ErrorIs(t, err, common.ErrExchangeNameNotSet) _, err = pc.GetOpenPosition(testExchange, asset.Futures, cp) assert.ErrorIs(t, err, ErrPositionNotFound) @@ -1053,7 +1053,7 @@ func TestPCTrackFundingDetails(t *testing.T) { Pair: p, } err = pc.TrackFundingDetails(rates) - assert.ErrorIs(t, err, errExchangeNameEmpty) + assert.ErrorIs(t, err, common.ErrExchangeNameNotSet) rates.Exchange = testExchange err = pc.TrackFundingDetails(rates) @@ -1104,7 +1104,7 @@ func TestMPTTrackFundingDetails(t *testing.T) { Pair: cp, } err = mpt.TrackFundingDetails(rates) - assert.ErrorIs(t, err, errExchangeNameEmpty) + assert.ErrorIs(t, err, common.ErrExchangeNameNotSet) mpt.exchange = testExchange rates = &fundingrate.HistoricalRates{ @@ -1206,7 +1206,7 @@ func TestPTTrackFundingDetails(t *testing.T) { rates.Exchange = "" err = p.TrackFundingDetails(rates) - assert.ErrorIs(t, err, errExchangeNameEmpty) + assert.ErrorIs(t, err, common.ErrExchangeNameNotSet) p = nil err = p.TrackFundingDetails(rates) @@ -1278,7 +1278,7 @@ func TestGetCurrencyForRealisedPNL(t *testing.T) { func TestCheckTrackerPrerequisitesLowerExchange(t *testing.T) { t.Parallel() _, err := checkTrackerPrerequisitesLowerExchange("", asset.Spot, currency.EMPTYPAIR) - assert.ErrorIs(t, err, errExchangeNameEmpty) + assert.ErrorIs(t, err, common.ErrExchangeNameNotSet) upperExch := "IM UPPERCASE" _, err = checkTrackerPrerequisitesLowerExchange(upperExch, asset.Spot, currency.EMPTYPAIR) diff --git a/exchanges/futures/futures_types.go b/exchanges/futures/futures_types.go index 2c751dcb..a682dabb 100644 --- a/exchanges/futures/futures_types.go +++ b/exchanges/futures/futures_types.go @@ -42,7 +42,6 @@ var ( // ErrOrderHistoryTooLarge is returned when you lookup order history, but with too early a start date ErrOrderHistoryTooLarge = errors.New("order history start date too long ago") - errExchangeNameEmpty = errors.New("exchange name empty") errExchangeNameMismatch = errors.New("exchange name mismatch") errTimeUnset = errors.New("time unset") errMissingPNLCalculationFunctions = errors.New("futures tracker requires exchange PNL calculation functions") diff --git a/exchanges/order/order_test.go b/exchanges/order/order_test.go index 44859463..8abd5940 100644 --- a/exchanges/order/order_test.go +++ b/exchanges/order/order_test.go @@ -38,7 +38,7 @@ func TestSubmitValidate(t *testing.T) { Submit: nil, }, // nil struct { - ExpectedErr: errExchangeNameUnset, + ExpectedErr: common.ErrExchangeNameNotSet, Submit: &Submit{}, }, // empty exchange { @@ -831,7 +831,7 @@ func TestStringToOrderType(t *testing.T) { {"mMp", MarketMakerProtection, nil}, {"tWaP", TWAP, nil}, {"TWAP", TWAP, nil}, - {"woahMan", UnknownType, errUnrecognisedOrderType}, + {"woahMan", UnknownType, ErrUnrecognisedOrderType}, {"chase", Chase, nil}, {"MOVE_ORDER_STOP", TrailingStop, nil}, {"mOVe_OrdeR_StoP", TrailingStop, nil}, @@ -842,6 +842,8 @@ func TestStringToOrderType(t *testing.T) { {"Take ProfIt", TakeProfit, nil}, {"TAKE PROFIT MARkEt", TakeProfitMarket, nil}, {"TAKE_PROFIT_MARkEt", TakeProfitMarket, nil}, + {"brAcket", Bracket, nil}, + {"TRIGGER_bracket", Bracket, nil}, {"optimal_limit", OptimalLimit, nil}, {"OPTIMAL_LIMIT", OptimalLimit, nil}, } @@ -1135,7 +1137,7 @@ func TestValidationOnOrderTypes(t *testing.T) { getOrders.Side = AnySide err = getOrders.Validate() - require.ErrorIs(t, err, errUnrecognisedOrderType) + require.ErrorIs(t, err, ErrUnrecognisedOrderType) errTestError := errors.New("test error") getOrders.Type = AnyType @@ -1743,6 +1745,6 @@ func TestMarshalOrder(t *testing.T) { } j, err := json.Marshal(orderSubmit) require.NoError(t, err, "Marshal must not error") - exp := []byte(`{"Exchange":"test","Type":4,"Side":"BUY","Pair":"BTC-USDT","AssetType":"spot","TimeInForce":"","ReduceOnly":false,"Leverage":0,"Price":1000,"Amount":1,"QuoteAmount":0,"TriggerPrice":0,"TriggerPriceType":0,"ClientID":"","ClientOrderID":"","AutoBorrow":false,"MarginType":"multi","RetrieveFees":false,"RetrieveFeeDelay":0,"RiskManagementModes":{"Mode":"","TakeProfit":{"Enabled":false,"TriggerPriceType":0,"Price":0,"LimitPrice":0,"OrderType":0},"StopLoss":{"Enabled":false,"TriggerPriceType":0,"Price":0,"LimitPrice":0,"OrderType":0},"StopEntry":{"Enabled":false,"TriggerPriceType":0,"Price":0,"LimitPrice":0,"OrderType":0}},"Hidden":false,"Iceberg":false,"TrackingMode":0,"TrackingValue":0}`) + exp := []byte(`{"Exchange":"test","Type":4,"Side":"BUY","Pair":"BTC-USDT","AssetType":"spot","TimeInForce":"","ReduceOnly":false,"Leverage":0,"Price":1000,"Amount":1,"QuoteAmount":0,"TriggerPrice":0,"TriggerPriceType":0,"ClientID":"","ClientOrderID":"","AutoBorrow":false,"MarginType":"multi","RetrieveFees":false,"RetrieveFeeDelay":0,"RiskManagementModes":{"Mode":"","TakeProfit":{"Enabled":false,"TriggerPriceType":0,"Price":0,"LimitPrice":0,"OrderType":0},"StopLoss":{"Enabled":false,"TriggerPriceType":0,"Price":0,"LimitPrice":0,"OrderType":0},"StopEntry":{"Enabled":false,"TriggerPriceType":0,"Price":0,"LimitPrice":0,"OrderType":0}},"Hidden":false,"Iceberg":false,"EndTime":"0001-01-01T00:00:00Z","StopDirection":false,"TrackingMode":0,"TrackingValue":0,"RFQDisabled":false}`) assert.Equal(t, exp, j) } diff --git a/exchanges/order/order_types.go b/exchanges/order/order_types.go index e565c5c6..2bd34464 100644 --- a/exchanges/order/order_types.go +++ b/exchanges/order/order_types.go @@ -28,6 +28,7 @@ var ( ErrSubmitLeverageNotSupported = errors.New("leverage is not supported via order submission") ErrClientOrderIDNotSupported = errors.New("client order id not supported") ErrUnsupportedOrderType = errors.New("unsupported order type") + ErrUnsupportedStatusType = errors.New("unsupported status type") // ErrNoRates is returned when no margin rates are returned when they are expected ErrNoRates = errors.New("no rates") ErrCannotLiquidate = errors.New("cannot liquidate position") @@ -93,10 +94,18 @@ type Submit struct { // Iceberg specifies whether or not only visible portions of orders are shown in iceberg orders Iceberg bool + // EndTime is the moment which a good til date order is valid until + EndTime time.Time + + // StopDirection is the direction from which the stop order will trigger + StopDirection StopDirection // TrackingMode specifies the way trailing stop and chase orders follow the market price or ask/bid prices. // See: https://www.okx.com/docs-v5/en/#order-book-trading-algo-trading-post-place-algo-order TrackingMode TrackingMode TrackingValue float64 + + // RFQDisabled, when set, attempts to route the order to the exchange CLOB. Currently only supported by Coinbase + RFQDisabled bool } // SubmitResponse is what is returned after submitting an order to an exchange @@ -375,6 +384,7 @@ const ( StopLimit = Stop | Limit StopMarket = Stop | Market TakeProfitMarket = TakeProfit | Market + Bracket = Stop | TakeProfit ) // order-type string representations @@ -396,9 +406,31 @@ const ( orderOCO = "OCO" orderOptimalLimit = "OPTIMAL_LIMIT" orderMarketMakerProtection = "MMP" + orderBracket = "BRACKET" orderAnyType = "ANY" ) +// AllOrderTypes collects all order types for easy and consistent comparisons +var AllOrderTypes = Limit | + Market | + Stop | + StopLimit | + StopMarket | + TakeProfit | + TakeProfitMarket | + TrailingStop | + IOS | + AnyType | + Liquidation | + Trigger | + OCO | + ConditionalStop | + TWAP | + Chase | + OptimalLimit | + MarketMakerProtection | + Bracket + // Side enforces a standard for order sides across the code base type Side uint32 @@ -453,6 +485,17 @@ type ClassificationError struct { // MultiOrderRequest. type FilteredOrders []Detail +// StopDirection is the direction from which the stop order will trigger; Up will have the order trigger +// when the last trade price goes above the TriggerPrice; Down will have the order trigger when the +// last trade price goes below the TriggerPrice +type StopDirection bool + +// StopDirection types +const ( + StopUp StopDirection = true + StopDown StopDirection = false +) + // RiskManagement represents a risk management detail information. type RiskManagement struct { Enabled bool diff --git a/exchanges/order/orders.go b/exchanges/order/orders.go index 550d95bb..6382dacf 100644 --- a/exchanges/order/orders.go +++ b/exchanges/order/orders.go @@ -40,12 +40,11 @@ var ( ErrAmountMustBeSet = errors.New("amount must be set") ErrClientOrderIDMustBeSet = errors.New("client order ID must be set") ErrUnknownSubmissionAmountType = errors.New("unknown submission amount type") + ErrUnrecognisedOrderType = errors.New("unrecognised order type") ) var ( - errUnrecognisedOrderType = errors.New("unrecognised order type") errUnrecognisedOrderStatus = errors.New("unrecognised order status") - errExchangeNameUnset = errors.New("exchange name unset") errOrderSubmitIsNil = errors.New("order submit is nil") errOrderSubmitResponseIsNil = errors.New("order submit response is nil") errOrderDetailIsNil = errors.New("order detail is nil") @@ -64,7 +63,7 @@ func (s *Submit) Validate(requirements protocol.TradingRequirements, opt ...vali } if s.Exchange == "" { - return errExchangeNameUnset + return common.ErrExchangeNameNotSet } if s.Pair.IsEmpty() { @@ -83,7 +82,7 @@ func (s *Submit) Validate(requirements protocol.TradingRequirements, opt ...vali return fmt.Errorf("%w %v", ErrSideIsInvalid, s.Side) } - if s.Type != Market && s.Type != Limit { + if AllOrderTypes&s.Type != s.Type || s.Type == UnknownType { return ErrTypeIsInvalid } @@ -697,6 +696,8 @@ func (t Type) String() string { return orderTrigger case OCO: return orderOCO + case Bracket: + return orderBracket case OptimalLimit: return orderOptimalLimit case MarketMakerProtection: @@ -1136,8 +1137,10 @@ func StringToOrderType(oType string) (Type, error) { return TakeProfit, nil case orderLiquidation: return Liquidation, nil + case orderBracket, "TRIGGER_BRACKET": + return Bracket, nil default: - return UnknownType, fmt.Errorf("'%v' %w", oType, errUnrecognisedOrderType) + return UnknownType, fmt.Errorf("'%v' %w", oType, ErrUnrecognisedOrderType) } } @@ -1263,7 +1266,7 @@ func (g *MultiOrderRequest) Validate(opt ...validate.Checker) error { } if g.Type == UnknownType { - return errUnrecognisedOrderType + return ErrUnrecognisedOrderType } var errs error diff --git a/exchanges/order/timeinforce.go b/exchanges/order/timeinforce.go index 7ceda728..9cfd449a 100644 --- a/exchanges/order/timeinforce.go +++ b/exchanges/order/timeinforce.go @@ -13,7 +13,7 @@ var ( ) // TimeInForce enforces a standard for time-in-force values across the code base. -type TimeInForce uint8 +type TimeInForce uint16 // TimeInForce types const ( @@ -25,8 +25,9 @@ const ( FillOrKill ImmediateOrCancel PostOnly + StopOrReduce - supportedTimeInForceFlag = GoodTillCancel | GoodTillDay | GoodTillTime | GoodTillCrossing | FillOrKill | ImmediateOrCancel | PostOnly + supportedTimeInForceFlag = GoodTillCancel | GoodTillDay | GoodTillTime | GoodTillCrossing | FillOrKill | ImmediateOrCancel | PostOnly | StopOrReduce ) // time-in-force string representations @@ -38,6 +39,7 @@ const ( fokStr = "FOK" iocStr = "IOC" postonlyStr = "POSTONLY" + sorStr = "SOR" ) // Is checks to see if the enum contains the flag @@ -64,6 +66,8 @@ func StringToTimeInForce(timeInForce string) (TimeInForce, error) { result = FillOrKill case "POC", "POST_ONLY", "PENDINGORCANCEL", postonlyStr: result = PostOnly + case "STOPORREDUCE", "STOP_OR_REDUCE", sorStr: + result = StopOrReduce } if result == UnknownTIF && timeInForce != "" { return UnknownTIF, fmt.Errorf("%w: tif=%s", ErrInvalidTimeInForce, timeInForce) @@ -111,6 +115,9 @@ func (t TimeInForce) String() string { if t.Is(PostOnly) { tifStrings = append(tifStrings, postonlyStr) } + if t.Is(StopOrReduce) { + tifStrings = append(tifStrings, sorStr) + } if len(tifStrings) == 0 { return "UNKNOWN" } diff --git a/exchanges/order/timeinforce_test.go b/exchanges/order/timeinforce_test.go index 155bab6c..b3c22e50 100644 --- a/exchanges/order/timeinforce_test.go +++ b/exchanges/order/timeinforce_test.go @@ -22,6 +22,7 @@ func TestTimeInForceIs(t *testing.T) { FillOrKill: {FillOrKill}, PostOnly: {PostOnly}, GoodTillCrossing: {GoodTillCrossing}, + StopOrReduce: {StopOrReduce}, } for tif, exps := range tifValuesMap { for _, v := range exps { @@ -49,6 +50,7 @@ func TestIsValid(t *testing.T) { GoodTillDay | PostOnly: true, GoodTillCrossing | PostOnly: true, GoodTillCancel | PostOnly: true, + StopOrReduce: true, UnknownTIF: true, } for tif, value := range timeInForceValidityMap { @@ -80,6 +82,8 @@ var timeInForceStringToValueMap = map[string]struct { "GTX": {TIF: GoodTillCrossing}, "GOOD_TILL_CROSSING": {TIF: GoodTillCrossing}, "Good Til crossing": {TIF: GoodTillCrossing}, + "sor": {TIF: StopOrReduce}, + "STOP_OR_REDUCE": {TIF: StopOrReduce}, "abcdfeg": {TIF: UnknownTIF, Error: ErrInvalidTimeInForce}, } @@ -113,6 +117,7 @@ func TestString(t *testing.T) { GoodTillTime | PostOnly: "GTT,POSTONLY", GoodTillDay | PostOnly: "GTD,POSTONLY", FillOrKill | ImmediateOrCancel: "IOC,FOK", + StopOrReduce: "SOR", TimeInForce(1): "UNKNOWN", } for x := range valMap { @@ -125,9 +130,9 @@ func TestUnmarshalJSON(t *testing.T) { t.Parallel() targets := []TimeInForce{ GoodTillCancel | PostOnly | ImmediateOrCancel, GoodTillCancel | PostOnly, GoodTillCancel, UnknownTIF, PostOnly | ImmediateOrCancel, - GoodTillCancel, GoodTillCancel, PostOnly, PostOnly, ImmediateOrCancel, GoodTillDay, GoodTillDay, GoodTillTime, FillOrKill, FillOrKill, + GoodTillCancel, GoodTillCancel, PostOnly, PostOnly, ImmediateOrCancel, GoodTillDay, GoodTillDay, GoodTillTime, FillOrKill, FillOrKill, StopOrReduce, } - data := []byte(`{"tifs": ["GTC,POSTONLY,IOC", "GTC,POSTONLY", "GTC", "", "POSTONLY,IOC", "GoodTilCancel", "GoodTILLCANCEL", "POST_ONLY", "POC","IOC", "GTD", "gtd","gtt", "fok", "fillOrKill"]}`) + data := []byte(`{"tifs": ["GTC,POSTONLY,IOC", "GTC,POSTONLY", "GTC", "", "POSTONLY,IOC", "GoodTilCancel", "GoodTILLCANCEL", "POST_ONLY", "POC","IOC", "GTD", "gtd","gtt", "fok", "fillOrKill", "SOR"]}`) target := &struct { TIFs []TimeInForce `json:"tifs"` }{} @@ -135,7 +140,7 @@ func TestUnmarshalJSON(t *testing.T) { require.NoError(t, err) require.Equal(t, targets, target.TIFs) - data = []byte(`{"tifs": ["abcd,POSTONLY,IOC", "GTC,POSTONLY", "GTC", "", "POSTONLY,IOC", "GoodTilCancel", "GoodTILLCANCEL", "POST_ONLY", "POC","IOC", "GTD", "gtd","gtt", "fok", "fillOrKill"]}`) + data = []byte(`{"tifs": ["abcd,POSTONLY,IOC", "GTC,POSTONLY", "GTC", "", "POSTONLY,IOC", "GoodTilCancel", "GoodTILLCANCEL", "POST_ONLY", "POC","IOC", "GTD", "gtd","gtt", "fok", "fillOrKill", "SOR"]}`) target = &struct { TIFs []TimeInForce `json:"tifs"` }{} diff --git a/exchanges/orderbook/orderbook.go b/exchanges/orderbook/orderbook.go index bc57161a..c8315daa 100644 --- a/exchanges/orderbook/orderbook.go +++ b/exchanges/orderbook/orderbook.go @@ -5,6 +5,7 @@ import ( "sort" "time" + "github.com/thrasher-corp/gocryptotrader/common" "github.com/thrasher-corp/gocryptotrader/common/key" "github.com/thrasher-corp/gocryptotrader/currency" "github.com/thrasher-corp/gocryptotrader/dispatch" @@ -80,7 +81,7 @@ func (s *store) track(b *Book) (book, error) { // DeployDepth used for subsystem deployment creates a depth item in the struct then returns a ptr to that Depth item func (s *store) DeployDepth(exchange string, p currency.Pair, a asset.Item) (*Depth, error) { if exchange == "" { - return nil, ErrExchangeNameEmpty + return nil, common.ErrExchangeNameNotSet } if p.IsEmpty() { return nil, errPairNotSet @@ -157,7 +158,7 @@ func (b *Book) TotalAsksAmount() (amountCollated, total float64) { // list func (b *Book) Process() error { if b.Exchange == "" { - return ErrExchangeNameEmpty + return common.ErrExchangeNameNotSet } if b.Pair.IsEmpty() { diff --git a/exchanges/orderbook/orderbook_test.go b/exchanges/orderbook/orderbook_test.go index b832c29d..4a079caa 100644 --- a/exchanges/orderbook/orderbook_test.go +++ b/exchanges/orderbook/orderbook_test.go @@ -12,6 +12,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/thrasher-corp/gocryptotrader/common" "github.com/thrasher-corp/gocryptotrader/currency" "github.com/thrasher-corp/gocryptotrader/dispatch" "github.com/thrasher-corp/gocryptotrader/exchanges/asset" @@ -223,7 +224,7 @@ func TestBookGetDepth(t *testing.T) { func TestDeployDepth(t *testing.T) { pair := currency.NewBTCUSD() _, err := DeployDepth("", pair, asset.Spot) - require.ErrorIs(t, err, ErrExchangeNameEmpty) + require.ErrorIs(t, err, common.ErrExchangeNameNotSet) _, err = DeployDepth("test", currency.EMPTYPAIR, asset.Spot) require.ErrorIs(t, err, errPairNotSet) _, err = DeployDepth("test", pair, asset.Empty) diff --git a/exchanges/subscription/template_test.go b/exchanges/subscription/template_test.go index 28e5bad6..86d41eab 100644 --- a/exchanges/subscription/template_test.go +++ b/exchanges/subscription/template_test.go @@ -102,7 +102,7 @@ func TestExpandTemplates(t *testing.T) { equalLists(t, exp, got) // Users can specify pairs which aren't available, even across diverse assets - // Use-case: Coinbasepro user sub for futures BTC-USD would return all BTC pairs and all USD pairs, even though BTC-USD might not be enabled or available + // Use-case: Coinbase user sub for futures BTC-USD would return all BTC pairs and all USD pairs, even though BTC-USD might not be enabled or available p := currency.Pairs{currency.NewPairWithDelimiter("BEAR", "PEAR", "🐻")} got, err = List{{Channel: "expand-pairs", Asset: asset.All, Pairs: p}}.ExpandTemplates(e) require.NoError(t, err, "Must not error with fictional pairs") diff --git a/exchanges/support.go b/exchanges/support.go index 415bf1dc..643dfd48 100644 --- a/exchanges/support.go +++ b/exchanges/support.go @@ -24,7 +24,7 @@ var Exchanges = []string{ "btc markets", "btse", "bybit", - "coinbasepro", + "coinbase", "coinut", "deribit", "exmo", diff --git a/exchanges/ticker/ticker.go b/exchanges/ticker/ticker.go index d1d53bbe..4edd6c8a 100644 --- a/exchanges/ticker/ticker.go +++ b/exchanges/ticker/ticker.go @@ -7,6 +7,7 @@ import ( "time" "github.com/gofrs/uuid" + "github.com/thrasher-corp/gocryptotrader/common" "github.com/thrasher-corp/gocryptotrader/common/key" "github.com/thrasher-corp/gocryptotrader/currency" "github.com/thrasher-corp/gocryptotrader/dispatch" @@ -15,9 +16,8 @@ import ( // Public errors var ( - ErrTickerNotFound = errors.New("no ticker found") - ErrBidEqualsAsk = errors.New("bid equals ask this is a crossed or locked market") - ErrExchangeNameIsEmpty = errors.New("exchange name is empty") + ErrTickerNotFound = errors.New("no ticker found") + ErrBidEqualsAsk = errors.New("bid equals ask this is a crossed or locked market") ) var ( @@ -66,7 +66,7 @@ func SubscribeToExchangeTickers(exchange string) (dispatch.Pipe, error) { // GetTicker checks and returns a requested ticker if it exists func GetTicker(exchange string, p currency.Pair, a asset.Item) (*Price, error) { if exchange == "" { - return nil, ErrExchangeNameIsEmpty + return nil, common.ErrExchangeNameNotSet } if p.IsEmpty() { return nil, currency.ErrCurrencyPairEmpty @@ -93,7 +93,7 @@ func GetExchangeTickers(exchange string) ([]*Price, error) { func (s *Service) getExchangeTickers(exchange string) ([]*Price, error) { if exchange == "" { - return nil, ErrExchangeNameIsEmpty + return nil, common.ErrExchangeNameNotSet } exchange = strings.ToLower(exchange) s.mu.Lock() @@ -136,7 +136,7 @@ func ProcessTicker(p *Price) error { } if p.ExchangeName == "" { - return ErrExchangeNameIsEmpty + return common.ErrExchangeNameNotSet } if p.Pair.IsEmpty() { @@ -223,7 +223,7 @@ func (s *Service) setItemID(t *Ticker, p *Price, exch string) error { // getAssociations links a singular book with its dispatch associations func (s *Service) getAssociations(exch string) ([]uuid.UUID, error) { if exch == "" { - return nil, ErrExchangeNameIsEmpty + return nil, common.ErrExchangeNameNotSet } var ids []uuid.UUID exchangeID, ok := s.Exchange[exch] diff --git a/exchanges/ticker/ticker_test.go b/exchanges/ticker/ticker_test.go index 3dcf8fa2..f8b347bc 100644 --- a/exchanges/ticker/ticker_test.go +++ b/exchanges/ticker/ticker_test.go @@ -12,6 +12,7 @@ import ( "github.com/gofrs/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/thrasher-corp/gocryptotrader/common" "github.com/thrasher-corp/gocryptotrader/common/key" "github.com/thrasher-corp/gocryptotrader/currency" "github.com/thrasher-corp/gocryptotrader/dispatch" @@ -423,7 +424,7 @@ func TestProcessTicker(t *testing.T) { // non-appending function to tickers func TestGetAssociation(t *testing.T) { _, err := service.getAssociations("") - assert.ErrorIs(t, err, ErrExchangeNameIsEmpty) + assert.ErrorIs(t, err, common.ErrExchangeNameNotSet) service.mux = nil @@ -437,7 +438,7 @@ func TestGetAssociation(t *testing.T) { func TestGetExchangeTickersPublic(t *testing.T) { _, err := GetExchangeTickers("") - assert.ErrorIs(t, err, ErrExchangeNameIsEmpty) + assert.ErrorIs(t, err, common.ErrExchangeNameNotSet) } func TestGetExchangeTickers(t *testing.T) { @@ -448,7 +449,7 @@ func TestGetExchangeTickers(t *testing.T) { } _, err := s.getExchangeTickers("") - assert.ErrorIs(t, err, ErrExchangeNameIsEmpty) + assert.ErrorIs(t, err, common.ErrExchangeNameNotSet) _, err = s.getExchangeTickers("test") assert.ErrorIs(t, err, errExchangeNotFound) diff --git a/exchanges/trade/README.md b/exchanges/trade/README.md index 46bd45fe..482ae6e5 100644 --- a/exchanges/trade/README.md +++ b/exchanges/trade/README.md @@ -69,7 +69,7 @@ _b in this context is an `IBotExchange` implemented struct_ | BTCMarkets | Yes | Yes | No | | BTSE | Yes | Yes | No | | Bybit | Yes | Yes | Yes | -| CoinbasePro | Yes | Yes | No| +| Coinbase | Yes | Yes | No| | COINUT | Yes | Yes | No | | Deribit | Yes | Yes | Yes | | Exmo | Yes | NA | No | diff --git a/gctscript/examples/exchange/ohlcv.gct b/gctscript/examples/exchange/ohlcv.gct index 281d5954..e7728b44 100644 --- a/gctscript/examples/exchange/ohlcv.gct +++ b/gctscript/examples/exchange/ohlcv.gct @@ -6,7 +6,7 @@ load := func() { start := t.add(t.now(), -t.hour*24) // 'ctx' is already defined when we construct our bytecode from file. // To add debugging information to the request, see verbose.gct. To add account credentials, see account.gct - ohlcvData := exch.ohlcv(ctx, "coinbasepro", "BTC-USD", "-", "SPOT", start, t.now(), "1h") + ohlcvData := exch.ohlcv(ctx, "coinbase", "BTC-USD", "-", "SPOT", start, t.now(), "1h") if is_error(ohlcvData) { // handle error } diff --git a/portfolio/withdraw/validate.go b/portfolio/withdraw/validate.go index 297cfe8b..a39c7a8d 100644 --- a/portfolio/withdraw/validate.go +++ b/portfolio/withdraw/validate.go @@ -4,6 +4,7 @@ import ( "errors" "strings" + "github.com/thrasher-corp/gocryptotrader/common" "github.com/thrasher-corp/gocryptotrader/currency" "github.com/thrasher-corp/gocryptotrader/exchanges/validate" ) @@ -15,7 +16,7 @@ func (r *Request) Validate(opt ...validate.Checker) (err error) { } if r.Exchange == "" { - return ErrExchangeNameUnset + return common.ErrExchangeNameNotSet } var allErrors []string diff --git a/portfolio/withdraw/validate_test.go b/portfolio/withdraw/validate_test.go index 46fc4271..b787ef0a 100644 --- a/portfolio/withdraw/validate_test.go +++ b/portfolio/withdraw/validate_test.go @@ -6,6 +6,7 @@ import ( "os" "testing" + "github.com/thrasher-corp/gocryptotrader/common" "github.com/thrasher-corp/gocryptotrader/core" "github.com/thrasher-corp/gocryptotrader/currency" "github.com/thrasher-corp/gocryptotrader/exchanges/validate" @@ -151,7 +152,7 @@ func TestExchangeNameUnset(t *testing.T) { r := Request{} err := r.Validate() if err != nil { - if err != ErrExchangeNameUnset { + if err != common.ErrExchangeNameNotSet { t.Fatal(err) } } diff --git a/portfolio/withdraw/withdraw_types.go b/portfolio/withdraw/withdraw_types.go index 2dc5d1a7..8a4c71c9 100644 --- a/portfolio/withdraw/withdraw_types.go +++ b/portfolio/withdraw/withdraw_types.go @@ -40,8 +40,6 @@ const ( var ( // ErrRequestCannotBeNil message to return when a request is nil ErrRequestCannotBeNil = errors.New("request cannot be nil") - // ErrExchangeNameUnset message to return when an exchange name is unset - ErrExchangeNameUnset = errors.New("exchange name unset") // ErrInvalidRequest message to return when a request type is invalid ErrInvalidRequest = errors.New("invalid request type") // ErrStrAddressNotWhiteListed occurs when a withdrawal attempts to withdraw from a non-whitelisted address @@ -83,6 +81,27 @@ type FiatRequest struct { WireCurrency string } +// TravelAddress holds the address information required for travel rule compliance +type TravelAddress struct { + Address1 string + Address2 string + Address3 string + City string + State string + Country string + PostalCode string +} + +// TravelRule stores the information that may need to be provided to comply with local regulations +type TravelRule struct { + BeneficiaryWalletType string + IsSelf bool + BeneficiaryName string + BeneficiaryAddress TravelAddress + BeneficiaryFinancialInstitution string + TransferPurpose string +} + // Request holds complete details for request type Request struct { Exchange string `json:"exchange"` @@ -91,9 +110,10 @@ type Request struct { Amount float64 `json:"amount"` Type RequestType `json:"type"` - // Used exclusively in Binance.US ClientOrderID string `json:"clientID"` + WalletID string `json:"walletID"` + // Used exclusively in OKX to classify internal represented by '3' or on chain represented by '4' InternalTransfer bool @@ -103,6 +123,10 @@ type Request struct { Crypto CryptoRequest `json:"crypto"` Fiat FiatRequest `json:"fiat"` + + Travel TravelRule `json:"travel_rule"` + + IdempotencyToken string } // Response holds complete details for Response diff --git a/testdata/configtest.json b/testdata/configtest.json index 07d652dd..fa140786 100644 --- a/testdata/configtest.json +++ b/testdata/configtest.json @@ -1266,7 +1266,7 @@ ] }, { - "name": "CoinbasePro", + "name": "Coinbase", "enabled": true, "verbose": false, "httpTimeout": 15000000000, @@ -1286,12 +1286,17 @@ }, "useGlobalFormat": true, "assetTypes": [ - "spot" + "spot", + "futures" ], "pairs": { "spot": { "enabled": "BTC-USD", "available": "LTC-GBP,XLM-BTC,DASH-BTC,DAI-USDC,ZEC-USDC,XLM-EUR,ZRX-BTC,LTC-BTC,ETC-BTC,ETH-USD,XRP-EUR,BTC-USDC,REP-USD,EOS-BTC,ZEC-BTC,ETC-GBP,LINK-ETH,XRP-BTC,ZRX-USD,ETH-USDC,MANA-USDC,BTC-EUR,BCH-GBP,DNT-USDC,EOS-EUR,BCH-EUR,LTC-EUR,CVC-USDC,ETH-GBP,DASH-USD,ETH-EUR,XTZ-BTC,ZRX-EUR,BAT-ETH,BTC-GBP,ETC-USD,BAT-USDC,BCH-USD,GNT-USDC,ALGO-USD,LINK-USD,XLM-USD,ETH-BTC,EOS-USD,REP-BTC,ETH-DAI,XRP-USD,LTC-USD,ETC-EUR,BTC-USD,XTZ-USD,BCH-BTC,LOOM-USDC" + }, + "futures": { + "enabled": "BTC-USD", + "available": "BTC-USD,BTC-PERP-INTX" } } }, diff --git a/testdata/exchangelist.csv b/testdata/exchangelist.csv index 6307d756..a35773ef 100644 --- a/testdata/exchangelist.csv +++ b/testdata/exchangelist.csv @@ -8,7 +8,7 @@ bitstamp, btc markets, btse, bybit, -coinbasepro, +coinbase, coinut, deribit, exmo,