From 491c566ae2d65b97cf8ae297f540a635250d2d64 Mon Sep 17 00:00:00 2001 From: karstenevers <50057690+karstenevers@users.noreply.github.com> Date: Sun, 28 Dec 2025 19:21:01 +0100 Subject: [PATCH 1/3] Migrate Bitget extension to API v2 (v1 decommissioned) - Bitget V1 endpoints are decommissioned; migrate spot + mix requests to /api/v2/* and update response parsing (arrays, lastPr, markPrice/openPriceAvg, marginSize). - Canonicalize query-string ordering for signature prehash to avoid intermittent auth errors. - Refactor FX conversion: treat USDT/USDC as USD, and avoid caching failed non-EUR ECB lookups that can break reverse-pair logic. --- Bitget.lua | 163 +++++++++++++++++++++++++++++++---------------------- 1 file changed, 95 insertions(+), 68 deletions(-) diff --git a/Bitget.lua b/Bitget.lua index a759f71..01412de 100644 --- a/Bitget.lua +++ b/Bitget.lua @@ -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== From 44db0ffce0f40ed4b9fefb2ec2bbcc32af500cf4 Mon Sep 17 00:00:00 2001 From: karstenevers <50057690+karstenevers@users.noreply.github.com> Date: Sun, 28 Dec 2025 19:39:20 +0100 Subject: [PATCH 2/3] Update API-SPEC for Bitget REST API v2 Document Bitget REST API v2 endpoints used by this project (spot assets, spot tickers, mix accounts, mix positions, mix ticker, public time). Clarify v2 signature prehash format (including conditional '?' before query string) and recommend deterministic query construction to prevent signature mismatches. Add timestamp drift guidance for ACCESS-TIMESTAMP and update response format notes (code "00000", msg/message). --- API-SPEC.md | 199 +++++++++++++++++++++++++++++++++++----------------- 1 file changed, 133 insertions(+), 66 deletions(-) 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) From 0161a30aea03e7a86adff416f3220c10abe14caf Mon Sep 17 00:00:00 2001 From: Robert Gering Date: Sun, 1 Feb 2026 11:58:01 +0000 Subject: [PATCH 3/3] Bump version to 2.0 Co-Authored-By: Claude Opus 4.5 --- .gitignore | 4 +++- Bitget.lua | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) 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/Bitget.lua b/Bitget.lua index 01412de..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"},