Files
nofx/trader/indodax/trader.go
Muhammad Syaiful Anwar 27a7491cd1 feat(trader): add Indodax exchange integration (#1400)
* feat(trader): add Indodax exchange integration

- Add IndodaxTrader implementing types.Trader interface for spot trading
- Support HMAC-SHA512 authentication with Key/Sign headers
- Map spot buy/sell to OpenLong/CloseLong, stub futures-only methods
- Wire up auto_trader.go, trader_manager.go, store/exchange.go
- Add Indodax to frontend ExchangeConfigModal and ExchangeIcons
- Add integration tests with env-var based credentials
- Add Indodax logo assets (PNG + SVG)

* fix: type validation at server.go for indodax exchange
2026-03-03 18:41:50 +08:00

879 lines
24 KiB
Go

package indodax
import (
"crypto/hmac"
"crypto/sha512"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"math"
"net/http"
"net/url"
"nofx/logger"
"nofx/trader/types"
"strconv"
"strings"
"sync"
"time"
)
// Indodax API endpoints
const (
indodaxBaseURL = "https://indodax.com"
indodaxPublicAPI = "/api"
indodaxPrivateAPI = "/tapi"
)
// IndodaxTrader implements types.Trader interface for Indodax Spot Exchange
// Indodax is Indonesia's largest crypto exchange, supporting IDR (Indonesian Rupiah) pairs.
// Since Indodax is spot-only, futures-specific methods (OpenShort, CloseShort, leverage, etc.)
// are gracefully stubbed.
type IndodaxTrader struct {
apiKey string
secretKey string
httpClient *http.Client
nonce int64
nonceMutex sync.Mutex
// Cache for pair info
pairCache map[string]*IndodaxPair
pairCacheMutex sync.RWMutex
pairCacheTime time.Time
// Cache for balance
cachedBalance map[string]interface{}
cachedPositions []map[string]interface{}
balanceCacheTime time.Time
positionCacheTime time.Time
cacheDuration time.Duration
cacheMutex sync.RWMutex
}
// IndodaxPair represents a trading pair on Indodax
type IndodaxPair struct {
ID string `json:"id"`
Symbol string `json:"symbol"`
BaseCurrency string `json:"base_currency"`
TradedCurrency string `json:"traded_currency"`
TradedCurrencyUnit string `json:"traded_currency_unit"`
Description string `json:"description"`
TickerID string `json:"ticker_id"`
VolumePrecision int `json:"volume_precision"`
PricePrecision float64 `json:"price_precision"`
PriceRound int `json:"price_round"`
Pricescale float64 `json:"pricescale"`
TradeMinBaseCurrency float64 `json:"trade_min_base_currency"`
TradeMinTradedCurrency float64 `json:"trade_min_traded_currency"`
}
// IndodaxResponse represents the standard Indodax private API response
type IndodaxResponse struct {
Success int `json:"success"`
Return json.RawMessage `json:"return,omitempty"`
Error string `json:"error,omitempty"`
ErrorCode string `json:"error_code,omitempty"`
}
// IndodaxTicker represents ticker data
type IndodaxTicker struct {
High string `json:"high"`
Low string `json:"low"`
Last string `json:"last"`
Buy string `json:"buy"`
Sell string `json:"sell"`
ServerTime int64 `json:"server_time"`
}
// IndodaxTickerResponse wraps ticker response
type IndodaxTickerResponse struct {
Ticker IndodaxTicker `json:"ticker"`
}
// NewIndodaxTrader creates a new Indodax trader instance
func NewIndodaxTrader(apiKey, secretKey string) *IndodaxTrader {
return &IndodaxTrader{
apiKey: apiKey,
secretKey: secretKey,
httpClient: &http.Client{Timeout: 30 * time.Second},
nonce: time.Now().UnixMilli(),
pairCache: make(map[string]*IndodaxPair),
cacheDuration: 15 * time.Second,
}
}
// getNonce returns a unique incrementing nonce for each request
func (t *IndodaxTrader) getNonce() int64 {
t.nonceMutex.Lock()
defer t.nonceMutex.Unlock()
t.nonce++
return t.nonce
}
// sign generates HMAC-SHA512 signature for request body
func (t *IndodaxTrader) sign(body string) string {
mac := hmac.New(sha512.New, []byte(t.secretKey))
mac.Write([]byte(body))
return hex.EncodeToString(mac.Sum(nil))
}
// doPublicRequest makes a public API GET request
func (t *IndodaxTrader) doPublicRequest(path string) ([]byte, error) {
reqURL := indodaxBaseURL + indodaxPublicAPI + path
req, err := http.NewRequest("GET", reqURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
resp, err := t.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(data))
}
return data, nil
}
// doPrivateRequest makes a signed private API POST request
func (t *IndodaxTrader) doPrivateRequest(params url.Values) ([]byte, error) {
reqURL := indodaxBaseURL + indodaxPrivateAPI
// Add nonce
params.Set("nonce", strconv.FormatInt(t.getNonce(), 10))
body := params.Encode()
signature := t.sign(body)
req, err := http.NewRequest("POST", reqURL, strings.NewReader(body))
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Key", t.apiKey)
req.Header.Set("Sign", signature)
resp, err := t.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err)
}
if resp.StatusCode == http.StatusTooManyRequests {
return nil, fmt.Errorf("rate limit exceeded, please try again later")
}
// Parse response to check success
var apiResp IndodaxResponse
if err := json.Unmarshal(data, &apiResp); err != nil {
return nil, fmt.Errorf("failed to parse response: %w (body: %s)", err, string(data))
}
if apiResp.Success != 1 {
return nil, fmt.Errorf("API error: %s (code: %s)", apiResp.Error, apiResp.ErrorCode)
}
return apiResp.Return, nil
}
// convertSymbol converts standard symbol to Indodax format
// e.g. BTCIDR -> btc_idr, ETHIDR -> eth_idr
func (t *IndodaxTrader) convertSymbol(symbol string) string {
s := strings.ToLower(symbol)
// Already in Indodax format (contains underscore)
if strings.Contains(s, "_") {
return s
}
// Try to split by known base currencies
for _, base := range []string{"idr", "btc", "usdt"} {
if strings.HasSuffix(s, base) {
traded := strings.TrimSuffix(s, base)
if traded != "" {
return traded + "_" + base
}
}
}
return s
}
// convertSymbolBack converts Indodax format back to standard
// e.g. btc_idr -> BTCIDR
func (t *IndodaxTrader) convertSymbolBack(indodaxSymbol string) string {
return strings.ToUpper(strings.ReplaceAll(indodaxSymbol, "_", ""))
}
// getCoinFromSymbol extracts the traded currency from a symbol
// e.g. btc_idr -> btc, eth_idr -> eth
func (t *IndodaxTrader) getCoinFromSymbol(symbol string) string {
pair := t.convertSymbol(symbol)
parts := strings.Split(pair, "_")
if len(parts) >= 1 {
return parts[0]
}
return strings.ToLower(symbol)
}
// loadPairs loads trading pair information from the public API
func (t *IndodaxTrader) loadPairs() error {
t.pairCacheMutex.RLock()
if len(t.pairCache) > 0 && time.Since(t.pairCacheTime) < 5*time.Minute {
t.pairCacheMutex.RUnlock()
return nil
}
t.pairCacheMutex.RUnlock()
data, err := t.doPublicRequest("/pairs")
if err != nil {
return fmt.Errorf("failed to load pairs: %w", err)
}
var pairs []IndodaxPair
if err := json.Unmarshal(data, &pairs); err != nil {
return fmt.Errorf("failed to parse pairs: %w", err)
}
t.pairCacheMutex.Lock()
defer t.pairCacheMutex.Unlock()
t.pairCache = make(map[string]*IndodaxPair)
for i := range pairs {
p := pairs[i]
t.pairCache[p.TickerID] = &p
// Also index by ID (e.g. "btcidr")
t.pairCache[p.ID] = &p
}
t.pairCacheTime = time.Now()
logger.Infof("[Indodax] Loaded %d trading pairs", len(pairs))
return nil
}
// getPair gets pair info for a symbol
func (t *IndodaxTrader) getPair(symbol string) (*IndodaxPair, error) {
if err := t.loadPairs(); err != nil {
return nil, err
}
pairID := t.convertSymbol(symbol)
t.pairCacheMutex.RLock()
defer t.pairCacheMutex.RUnlock()
if pair, ok := t.pairCache[pairID]; ok {
return pair, nil
}
// Try without underscore
noUnderscore := strings.ReplaceAll(pairID, "_", "")
if pair, ok := t.pairCache[noUnderscore]; ok {
return pair, nil
}
return nil, fmt.Errorf("pair not found: %s", symbol)
}
// clearCache clears cached data
func (t *IndodaxTrader) clearCache() {
t.cacheMutex.Lock()
defer t.cacheMutex.Unlock()
t.cachedBalance = nil
t.cachedPositions = nil
}
// ============================================================
// types.Trader interface implementation
// ============================================================
// GetBalance gets account balance from Indodax
func (t *IndodaxTrader) GetBalance() (map[string]interface{}, error) {
// Check cache
t.cacheMutex.RLock()
if t.cachedBalance != nil && time.Since(t.balanceCacheTime) < t.cacheDuration {
cached := t.cachedBalance
t.cacheMutex.RUnlock()
return cached, nil
}
t.cacheMutex.RUnlock()
params := url.Values{}
params.Set("method", "getInfo")
data, err := t.doPrivateRequest(params)
if err != nil {
return nil, fmt.Errorf("failed to get account info: %w", err)
}
var result struct {
ServerTime int64 `json:"server_time"`
Balance map[string]interface{} `json:"balance"`
BalanceHold map[string]interface{} `json:"balance_hold"`
UserID string `json:"user_id"`
Name string `json:"name"`
Email string `json:"email"`
}
if err := json.Unmarshal(data, &result); err != nil {
return nil, fmt.Errorf("failed to parse balance: %w", err)
}
// Calculate total balance in IDR
idrBalance := parseFloat(result.Balance["idr"])
idrHold := parseFloat(result.BalanceHold["idr"])
totalIDR := idrBalance + idrHold
balance := map[string]interface{}{
"totalWalletBalance": totalIDR,
"availableBalance": idrBalance,
"totalUnrealizedProfit": 0.0,
"totalEquity": totalIDR,
"balance": totalIDR,
"idr_balance": idrBalance,
"idr_hold": idrHold,
"currency": "IDR",
"user_id": result.UserID,
"server_time": result.ServerTime,
}
// Add individual crypto balances
for currency, amount := range result.Balance {
if currency != "idr" {
balance["balance_"+currency] = parseFloat(amount)
}
}
for currency, amount := range result.BalanceHold {
if currency != "idr" {
balance["hold_"+currency] = parseFloat(amount)
}
}
// Update cache
t.cacheMutex.Lock()
t.cachedBalance = balance
t.balanceCacheTime = time.Now()
t.cacheMutex.Unlock()
return balance, nil
}
// GetPositions returns currently held crypto balances as "positions"
// Since Indodax is spot-only, each non-zero crypto balance is treated as a position
func (t *IndodaxTrader) GetPositions() ([]map[string]interface{}, error) {
// Check cache
t.cacheMutex.RLock()
if t.cachedPositions != nil && time.Since(t.positionCacheTime) < t.cacheDuration {
cached := t.cachedPositions
t.cacheMutex.RUnlock()
return cached, nil
}
t.cacheMutex.RUnlock()
params := url.Values{}
params.Set("method", "getInfo")
data, err := t.doPrivateRequest(params)
if err != nil {
return nil, fmt.Errorf("failed to get positions: %w", err)
}
var result struct {
Balance map[string]interface{} `json:"balance"`
BalanceHold map[string]interface{} `json:"balance_hold"`
}
if err := json.Unmarshal(data, &result); err != nil {
return nil, fmt.Errorf("failed to parse positions: %w", err)
}
var positions []map[string]interface{}
for currency, amountRaw := range result.Balance {
if currency == "idr" {
continue
}
amount := parseFloat(amountRaw)
holdAmount := parseFloat(result.BalanceHold[currency])
totalAmount := amount + holdAmount
if totalAmount <= 0 {
continue
}
// Get market price for this coin
markPrice, _ := t.GetMarketPrice(strings.ToUpper(currency) + "IDR")
// Calculate position value in IDR
notionalValue := totalAmount * markPrice
position := map[string]interface{}{
"symbol": strings.ToUpper(currency) + "IDR",
"side": "LONG",
"positionAmt": totalAmount,
"entryPrice": markPrice, // Spot doesn't track entry price
"markPrice": markPrice,
"unRealizedProfit": 0.0, // Spot doesn't track unrealized PnL
"leverage": 1.0,
"mgnMode": "spot",
"notionalValue": notionalValue,
"currency": currency,
"available": amount,
"hold": holdAmount,
}
positions = append(positions, position)
}
// Update cache
t.cacheMutex.Lock()
t.cachedPositions = positions
t.positionCacheTime = time.Now()
t.cacheMutex.Unlock()
return positions, nil
}
// OpenLong opens a spot buy order
func (t *IndodaxTrader) OpenLong(symbol string, quantity float64, leverage int) (map[string]interface{}, error) {
t.clearCache()
pair := t.convertSymbol(symbol)
coin := t.getCoinFromSymbol(symbol)
// Get market price to calculate IDR amount
price, err := t.GetMarketPrice(symbol)
if err != nil {
return nil, fmt.Errorf("failed to get market price: %w", err)
}
params := url.Values{}
params.Set("method", "trade")
params.Set("pair", pair)
params.Set("type", "buy")
params.Set("price", strconv.FormatFloat(price, 'f', 0, 64))
params.Set(coin, strconv.FormatFloat(quantity, 'f', 8, 64))
params.Set("order_type", "limit")
data, err := t.doPrivateRequest(params)
if err != nil {
return nil, fmt.Errorf("failed to place buy order: %w", err)
}
var result map[string]interface{}
if err := json.Unmarshal(data, &result); err != nil {
return nil, fmt.Errorf("failed to parse trade response: %w", err)
}
logger.Infof("[Indodax] Buy order placed: %s qty=%.8f price=%.0f", symbol, quantity, price)
return map[string]interface{}{
"orderId": result["order_id"],
"symbol": symbol,
"side": "BUY",
"price": price,
"qty": quantity,
"status": "NEW",
}, nil
}
// OpenShort is not supported on Indodax (spot-only exchange)
func (t *IndodaxTrader) OpenShort(symbol string, quantity float64, leverage int) (map[string]interface{}, error) {
return nil, fmt.Errorf("short selling is not supported on Indodax (spot-only exchange)")
}
// CloseLong closes a spot position by selling
func (t *IndodaxTrader) CloseLong(symbol string, quantity float64) (map[string]interface{}, error) {
t.clearCache()
pair := t.convertSymbol(symbol)
coin := t.getCoinFromSymbol(symbol)
// If quantity is 0, sell all available balance
if quantity <= 0 {
balance, err := t.GetBalance()
if err != nil {
return nil, fmt.Errorf("failed to get balance for close all: %w", err)
}
available := parseFloat(balance["balance_"+coin])
if available <= 0 {
return nil, fmt.Errorf("no %s balance to sell", coin)
}
quantity = available
}
// Get market price
price, err := t.GetMarketPrice(symbol)
if err != nil {
return nil, fmt.Errorf("failed to get market price: %w", err)
}
params := url.Values{}
params.Set("method", "trade")
params.Set("pair", pair)
params.Set("type", "sell")
params.Set("price", strconv.FormatFloat(price, 'f', 0, 64))
params.Set(coin, strconv.FormatFloat(quantity, 'f', 8, 64))
params.Set("order_type", "limit")
data, err := t.doPrivateRequest(params)
if err != nil {
return nil, fmt.Errorf("failed to place sell order: %w", err)
}
var result map[string]interface{}
if err := json.Unmarshal(data, &result); err != nil {
return nil, fmt.Errorf("failed to parse trade response: %w", err)
}
logger.Infof("[Indodax] Sell order placed: %s qty=%.8f price=%.0f", symbol, quantity, price)
return map[string]interface{}{
"orderId": result["order_id"],
"symbol": symbol,
"side": "SELL",
"price": price,
"qty": quantity,
"status": "NEW",
}, nil
}
// CloseShort is not supported on Indodax (spot-only exchange)
func (t *IndodaxTrader) CloseShort(symbol string, quantity float64) (map[string]interface{}, error) {
return nil, fmt.Errorf("short selling is not supported on Indodax (spot-only exchange)")
}
// SetLeverage is a no-op for Indodax (spot-only, no leverage)
func (t *IndodaxTrader) SetLeverage(symbol string, leverage int) error {
logger.Infof("[Indodax] SetLeverage ignored (spot-only exchange, no leverage support)")
return nil
}
// SetMarginMode is a no-op for Indodax (spot-only, no margin)
func (t *IndodaxTrader) SetMarginMode(symbol string, isCrossMargin bool) error {
logger.Infof("[Indodax] SetMarginMode ignored (spot-only exchange, no margin support)")
return nil
}
// GetMarketPrice gets the current market price for a symbol
func (t *IndodaxTrader) GetMarketPrice(symbol string) (float64, error) {
pairID := strings.ToLower(strings.ReplaceAll(t.convertSymbol(symbol), "_", ""))
data, err := t.doPublicRequest("/ticker/" + pairID)
if err != nil {
return 0, fmt.Errorf("failed to get ticker: %w", err)
}
var tickerResp IndodaxTickerResponse
if err := json.Unmarshal(data, &tickerResp); err != nil {
return 0, fmt.Errorf("failed to parse ticker: %w", err)
}
price, err := strconv.ParseFloat(tickerResp.Ticker.Last, 64)
if err != nil {
return 0, fmt.Errorf("failed to parse price '%s': %w", tickerResp.Ticker.Last, err)
}
return price, nil
}
// SetStopLoss is not supported on Indodax (spot-only exchange)
func (t *IndodaxTrader) SetStopLoss(symbol string, positionSide string, quantity, stopPrice float64) error {
return fmt.Errorf("stop-loss orders are not supported on Indodax (spot-only exchange)")
}
// SetTakeProfit is not supported on Indodax (spot-only exchange)
func (t *IndodaxTrader) SetTakeProfit(symbol string, positionSide string, quantity, takeProfitPrice float64) error {
return fmt.Errorf("take-profit orders are not supported on Indodax (spot-only exchange)")
}
// CancelStopLossOrders is a no-op for Indodax
func (t *IndodaxTrader) CancelStopLossOrders(symbol string) error {
return nil
}
// CancelTakeProfitOrders is a no-op for Indodax
func (t *IndodaxTrader) CancelTakeProfitOrders(symbol string) error {
return nil
}
// CancelAllOrders cancels all open orders for a given symbol
func (t *IndodaxTrader) CancelAllOrders(symbol string) error {
t.clearCache()
pair := t.convertSymbol(symbol)
// First get open orders
params := url.Values{}
params.Set("method", "openOrders")
params.Set("pair", pair)
data, err := t.doPrivateRequest(params)
if err != nil {
return fmt.Errorf("failed to get open orders: %w", err)
}
var result struct {
Orders []struct {
OrderID json.Number `json:"order_id"`
Type string `json:"type"`
OrderType string `json:"order_type"`
} `json:"orders"`
}
if err := json.Unmarshal(data, &result); err != nil {
return fmt.Errorf("failed to parse open orders: %w", err)
}
// Cancel each order
for _, order := range result.Orders {
cancelParams := url.Values{}
cancelParams.Set("method", "cancelOrder")
cancelParams.Set("pair", pair)
cancelParams.Set("order_id", order.OrderID.String())
cancelParams.Set("type", order.Type)
if _, err := t.doPrivateRequest(cancelParams); err != nil {
logger.Warnf("[Indodax] Failed to cancel order %s: %v", order.OrderID, err)
} else {
logger.Infof("[Indodax] Cancelled order: %s", order.OrderID)
}
}
return nil
}
// CancelStopOrders is a no-op for Indodax (no stop orders)
func (t *IndodaxTrader) CancelStopOrders(symbol string) error {
return nil
}
// FormatQuantity formats quantity to correct precision for Indodax
func (t *IndodaxTrader) FormatQuantity(symbol string, quantity float64) (string, error) {
pair, err := t.getPair(symbol)
if err != nil {
// Default: 8 decimal places
return strconv.FormatFloat(quantity, 'f', 8, 64), nil
}
precision := pair.PriceRound
if precision <= 0 {
precision = 8
}
// Round down to avoid exceeding balance
factor := math.Pow(10, float64(precision))
rounded := math.Floor(quantity*factor) / factor
return strconv.FormatFloat(rounded, 'f', precision, 64), nil
}
// GetOrderStatus gets the status of a specific order
func (t *IndodaxTrader) GetOrderStatus(symbol string, orderID string) (map[string]interface{}, error) {
pair := t.convertSymbol(symbol)
params := url.Values{}
params.Set("method", "getOrder")
params.Set("pair", pair)
params.Set("order_id", orderID)
data, err := t.doPrivateRequest(params)
if err != nil {
return nil, fmt.Errorf("failed to get order status: %w", err)
}
var result struct {
Order struct {
OrderID string `json:"order_id"`
Price string `json:"price"`
Type string `json:"type"`
Status string `json:"status"`
SubmitTime string `json:"submit_time"`
FinishTime string `json:"finish_time"`
ClientOrderID string `json:"client_order_id"`
} `json:"order"`
}
if err := json.Unmarshal(data, &result); err != nil {
return nil, fmt.Errorf("failed to parse order: %w", err)
}
// Map Indodax status to standard status
status := "NEW"
switch result.Order.Status {
case "filled":
status = "FILLED"
case "cancelled":
status = "CANCELED"
case "open":
status = "NEW"
}
price, _ := strconv.ParseFloat(result.Order.Price, 64)
return map[string]interface{}{
"status": status,
"avgPrice": price,
"executedQty": 0.0, // Indodax doesn't return executed qty in getOrder
"commission": 0.0,
"orderId": result.Order.OrderID,
}, nil
}
// GetClosedPnL gets closed position PnL records (trade history)
func (t *IndodaxTrader) GetClosedPnL(startTime time.Time, limit int) ([]types.ClosedPnLRecord, error) {
// Indodax trade history is limited to 7 days range
params := url.Values{}
params.Set("method", "tradeHistory")
params.Set("pair", "btc_idr") // Default pair; Indodax requires a pair
if limit > 0 {
params.Set("count", strconv.Itoa(limit))
}
if !startTime.IsZero() {
params.Set("since", strconv.FormatInt(startTime.Unix(), 10))
}
data, err := t.doPrivateRequest(params)
if err != nil {
return nil, fmt.Errorf("failed to get trade history: %w", err)
}
var result struct {
Trades []struct {
TradeID string `json:"trade_id"`
OrderID string `json:"order_id"`
Type string `json:"type"`
Price string `json:"price"`
Fee string `json:"fee"`
TradeTime string `json:"trade_time"`
ClientOrderID string `json:"client_order_id"`
} `json:"trades"`
}
if err := json.Unmarshal(data, &result); err != nil {
// Trade history might return empty, that's fine
return nil, nil
}
var records []types.ClosedPnLRecord
for _, trade := range result.Trades {
price, _ := strconv.ParseFloat(trade.Price, 64)
fee, _ := strconv.ParseFloat(trade.Fee, 64)
tradeTime, _ := strconv.ParseInt(trade.TradeTime, 10, 64)
side := "long"
if trade.Type == "sell" {
side = "long" // Selling from a spot position is closing long
}
records = append(records, types.ClosedPnLRecord{
Symbol: "BTCIDR",
Side: side,
ExitPrice: price,
Fee: fee,
ExitTime: time.Unix(tradeTime, 0),
OrderID: trade.OrderID,
CloseType: "manual",
})
}
return records, nil
}
// GetOpenOrders gets open/pending orders
func (t *IndodaxTrader) GetOpenOrders(symbol string) ([]types.OpenOrder, error) {
pair := t.convertSymbol(symbol)
params := url.Values{}
params.Set("method", "openOrders")
if pair != "" {
params.Set("pair", pair)
}
data, err := t.doPrivateRequest(params)
if err != nil {
return nil, fmt.Errorf("failed to get open orders: %w", err)
}
var result struct {
Orders []struct {
OrderID json.Number `json:"order_id"`
ClientOrderID string `json:"client_order_id"`
SubmitTime string `json:"submit_time"`
Price string `json:"price"`
Type string `json:"type"`
OrderType string `json:"order_type"`
} `json:"orders"`
}
if err := json.Unmarshal(data, &result); err != nil {
return nil, fmt.Errorf("failed to parse open orders: %w", err)
}
var orders []types.OpenOrder
for _, order := range result.Orders {
price, _ := strconv.ParseFloat(order.Price, 64)
side := "BUY"
if order.Type == "sell" {
side = "SELL"
}
orders = append(orders, types.OpenOrder{
OrderID: order.OrderID.String(),
Symbol: t.convertSymbolBack(pair),
Side: side,
PositionSide: "LONG",
Type: "LIMIT",
Price: price,
Status: "NEW",
})
}
return orders, nil
}
// ============================================================
// Helper functions
// ============================================================
// parseFloat safely parses a float from interface{}
func parseFloat(v interface{}) float64 {
if v == nil {
return 0
}
switch val := v.(type) {
case float64:
return val
case string:
f, _ := strconv.ParseFloat(val, 64)
return f
case json.Number:
f, _ := val.Float64()
return f
case int:
return float64(val)
case int64:
return float64(val)
default:
return 0
}
}