diff --git a/cmd/markets.go b/cmd/markets.go index a7af4dd..c0e0fcb 100644 --- a/cmd/markets.go +++ b/cmd/markets.go @@ -90,4 +90,18 @@ var marketsConfig = map[string]pnl.Market{ MarketLogo: "/assets/img/" + trading212 + ".png", Init: markets.NewTrading212API, }, + coinbase: { + IsSet: os.Getenv(PREFIX+strings.ToUpper(coinbase)+MARKET_SUFIX) != "", + Env: PREFIX + strings.ToUpper(coinbase) + MARKET_SUFIX, + MarketName: "Coinbase", + MarketKey: coinbase + "_trader", + Details: "", + Color: "#00AAE4", + MarketTradingSymbols: []string{"USD", "EUR"}, + Type: pnl.Exchange, + Token: os.Getenv(PREFIX + strings.ToUpper(coinbase) + MARKET_SUFIX), + ProviderURL: "https://docs.cdp.coinbase.com/coinbase-app/authentication-authorization/api-key-authentication", + MarketLogo: "/assets/img/" + coinbase + ".png", + Init: markets.NewCoinbaseAPI, + }, } diff --git a/go.mod b/go.mod index 8e2d130..0f7da88 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.24 require ( github.com/gofiber/fiber/v2 v2.48.0 github.com/gofiber/template/html/v2 v2.0.5 + github.com/golang-jwt/jwt/v4 v4.5.2 github.com/google/uuid v1.6.0 github.com/mattn/go-sqlite3 v1.14.17 ) diff --git a/go.sum b/go.sum index ecb4f45..fbd7d41 100644 --- a/go.sum +++ b/go.sum @@ -8,6 +8,8 @@ github.com/gofiber/template/html/v2 v2.0.5 h1:BKLJ6Qr940NjntbGmpO3zVa4nFNGDCi/If github.com/gofiber/template/html/v2 v2.0.5/go.mod h1:RCF14eLeQDCSUPp0IGc2wbSSDv6yt+V54XB/+Unz+LM= github.com/gofiber/utils v1.1.0 h1:vdEBpn7AzIUJRhe+CiTOJdUcTg4Q9RK+pEa0KPbLdrM= github.com/gofiber/utils v1.1.0/go.mod h1:poZpsnhBykfnY1Mc0KeEa6mSHrS3dV0+oBWyeQmb2e0= +github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= +github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/klauspost/compress v1.16.3 h1:XuJt9zzcnaz6a16/OU53ZjWp/v7/42WcR5t2a0PcNQY= diff --git a/src/gateways/markets/coinbaseAPI.go b/src/gateways/markets/coinbaseAPI.go new file mode 100644 index 0000000..a465071 --- /dev/null +++ b/src/gateways/markets/coinbaseAPI.go @@ -0,0 +1,362 @@ +package markets + +import ( + "controtto/src/domain/pnl" + "encoding/json" + "encoding/pem" + "errors" + "fmt" + "io" + "log" + "math" + "net/http" + "strings" + "time" + + "github.com/golang-jwt/jwt/v4" +) + +const COINBASE_HOST = "api.coinbase.com" + +type CoinbaseMarketAPI struct { + ApiKey string + ApiSecret string +} + +type accountsResponse struct { + Accounts []struct { + UUID string `json:"uuid"` + Name string `json:"name"` + Currency string `json:"currency"` + AvailableBalance struct { + Value string `json:"value"` + Currency string `json:"currency"` + } `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 struct { + Value string `json:"value"` + Currency string `json:"currency"` + } `json:"hold"` + RetailPortfolioID string `json:"retail_portfolio_id"` + Platform string `json:"platform"` + } `json:"accounts"` +} + +type transactionResponse struct { + Pagination struct { + EndingBefore string `json:"ending_before"` + StartingAfter string `json:"starting_after"` + Limit int `json:"limit"` + Order string `json:"order"` + PreviousURI string `json:"previous_uri"` + NextURI string `json:"next_uri"` + } `json:"pagination"` + Transactions []struct { + Amount struct { + Amount string `json:"amount"` + Currency string `json:"currency"` + } + CreatedAt time.Time `json:"created_at"` + ID string `json:"id"` + IDEMO string `json:"idem"` + NativeAmount struct { + Amount string `json:"amount"` + Currency string `json:"currency"` + } `json:"native_amount"` + Network struct { + Hash string `json:"hash"` + NetworkName string `json:"network_name"` + Status string `json:"status"` + TransactionFee struct { + Amount string `json:"amount"` + Currency string `json:"currency"` + } `json:"transaction_fee"` + } `json:"network"` + Resource string `json:"resource"` + Status string `json:"status"` + To struct { + Address string `json:"address"` + Resource string `json:"resource"` + } `json:"to"` + Buy struct { + Fee struct { + Amount string `json:"amount"` + Currency string `json:"currency"` + } `json:"fee"` + ID string `json:"id"` + PaymentMethodName string `json:"payment_method_name"` + Subtotal struct { + Amount string `json:"amount"` + Currency string `json:"currency"` + } `json:"subtotal"` + Total struct { + Amount string `json:"amount"` + Currency string `json:"currency"` + } `json:"total"` + } `json:"buy"` + Sell struct { + Fee struct { + Amount string `json:"amount"` + Currency string `json:"currency"` + } `json:"fee"` + ID string `json:"id"` + PaymentMethodName string `json:"payment_method_name"` + Subtotal struct { + Amount string `json:"amount"` + Currency string `json:"currency"` + } `json:"subtotal"` + Total struct { + Amount string `json:"amount"` + Currency string `json:"currency"` + } `json:"total"` + } `json:"sell"` + Type string `json:"type"` + } `json:"data"` +} + +func (a *accountsResponse) String() string { + accountSummaries := []string{} + for _, account := range a.Accounts { + summary := fmt.Sprintf("Account: %s, Currency: %s, Available Balance: %s %s, Hold: %s %s", account.Name, account.Currency, account.AvailableBalance.Value, account.AvailableBalance.Currency, account.Hold.Value, account.Hold.Currency) + accountSummaries = append(accountSummaries, summary) + } + return strings.Join(accountSummaries, "\n") +} + +func NewCoinbaseAPI(token string) pnl.MarketAPI { + if len(strings.Split(token, ":")) >= 2 { + secret := strings.ReplaceAll(strings.TrimSpace(strings.Split(token, ":")[1]), "\\n", "\n") + return &CoinbaseMarketAPI{ + ApiKey: strings.TrimSpace(strings.Split(token, ":")[0]), + ApiSecret: secret, + } + } + return &CoinbaseMarketAPI{} +} + +func (c *CoinbaseMarketAPI) HealthCheck() bool { + _, err := c.getAllAccounts() + if err != nil { + log.Printf("Coinbase API health check failed: %v", err) + return false + } + return true +} + +func (c *CoinbaseMarketAPI) FetchAssetAmount(symbol string) (float64, error) { + accounts, err := c.getAllAccounts() + if err != nil { + return 0, err + } + for _, account := range accounts.Accounts { + if account.Currency == symbol { + var amount float64 + _, err := fmt.Sscanf(account.AvailableBalance.Value, "%f", &amount) + if err != nil { + return 0, err + } + return amount, nil + } + } + return 0, fmt.Errorf("asset %s not found", symbol) +} + +func (c *CoinbaseMarketAPI) Buy(options pnl.TradeOptions) (*pnl.Trade, error) { + return nil, errors.New("not implemented") +} +func (c *CoinbaseMarketAPI) Sell(options pnl.TradeOptions) (*pnl.Trade, error) { + return nil, errors.New("not implemented") +} + +func (c *CoinbaseMarketAPI) AccountDetails() (string, error) { + accounts, err := c.getAllAccounts() + if err != nil { + return "", err + } + return accounts.String(), nil +} + +func (c *CoinbaseMarketAPI) ImportTrades(tradingPair pnl.Pair, since time.Time) ([]pnl.Trade, error) { + accountID, err := c.getAccountForBaseAsset(tradingPair.BaseAsset.Symbol) + if err != nil { + return nil, fmt.Errorf("error getting account for base asset %s: %v", tradingPair.BaseAsset.Symbol, err) + } + if accountID == "" { + return nil, fmt.Errorf("no account found for base asset %s", tradingPair.BaseAsset.Symbol) + } + pairs, err := c.getTradesForAccount(accountID, tradingPair, since) + if err != nil { + return nil, fmt.Errorf("error getting trades for account %s: %v", accountID, err) + } + return pairs, nil +} + +func (c *CoinbaseMarketAPI) getTradesForAccount(accountID string, tradingPair pnl.Pair, since time.Time) ([]pnl.Trade, error) { + resp, err := c.makeRequest(http.MethodGet, fmt.Sprintf("/v2/accounts/%s/transactions", accountID)) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("coinbase transaction summary returned status: %d", resp.StatusCode) + } + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + var transactions transactionResponse + if err := json.Unmarshal(body, &transactions); err != nil { + return nil, err + } + pairs := []pnl.Trade{} + for _, tx := range transactions.Transactions { + if tx.CreatedAt.Before(since) { + continue + } + if tx.Type != "buy" && tx.Type != "sell" { + // We only care about buy/sell transactions + continue + } + if tx.NativeAmount.Currency != tradingPair.QuoteAsset.Symbol || tx.Amount.Currency != tradingPair.BaseAsset.Symbol { + continue + } + var quoteAmount float64 + var fee float64 + var tradeType pnl.TradeType + var feeCurrency string + switch tx.Type { + case "sell": + _, err := fmt.Sscanf(tx.Sell.Total.Amount, "%f", "eAmount) + if err != nil { + return nil, fmt.Errorf("error parsing base amount for tx %s: %v", tx.ID, err) + } + _, err = fmt.Sscanf(tx.Sell.Fee.Amount, "%f", &fee) + if err != nil { + return nil, fmt.Errorf("error parsing base amount for tx %s: %v", tx.ID, err) + } + feeCurrency = tx.Sell.Fee.Currency + tradeType = pnl.Sell + case "buy": + _, err := fmt.Sscanf(tx.Buy.Total.Amount, "%f", "eAmount) + if err != nil { + return nil, fmt.Errorf("error parsing base amount for tx %s: %v", tx.ID, err) + } + _, err = fmt.Sscanf(tx.Buy.Fee.Amount, "%f", &fee) + if err != nil { + return nil, fmt.Errorf("error parsing base amount for tx %s: %v", tx.ID, err) + } + feeCurrency = tx.Buy.Fee.Currency + tradeType = pnl.Buy + } + var baseAmount float64 + _, err = fmt.Sscanf(tx.Amount.Amount, "%f", &baseAmount) + if err != nil { + return nil, fmt.Errorf("error parsing base amount for tx %s: %v", tx.ID, err) + } + trade := pnl.Trade{ + ID: tx.ID, + Timestamp: tx.CreatedAt, + BaseAmount: baseAmount, + QuoteAmount: quoteAmount, + FeeInBase: 0, + FeeInQuote: 0, + TradeType: tradeType, + Price: quoteAmount / baseAmount, + } + switch feeCurrency { + case tradingPair.BaseAsset.Symbol: + trade.FeeInBase = fee + case tradingPair.QuoteAsset.Symbol: + trade.FeeInQuote = fee + } + pairs = append(pairs, trade) + } + return pairs, nil +} + +func (c *CoinbaseMarketAPI) getAccountForBaseAsset(baseAsset string) (string, error) { + accounts, err := c.getAllAccounts() + if err != nil { + return "", err + } + for _, account := range accounts.Accounts { + if account.Currency == baseAsset { + return account.UUID, nil + } + } + return "", nil +} + +func (c *CoinbaseMarketAPI) getAllAccounts() (*accountsResponse, error) { + resp, err := c.makeRequest(http.MethodGet, "/api/v3/brokerage/accounts") + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("coinbase accounts returned status: %d", resp.StatusCode) + } + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + var accounts accountsResponse + if err := json.Unmarshal(body, &accounts); err != nil { + return nil, err + } + return &accounts, nil +} + +func (c *CoinbaseMarketAPI) makeRequest(method string, endpoint string) (*http.Response, error) { + jwtToken, err := c.generateJWT(method, endpoint) + if err != nil { + return nil, err + } + req, err := http.NewRequest(method, "https://"+COINBASE_HOST+endpoint, nil) + if err != nil { + return nil, err + } + + req.Header.Set("Authorization", "Bearer "+jwtToken) + + client := &http.Client{Timeout: 10 * time.Second} + return client.Do(req) +} + +func (c *CoinbaseMarketAPI) generateJWT(method string, uri string) (string, error) { + block, _ := pem.Decode([]byte(c.ApiSecret)) + if block == nil { + return "", errors.New("failed to parse API secret as PEM") + } + + privateKey, err := jwt.ParseECPrivateKeyFromPEM([]byte(c.ApiSecret)) + if err != nil { + return "", errors.New("failed to parse private key") + } + + claims := map[string]any{ + "sub": c.ApiKey, + "iss": "cdp", + "exp": time.Now().Add(time.Minute * 2).Unix(), + "nbf": time.Now().Unix(), + "uri": fmt.Sprintf("%s %s%s", method, COINBASE_HOST, uri), + } + + nonce := fmt.Sprintf("%d", time.Now().UnixNano()) + token := jwt.NewWithClaims(jwt.SigningMethodES256, jwt.MapClaims(claims)) + token.Header["kid"] = c.ApiKey + token.Header["nonce"] = nonce + + signedToken, err := token.SignedString(privateKey) + if err != nil { + return "", fmt.Errorf("failed to sign JWT: %v", err) + } + + return signedToken, nil +}