diff --git a/.gitignore b/.gitignore index 6d1dc32..b9341ca 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,6 @@ *.swp *.bak api-keys.txt -test-data/ \ No newline at end of file +test-data/ +.claude/settings.local.json +CLAUDE.md diff --git a/API-SPEC.md b/API-SPEC.md index aeac7bb..c8b792d 100644 --- a/API-SPEC.md +++ b/API-SPEC.md @@ -1,4 +1,6 @@ -# Bitget API Specification +# Bitget API Specification (REST, v2) + +This repository targets **Bitget REST API v2** endpoints. Some v1 endpoints are deprecated/decommissioned and may return errors; use v2 for new integrations. ## Base URLs - Primary: https://api.bitget.com @@ -6,115 +8,180 @@ ## Authentication -### Required Headers -- `ACCESS-KEY`: The API Key +### Required Headers (private/signed endpoints) +- `ACCESS-KEY`: The API key - `ACCESS-SIGN`: The signature (see below) -- `ACCESS-TIMESTAMP`: Request timestamp +- `ACCESS-TIMESTAMP`: Request timestamp in **milliseconds** since Unix epoch (string) - `ACCESS-PASSPHRASE`: The passphrase you specified when creating the API key +- `Content-Type`: `application/json` +- Optional: `locale`: e.g. `en-US` + +Public endpoints do **not** require these headers. Private endpoints require signing and the headers above. + +### Timestamp requirements +Bitget validates `ACCESS-TIMESTAMP` against server time. If your local clock is too far off (commonly > ~30 seconds), signed requests may fail. Prefer syncing system time or using server time for troubleshooting (`GET /api/v2/public/time`). ### Signature Calculation The signature is a Base64 encoded HMAC SHA256 hash: ``` -signature = base64(hmac_sha256(secret_key, message)) -message = timestamp + method + request_path + query_string + body +signature = base64(hmac_sha256(secret_key, prehash)) +``` + +`prehash` is constructed as: + +``` +prehash = timestamp + UPPER(method) + request_path + query_part + body +query_part = (query_string == "" ? "" : "?" + query_string) +body = "" for GET (or empty string if no JSON body) ``` Where: -- `timestamp`: Same as ACCESS-TIMESTAMP header +- `timestamp`: Same as the `ACCESS-TIMESTAMP` header (milliseconds as string) - `method`: HTTP method in uppercase (GET, POST, etc.) -- `request_path`: Path without domain (e.g., `/api/spot/v1/account/assets`) -- `query_string`: Query parameters (without ?) -- `body`: Request body (empty string for GET requests) +- `request_path`: Path without domain (e.g., `/api/v2/spot/account/assets`) +- `query_string`: URL-encoded query parameters **without** the leading `?` +- `body`: Request body string (empty for GET) + +Important: +- The `query_string` included in the signature must match the query string used in the request URL (including URL encoding). +- Build the query string deterministically to avoid signature mismatches (e.g., sort parameter keys before concatenation, then URL-encode keys/values). ## Key Endpoints +### Public + +#### Server time +``` +GET /api/v2/public/time +``` + ### Spot Trading -#### Account Balance +#### Account Balance / Assets (private) ``` -GET /api/spot/v1/account/assets +GET /api/v2/spot/account/assets ``` -Optional Parameters: -- `coin`: Filter by specific coin (e.g., "BTC") +Query parameters (common): +- `assetType` (optional): scope of returned assets + - `hold_only`: non-zero balances only + - `all`: all assets +- `coin` (optional): filter by specific coin (e.g., `BTC`) -Response includes: -- `available`: Available balance -- `frozen`: Frozen balance -- `locked`: Locked balance +Response includes (commonly used `data[]` fields): +- `coin` +- `available` +- `frozen` +- `locked` +- `limitAvailable` -#### Account Balance (Lite) +#### Market Tickers (public) ``` -GET /api/spot/v1/account/assets-lite +GET /api/v2/spot/market/tickers ``` -Similar to assets endpoint but defaults to showing only non-zero balances. +Query parameters: +- `symbol` (optional): e.g. `BTCUSDT` (if omitted, returns multiple tickers) + +Response includes (commonly used `data[]` fields): +- `symbol` +- `lastPr` +- `bidPr` +- `askPr` +- `high24h` +- `low24h` +- `ts` ### Futures Trading (Mix) -#### Account Information +#### Account Information (private) ``` -GET /api/mix/v1/account/account -GET /api/mix/v1/account/accounts +GET /api/v2/mix/account/accounts ``` -Parameters: -- `symbol`: Trading pair (e.g., "BTCUSDT_UMCBL") -- `marginCoin`: Margin coin (e.g., "USDT") - -#### Open Positions +Query parameters: +- `productType` (required) + +Common `productType` values (v2): +- `USDT-FUTURES`: USDT-margined futures +- `USDC-FUTURES`: USDC-margined futures +- `COIN-FUTURES`: coin-margined futures + +Response includes (commonly used `data[]` fields): +- `marginCoin` +- `available` +- `accountEquity` +- `locked` +- `crossedMaxAvailable` +- `isolatedMaxAvailable` +- `maxTransferOut` +- `assetMode` + +#### Open Positions / All Positions (private) ``` -GET /api/mix/v1/position/allPosition -GET /api/mix/v1/position/allPosition-v2 +GET /api/v2/mix/position/all-position ``` -Parameters: -- `productType`: Product type (umcbl, dmcbl, cmcbl, sumcbl) -- `marginCoin`: Optional filter +Query parameters: +- `productType` (required) +- `marginCoin` (optional): filter by margin coin (e.g., `USDT`) + +Response includes (commonly used `data[]` fields): +- `symbol` +- `marginCoin` +- `holdSide` (long/short) +- `total` +- `available` +- `locked` +- `leverage` +- `marginMode` +- `marginSize` +- `unrealizedPL` +- `achievedProfits` +- `markPrice` +- `openPriceAvg` +- `cTime` +- `uTime` + +#### Market Ticker (public) +``` +GET /api/v2/mix/market/ticker +``` -Response includes: -- `symbol`: Trading pair -- `marginCoin`: Margin currency -- `holdSide`: Position side (long/short) -- `openDelegateCount`: Number of open orders -- `margin`: Position margin -- `available`: Available quantity -- `locked`: Locked quantity -- `total`: Total position -- `leverage`: Leverage ratio -- `achievedProfits`: Realized PnL -- `unrealizedPL`: Unrealized PnL -- `unrealizedPLR`: Unrealized PnL ratio -- `liquidationPrice`: Liquidation price -- `keepMarginRate`: Maintenance margin rate -- `markPrice`: Mark price -- `averageOpenPrice`: Average entry price +Query parameters: +- `productType` (required) +- `symbol` (required) -#### Product Types -- `umcbl`: USDT perpetual -- `dmcbl`: Universal margin -- `cmcbl`: USDC perpetual -- `sumcbl`: USDT perpetual demo +Response includes (commonly used `data[]` fields): +- `symbol` +- `lastPr` +- `bidPr` +- `askPr` +- `markPrice` +- `ts` ## Rate Limits -- Most endpoints: 10-20 requests/second -- HTTP 429 status code when limit exceeded -- Limits are per UID or IP +Rate limits are endpoint-specific. If you hit limits, expect HTTP 429 responses and/or API error codes. ## Response Format Standard JSON response with: -- `code`: Error code (0 for success) -- `msg`: Error message -- `data`: Response data +- `code`: API status code (typically `"00000"` for success) +- `msg` (or `message`): status message +- `data`: response data +- `requestTime`: server timestamp (ms) ## Error Codes -- 0: Success -- 429: Rate limit exceeded -- Various other codes for authentication failures, invalid parameters, etc. +Examples: +- `"00000"`: Success +- HTTP 429: Rate limit exceeded +- Other codes indicate authentication failures, invalid parameters, permissions, etc. ## API Key Permissions +Recommended for read-only portfolio integrations: - Read: Query market data and account info + +Avoid enabling unless explicitly needed: - Trade: Place and cancel orders - Transfer: Transfer between accounts -- Withdraw: Withdraw assets (requires whitelisted IP) \ No newline at end of file +- Withdraw: Withdraw assets (often requires whitelisted IP) diff --git a/Bitget.lua b/Bitget.lua index a759f71..9fdee4e 100644 --- a/Bitget.lua +++ b/Bitget.lua @@ -25,7 +25,7 @@ -- SOFTWARE. WebBanking{ - version = 1.1, + version = 2.0, country = "de", description = string.format(MM.localizeText("Fetch balances and positions from %s"), "Bitget"), services = {"Bitget"}, @@ -68,10 +68,6 @@ end -- Currency conversion functions function fetchFxRate(base, quote) - if quote == "EUR" then - return 1 / fetchFxRate(quote, base) - end - if base == "EUR" then local content = Connection():request("GET", "https://www.ecb.europa.eu/stats/eurofxref/eurofxref-daily.xml") for cube in content:parseTags("Cube") do @@ -84,8 +80,10 @@ function fetchFxRate(base, quote) end MM.printStatus("Wechselkurs nicht verfügbar für " .. base .. "/" .. quote) - -- Cache failed lookups to avoid repeated API calls - setFxRate(base, quote, nil) + -- Only cache failed ECB lookups (EUR base), otherwise we may poison reverse-pair logic + if base == "EUR" then + setFxRate(base, quote, nil) + end return nil end @@ -125,11 +123,11 @@ end function convertToEUR(amount, currency) if currency == "EUR" then return amount - elseif currency == "USDT" then - -- Treat USDT as USD for conversion + elseif currency == "USDT" or currency == "USDC" then + -- Treat stablecoins as USD for conversion local rate = getFxRate("EUR", "USD") if not rate then - MM.printStatus("Fallback: USDT wird als 1:1 USD behandelt") + MM.printStatus("Fallback: Stablecoin wird als 1:1 USD behandelt") rate = 1 end return amount / rate @@ -144,8 +142,8 @@ function convertToEUR(amount, currency) end function getFxRateToBase(currency) - -- Special handling for USDT - treat as USD - if currency == "USDT" then + -- Special handling for stablecoins - treat as USD + if currency == "USDT" or currency == "USDC" then return getFxRate("EUR", "USD") end return getFxRate("EUR", currency) @@ -231,10 +229,10 @@ function lookupCoinName(symbol) end function fetchCurrentPrice(symbol) - local response = makeRequest("GET", "/api/mix/v1/market/ticker", {symbol = symbol}, nil) - - if response and response.code == "00000" and response.data then - return tonumber(response.data.last) or tonumber(response.data.close) or 0 + -- V2 requires productType + symbol; response.data is an array with lastPr + local response = makeRequest("GET", "/api/v2/mix/market/ticker", { productType = "USDT-FUTURES", symbol = symbol }, nil) + if response and response.code == "00000" and response.data and response.data[1] then + return tonumber(response.data[1].lastPr) or 0 end MM.printStatus("Fallback: Kein aktueller Preis für " .. symbol) @@ -254,19 +252,41 @@ function createSignature(timestamp, method, requestPath, queryString, body) return MM.base64(signature) end +local function urlEncode(str) + str = tostring(str or "") + return (str:gsub("([^%w%-_%.~])", function(c) + return string.format("%%%02X", string.byte(c)) + end)) +end + +local function buildQueryString(queryParams) + if not queryParams then return "" end + local keys = {} + for k, _ in pairs(queryParams) do table.insert(keys, k) end + table.sort(keys) + local params = {} + for _, k in ipairs(keys) do + table.insert(params, urlEncode(k) .. "=" .. urlEncode(queryParams[k])) + end + return table.concat(params, "&") +end + +local function makePublicRequest(method, path, queryParams) + local queryString = buildQueryString(queryParams) + local url = baseUrl .. path .. (queryString ~= "" and ("?" .. queryString) or "") + local content = connection:request(method, url) + if content then + return JSON(content):dictionary() + end + return nil +end + + function makeRequest(method, path, queryParams, body) local timestamp = tostring(os.time() * 1000) - local queryString = "" - - if queryParams then - local params = {} - for k, v in pairs(queryParams) do - table.insert(params, k .. "=" .. v) - end - queryString = table.concat(params, "&") - if queryString ~= "" then - path = path .. "?" .. queryString - end + local queryString = buildQueryString(queryParams) + if queryString ~= "" then + path = path .. "?" .. queryString end local signature = createSignature(timestamp, method, path:match("^([^?]+)"), queryString, body or "") @@ -276,6 +296,7 @@ function makeRequest(method, path, queryParams, body) ["ACCESS-SIGN"] = signature, ["ACCESS-TIMESTAMP"] = timestamp, ["ACCESS-PASSPHRASE"] = passphrase, + ["locale"] = "en-US", ["Content-Type"] = "application/json" } @@ -318,11 +339,17 @@ function InitializeSession(protocol, bankCode, username, username2, password, us connection = Connection() - -- Test connection with a simple API call - local response = makeRequest("GET", "/api/spot/v1/public/time", nil, nil) + -- V2: public time (no signing required) + local timeResp = makePublicRequest("GET", "/api/v2/public/time", nil) + if not timeResp or timeResp.code ~= "00000" then + MM.printStatus("Fehler: Verbindung fehlgeschlagen (public time)") + return LoginFailed + end - if not response or response.code ~= "00000" then - MM.printStatus("Fehler: Verbindung fehlgeschlagen") + -- V2: validate API credentials via a signed endpoint + local authResp = makeRequest("GET", "/api/v2/spot/account/assets", { assetType = "hold_only" }, nil) + if not authResp or authResp.code ~= "00000" then + MM.printStatus("Fehler: API Credentials ungültig oder keine Berechtigung (spot assets)") return LoginFailed end @@ -372,7 +399,8 @@ end function fetchSpotBalances() local securities = {} - local response = makeRequest("GET", "/api/spot/v1/account/assets", nil, nil) + -- V2 endpoint + response fields changed (coin, available, frozen, locked) + local response = makeRequest("GET", "/api/v2/spot/account/assets", { assetType = "hold_only" }, nil) if not response or response.code ~= "00000" then MM.printStatus("Fehler beim Abrufen der Spot-Guthaben") @@ -380,10 +408,9 @@ function fetchSpotBalances() end for _, asset in ipairs(response.data or {}) do - local coin = asset.coinName or asset.coinDisplayName - if not coin then - -- Skip asset if no coin name available - MM.printStatus("Überspringe Asset ohne Coin-Name") + local coin = asset.coin and tostring(asset.coin):upper() or nil + if not coin or coin == "" then + MM.printStatus("Überspringe Asset ohne Coin") goto continue end @@ -406,16 +433,18 @@ function fetchSpotBalances() -- USD is a fiat currency priceUSD = 1 fiat = true + baseCurrency = "USD" elseif coin == "EUR" then -- EUR is a fiat currency priceEUR = 1 fiat = true + baseCurrency = "EUR" else -- For other cryptocurrencies, fetch the current price - local priceResponse = makeRequest("GET", "/api/spot/v1/market/ticker", {symbol = coin .. "USDT_SPBL"}, nil) - - if priceResponse and priceResponse.code == "00000" and priceResponse.data then - priceUSD = tonumber(priceResponse.data.close) or 0 + -- V2 spot ticker: /api/v2/spot/market/tickers?symbol=BTCUSDT ; data is array; lastPr is last price + local priceResponse = makePublicRequest("GET", "/api/v2/spot/market/tickers", { symbol = coin .. "USDT" }) + if priceResponse and priceResponse.code == "00000" and priceResponse.data and priceResponse.data[1] then + priceUSD = tonumber(priceResponse.data[1].lastPr) or 0 end end @@ -465,49 +494,48 @@ function fetchFuturesPositions() local securities = {} -- First, fetch futures account balance (available funds) - local balanceResponse = makeRequest("GET", "/api/mix/v1/account/accounts", {productType = "umcbl"}, nil) - if balanceResponse and balanceResponse.code == "00000" and balanceResponse.data then - for _, account in ipairs(balanceResponse.data or {}) do - -- Try different fields for available balance - local available = tonumber(account.available) or tonumber(account.equity) or tonumber(account.crossMaxAvailable) or 0 - local marginCoin = account.marginCoin or "USDT" - - if available > 0 then - local availableEUR = convertToEUR(available, marginCoin == "USDT" and "USD" or marginCoin) - - table.insert(securities, { - name = marginCoin, - market = "Bitget Futures", - quantity = available, - exchangeRate = getFxRateToBase(marginCoin == "USDT" and "USD" or marginCoin), - amount = availableEUR - }) + local productTypes = { "USDT-FUTURES", "USDC-FUTURES", "COIN-FUTURES" } + for _, pt in ipairs(productTypes) do + local balanceResponse = makeRequest("GET", "/api/v2/mix/account/accounts", { productType = pt }, nil) + if balanceResponse and balanceResponse.code == "00000" and balanceResponse.data then + for _, account in ipairs(balanceResponse.data or {}) do + local available = tonumber(account.available) or tonumber(account.accountEquity) or 0 + local marginCoin = account.marginCoin or "USDT" + local fxCoin = (marginCoin == "USDT" or marginCoin == "USDC") and "USD" or marginCoin + if available and available > 0 then + local availableEUR = convertToEUR(available, fxCoin) + table.insert(securities, { + name = marginCoin, + market = "Bitget Futures", + quantity = available, + exchangeRate = getFxRateToBase(fxCoin), + amount = availableEUR + }) + end end end end - -- Fetch all futures positions - local productTypes = {"umcbl", "dmcbl", "cmcbl"} -- USDT, Universal, USDC perpetuals - + -- Fetch all futures positions (V2) for _, productType in ipairs(productTypes) do - local response = makeRequest("GET", "/api/mix/v1/position/allPosition-v2", {productType = productType}, nil) + local response = makeRequest("GET", "/api/v2/mix/position/all-position", { productType = productType }, nil) if response and response.code == "00000" and response.data then for _, position in ipairs(response.data or {}) do if tonumber(position.total) and tonumber(position.total) > 0 then - local symbol = position.symbol:gsub("_.*", "") -- Remove suffix like _UMCBL + local symbol = tostring(position.symbol or "") local cryptoSymbol = symbol:match("([%w]+)USDT") or symbol:match("([%w]+)USDC") or symbol:match("([%w]+)BTC") or symbol:match("([%w]+)ETH") local holdSide = position.holdSide local leverage = tonumber(position.leverage) or 1 local unrealizedPnl = tonumber(position.unrealizedPL) or 0 - local marketPrice = tonumber(position.marketPrice) or 0 - local avgPrice = tonumber(position.averageOpenPrice) or 0 + local marketPrice = tonumber(position.markPrice) or 0 + local avgPrice = tonumber(position.openPriceAvg) or 0 -- Check for margin mode (isolated vs cross) local marginMode = position.marginMode or "unknown" local total = tonumber(position.total) or 0 - local margin = tonumber(position.margin) or 0 + local margin = tonumber(position.marginSize) or 0 MM.printStatus("Futures-Position: " .. symbol .. " (" .. holdSide .. ")" .. " - Leverage: " .. leverage .. "x " .. marginMode .. " - Margin: " .. margin .. " - Price: " .. marketPrice .. " - Average Price: " .. avgPrice .. " - P&L: " .. unrealizedPnl .. " - Total: " .. total) @@ -538,10 +566,10 @@ function fetchFuturesPositions() end -- For correct portfolio value: use margin + unrealized P&L - local marginUSD = tonumber(position.margin) or 0 + local marginUSD = tonumber(position.marginSize) or 0 local marginCurrency = position.marginCoin or quoteCurrency - -- Convert USDT to USD for MoneyMoney compatibility - if marginCurrency == "USDT" then + -- Convert USDT / USDC to USD for MoneyMoney compatibility + if marginCurrency == "USDT" or marginCurrency == "USDC" then marginCurrency = "USD" end @@ -599,4 +627,3 @@ function EndSession() -- Nothing to do end --- SIGNATURE: MCwCFAvxxJEQqJ7YyXm49LDgsQ47T8C9AhRnIid+ufF7YqV3IF55wkhXbuY7nA==