From 27a7491cd1c2e06f8a77913629911f38d4bd02fc Mon Sep 17 00:00:00 2001 From: Muhammad Syaiful Anwar <51133847+asiifdev@users.noreply.github.com> Date: Tue, 3 Mar 2026 17:41:50 +0700 Subject: [PATCH] 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 --- api/server.go | 2 +- manager/trader_manager.go | 14 +- store/exchange.go | 52 +- trader/auto_trader.go | 57 +- trader/indodax/trader.go | 878 ++++++++++++++++++ trader/indodax/trader_test.go | 374 ++++++++ web/public/exchange-icons/indodax.png | Bin 0 -> 952 bytes web/public/exchange-icons/indodax.svg | 3 + web/src/components/ExchangeIcons.tsx | 5 +- .../traders/ExchangeConfigModal.tsx | 6 +- 10 files changed, 1331 insertions(+), 60 deletions(-) create mode 100644 trader/indodax/trader.go create mode 100644 trader/indodax/trader_test.go create mode 100644 web/public/exchange-icons/indodax.png create mode 100644 web/public/exchange-icons/indodax.svg diff --git a/api/server.go b/api/server.go index 41d490ce..45c5dda4 100644 --- a/api/server.go +++ b/api/server.go @@ -2008,7 +2008,7 @@ func (s *Server) handleCreateExchange(c *gin.Context) { // Validate exchange type validTypes := map[string]bool{ "binance": true, "bybit": true, "okx": true, "bitget": true, - "hyperliquid": true, "aster": true, "lighter": true, "gate": true, "kucoin": true, + "hyperliquid": true, "aster": true, "lighter": true, "gate": true, "kucoin": true, "indodax": true, } if !validTypes[req.ExchangeType] { c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Invalid exchange type: %s", req.ExchangeType)}) diff --git a/manager/trader_manager.go b/manager/trader_manager.go index d59a2204..59fa401c 100644 --- a/manager/trader_manager.go +++ b/manager/trader_manager.go @@ -407,7 +407,6 @@ func (tm *TraderManager) GetTopTradersData() (map[string]interface{}, error) { return result, nil } - // RemoveTrader removes a trader from memory (does not affect database) // Used to force reload when updating trader configuration // If the trader is running, it will be stopped first @@ -664,11 +663,11 @@ func (tm *TraderManager) addTraderFromStore(traderCfg *store.Trader, aiModelCfg QwenKey: "", CustomAPIURL: aiModelCfg.CustomAPIURL, CustomModelName: aiModelCfg.CustomModelName, - ScanInterval: time.Duration(traderCfg.ScanIntervalMinutes) * time.Minute, - InitialBalance: traderCfg.InitialBalance, - IsCrossMargin: traderCfg.IsCrossMargin, - ShowInCompetition: traderCfg.ShowInCompetition, - StrategyConfig: strategyConfig, + ScanInterval: time.Duration(traderCfg.ScanIntervalMinutes) * time.Minute, + InitialBalance: traderCfg.InitialBalance, + IsCrossMargin: traderCfg.IsCrossMargin, + ShowInCompetition: traderCfg.ShowInCompetition, + StrategyConfig: strategyConfig, } logger.Infof("๐Ÿ“Š Loading trader %s: ScanIntervalMinutes=%d (from DB), ScanInterval=%v", @@ -711,6 +710,9 @@ func (tm *TraderManager) addTraderFromStore(traderCfg *store.Trader, aiModelCfg traderConfig.LighterAPIKeyPrivateKey = string(exchangeCfg.LighterAPIKeyPrivateKey) traderConfig.LighterAPIKeyIndex = exchangeCfg.LighterAPIKeyIndex traderConfig.LighterTestnet = exchangeCfg.Testnet + case "indodax": + traderConfig.IndodaxAPIKey = string(exchangeCfg.APIKey) + traderConfig.IndodaxSecretKey = string(exchangeCfg.SecretKey) } // Set API keys based on AI model (convert EncryptedString to string) diff --git a/store/exchange.go b/store/exchange.go index 2d18ca62..e4acf69d 100644 --- a/store/exchange.go +++ b/store/exchange.go @@ -17,28 +17,28 @@ type ExchangeStore struct { // Exchange exchange configuration type Exchange struct { - ID string `gorm:"primaryKey" json:"id"` - ExchangeType string `gorm:"column:exchange_type;not null;default:''" json:"exchange_type"` - AccountName string `gorm:"column:account_name;not null;default:''" json:"account_name"` - UserID string `gorm:"column:user_id;not null;default:default;index" json:"user_id"` - Name string `gorm:"not null" json:"name"` - Type string `gorm:"not null" json:"type"` // "cex" or "dex" - Enabled bool `gorm:"default:false" json:"enabled"` + ID string `gorm:"primaryKey" json:"id"` + ExchangeType string `gorm:"column:exchange_type;not null;default:''" json:"exchange_type"` + AccountName string `gorm:"column:account_name;not null;default:''" json:"account_name"` + UserID string `gorm:"column:user_id;not null;default:default;index" json:"user_id"` + Name string `gorm:"not null" json:"name"` + Type string `gorm:"not null" json:"type"` // "cex" or "dex" + Enabled bool `gorm:"default:false" json:"enabled"` APIKey crypto.EncryptedString `gorm:"column:api_key;default:''" json:"apiKey"` SecretKey crypto.EncryptedString `gorm:"column:secret_key;default:''" json:"secretKey"` Passphrase crypto.EncryptedString `gorm:"column:passphrase;default:''" json:"passphrase"` - Testnet bool `gorm:"default:false" json:"testnet"` - HyperliquidWalletAddr string `gorm:"column:hyperliquid_wallet_addr;default:''" json:"hyperliquidWalletAddr"` - HyperliquidUnifiedAcct bool `gorm:"column:hyperliquid_unified_account;default:true" json:"hyperliquidUnifiedAccount"` // Unified Account mode (Spot as collateral) - AsterUser string `gorm:"column:aster_user;default:''" json:"asterUser"` - AsterSigner string `gorm:"column:aster_signer;default:''" json:"asterSigner"` + Testnet bool `gorm:"default:false" json:"testnet"` + HyperliquidWalletAddr string `gorm:"column:hyperliquid_wallet_addr;default:''" json:"hyperliquidWalletAddr"` + HyperliquidUnifiedAcct bool `gorm:"column:hyperliquid_unified_account;default:true" json:"hyperliquidUnifiedAccount"` // Unified Account mode (Spot as collateral) + AsterUser string `gorm:"column:aster_user;default:''" json:"asterUser"` + AsterSigner string `gorm:"column:aster_signer;default:''" json:"asterSigner"` AsterPrivateKey crypto.EncryptedString `gorm:"column:aster_private_key;default:''" json:"asterPrivateKey"` - LighterWalletAddr string `gorm:"column:lighter_wallet_addr;default:''" json:"lighterWalletAddr"` + LighterWalletAddr string `gorm:"column:lighter_wallet_addr;default:''" json:"lighterWalletAddr"` LighterPrivateKey crypto.EncryptedString `gorm:"column:lighter_private_key;default:''" json:"lighterPrivateKey"` LighterAPIKeyPrivateKey crypto.EncryptedString `gorm:"column:lighter_api_key_private_key;default:''" json:"lighterAPIKeyPrivateKey"` - LighterAPIKeyIndex int `gorm:"column:lighter_api_key_index;default:0" json:"lighterAPIKeyIndex"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + LighterAPIKeyIndex int `gorm:"column:lighter_api_key_index;default:0" json:"lighterAPIKeyIndex"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } func (Exchange) TableName() string { return "exchanges" } @@ -174,6 +174,8 @@ func getExchangeNameAndType(exchangeType string) (name string, typ string) { return "Aster DEX", "dex" case "lighter": return "LIGHTER DEX", "dex" + case "indodax": + return "Indodax", "cex" default: return exchangeType + " Exchange", "cex" } @@ -233,15 +235,15 @@ func (s *ExchangeStore) Update(userID, id string, enabled bool, apiKey, secretKe logger.Debugf("๐Ÿ”ง ExchangeStore.Update: userID=%s, id=%s, enabled=%v", userID, id, enabled) updates := map[string]interface{}{ - "enabled": enabled, - "testnet": testnet, - "hyperliquid_wallet_addr": hyperliquidWalletAddr, - "hyperliquid_unified_account": hyperliquidUnifiedAcct, - "aster_user": asterUser, - "aster_signer": asterSigner, - "lighter_wallet_addr": lighterWalletAddr, - "lighter_api_key_index": lighterApiKeyIndex, - "updated_at": time.Now().UTC(), + "enabled": enabled, + "testnet": testnet, + "hyperliquid_wallet_addr": hyperliquidWalletAddr, + "hyperliquid_unified_account": hyperliquidUnifiedAcct, + "aster_user": asterUser, + "aster_signer": asterSigner, + "lighter_wallet_addr": lighterWalletAddr, + "lighter_api_key_index": lighterApiKeyIndex, + "updated_at": time.Now().UTC(), } // Only update encrypted fields if not empty diff --git a/trader/auto_trader.go b/trader/auto_trader.go index d8ef87f2..9145e476 100644 --- a/trader/auto_trader.go +++ b/trader/auto_trader.go @@ -16,6 +16,7 @@ import ( "nofx/trader/bybit" "nofx/trader/gate" "nofx/trader/hyperliquid" + "nofx/trader/indodax" "nofx/trader/kucoin" "nofx/trader/lighter" "nofx/trader/okx" @@ -44,13 +45,13 @@ type AutoTraderConfig struct { BybitSecretKey string // OKX API configuration - OKXAPIKey string - OKXSecretKey string + OKXAPIKey string + OKXSecretKey string OKXPassphrase string // Bitget API configuration - BitgetAPIKey string - BitgetSecretKey string + BitgetAPIKey string + BitgetSecretKey string BitgetPassphrase string // Gate API configuration @@ -58,10 +59,14 @@ type AutoTraderConfig struct { GateSecretKey string // KuCoin API configuration - KuCoinAPIKey string - KuCoinSecretKey string + KuCoinAPIKey string + KuCoinSecretKey string KuCoinPassphrase string + // Indodax API configuration + IndodaxAPIKey string + IndodaxSecretKey string + // Hyperliquid configuration HyperliquidPrivateKey string HyperliquidWalletAddr string @@ -122,9 +127,9 @@ type AutoTrader struct { config AutoTraderConfig trader Trader // Use Trader interface (supports multiple platforms) mcpClient mcp.AIClient - store *store.Store // Data storage (decision records, etc.) + store *store.Store // Data storage (decision records, etc.) strategyEngine *kernel.StrategyEngine // Strategy engine (uses strategy configuration) - cycleNumber int // Current cycle number + cycleNumber int // Current cycle number initialBalance float64 dailyPnL float64 customPrompt string // Custom trading strategy prompt @@ -289,6 +294,9 @@ func NewAutoTrader(config AutoTraderConfig, st *store.Store, userID string) (*Au return nil, fmt.Errorf("failed to initialize LIGHTER trader: %w", err) } logger.Infof("โœ“ LIGHTER trader initialized successfully") + case "indodax": + logger.Infof("๐Ÿฆ [%s] Using Indodax Spot trading", config.Name) + trader = indodax.NewIndodaxTrader(config.IndodaxAPIKey, config.IndodaxSecretKey) default: return nil, fmt.Errorf("unsupported trading platform: %s", config.Exchange) } @@ -2181,22 +2189,22 @@ func (at *AutoTrader) recordOrderFill(orderRecordID int64, exchangeOrderID, symb normalizedSymbol := market.Normalize(symbol) fill := &store.TraderFill{ - TraderID: at.id, - ExchangeID: at.exchangeID, - ExchangeType: at.exchange, - OrderID: orderRecordID, - ExchangeOrderID: exchangeOrderID, - ExchangeTradeID: tradeID, - Symbol: normalizedSymbol, - Side: side, - Price: price, - Quantity: quantity, - QuoteQuantity: price * quantity, - Commission: fee, - CommissionAsset: "USDT", - RealizedPnL: 0, // Will be calculated for close orders - IsMaker: false, // Market orders are usually taker - CreatedAt: time.Now().UTC().UnixMilli(), + TraderID: at.id, + ExchangeID: at.exchangeID, + ExchangeType: at.exchange, + OrderID: orderRecordID, + ExchangeOrderID: exchangeOrderID, + ExchangeTradeID: tradeID, + Symbol: normalizedSymbol, + Side: side, + Price: price, + Quantity: quantity, + QuoteQuantity: price * quantity, + Commission: fee, + CommissionAsset: "USDT", + RealizedPnL: 0, // Will be calculated for close orders + IsMaker: false, // Market orders are usually taker + CreatedAt: time.Now().UTC().UnixMilli(), } // Calculate realized PnL for close orders @@ -2324,4 +2332,3 @@ func getSideFromAction(action string) string { func (at *AutoTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) { return at.trader.GetOpenOrders(symbol) } - diff --git a/trader/indodax/trader.go b/trader/indodax/trader.go new file mode 100644 index 00000000..ac49ac07 --- /dev/null +++ b/trader/indodax/trader.go @@ -0,0 +1,878 @@ +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 + } +} diff --git a/trader/indodax/trader_test.go b/trader/indodax/trader_test.go new file mode 100644 index 00000000..732036f8 --- /dev/null +++ b/trader/indodax/trader_test.go @@ -0,0 +1,374 @@ +package indodax + +import ( + "os" + "testing" + "time" + + "nofx/trader/types" +) + +// Test credentials - set via environment variables +func getIndodaxTestCredentials(t *testing.T) (string, string) { + apiKey := os.Getenv("INDODAX_TEST_API_KEY") + secretKey := os.Getenv("INDODAX_TEST_SECRET_KEY") + + if apiKey == "" || secretKey == "" { + t.Skip("Indodax test credentials not set (INDODAX_TEST_API_KEY, INDODAX_TEST_SECRET_KEY)") + } + + return apiKey, secretKey +} + +func createIndodaxTestTrader(t *testing.T) *IndodaxTrader { + apiKey, secretKey := getIndodaxTestCredentials(t) + trader := NewIndodaxTrader(apiKey, secretKey) + return trader +} + +// TestIndodaxTrader_InterfaceCompliance tests that IndodaxTrader implements types.Trader +func TestIndodaxTrader_InterfaceCompliance(t *testing.T) { + var _ types.Trader = (*IndodaxTrader)(nil) +} + +// TestNewIndodaxTrader tests creating Indodax trader instance +func TestNewIndodaxTrader(t *testing.T) { + trader := NewIndodaxTrader("test_api_key", "test_secret_key") + + if trader == nil { + t.Fatal("Expected non-nil trader") + } + if trader.apiKey != "test_api_key" { + t.Errorf("Expected apiKey 'test_api_key', got '%s'", trader.apiKey) + } + if trader.secretKey != "test_secret_key" { + t.Errorf("Expected secretKey 'test_secret_key', got '%s'", trader.secretKey) + } + if trader.httpClient == nil { + t.Error("Expected non-nil httpClient") + } + if trader.cacheDuration != 15*time.Second { + t.Errorf("Expected cacheDuration 15s, got %v", trader.cacheDuration) + } +} + +// TestIndodaxTrader_SymbolConversion tests symbol format conversion +func TestIndodaxTrader_SymbolConversion(t *testing.T) { + trader := NewIndodaxTrader("test", "test") + + tests := []struct { + name string + input string + expected string + }{ + {"BTCIDR to btc_idr", "BTCIDR", "btc_idr"}, + {"ETHIDR to eth_idr", "ETHIDR", "eth_idr"}, + {"SOLIDR to sol_idr", "SOLIDR", "sol_idr"}, + {"Already converted", "btc_idr", "btc_idr"}, + {"BTC pair", "ETHBTC", "eth_btc"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := trader.convertSymbol(tt.input) + if result != tt.expected { + t.Errorf("convertSymbol(%s) = %s, want %s", tt.input, result, tt.expected) + } + }) + } +} + +// TestIndodaxTrader_SymbolConversionBack tests symbol reversion +func TestIndodaxTrader_SymbolConversionBack(t *testing.T) { + trader := NewIndodaxTrader("test", "test") + + tests := []struct { + name string + input string + expected string + }{ + {"btc_idr to BTCIDR", "btc_idr", "BTCIDR"}, + {"eth_idr to ETHIDR", "eth_idr", "ETHIDR"}, + {"Already standard", "BTCIDR", "BTCIDR"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := trader.convertSymbolBack(tt.input) + if result != tt.expected { + t.Errorf("convertSymbolBack(%s) = %s, want %s", tt.input, result, tt.expected) + } + }) + } +} + +// TestIndodaxTrader_GetCoinFromSymbol tests coin extraction +func TestIndodaxTrader_GetCoinFromSymbol(t *testing.T) { + trader := NewIndodaxTrader("test", "test") + + tests := []struct { + input string + expected string + }{ + {"BTCIDR", "btc"}, + {"ETHIDR", "eth"}, + {"btc_idr", "btc"}, + {"eth_idr", "eth"}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + result := trader.getCoinFromSymbol(tt.input) + if result != tt.expected { + t.Errorf("getCoinFromSymbol(%s) = %s, want %s", tt.input, result, tt.expected) + } + }) + } +} + +// TestIndodaxTrader_Sign tests HMAC-SHA512 signature generation +func TestIndodaxTrader_Sign(t *testing.T) { + trader := NewIndodaxTrader("api_key", "secret_key") + + body := "method=getInfo&nonce=1000" + signature := trader.sign(body) + + if signature == "" { + t.Error("Expected non-empty signature") + } + if len(signature) != 128 { // SHA-512 hex = 128 chars + t.Errorf("Expected signature length 128, got %d", len(signature)) + } + + // Same input should produce same signature + signature2 := trader.sign(body) + if signature != signature2 { + t.Error("Signature should be deterministic") + } + + // Different input should produce different signature + signature3 := trader.sign("method=getInfo&nonce=1001") + if signature == signature3 { + t.Error("Different input should produce different signature") + } +} + +// TestIndodaxTrader_Nonce tests nonce incrementation +func TestIndodaxTrader_Nonce(t *testing.T) { + trader := NewIndodaxTrader("test", "test") + + nonce1 := trader.getNonce() + nonce2 := trader.getNonce() + nonce3 := trader.getNonce() + + if nonce2 <= nonce1 { + t.Errorf("Nonce should be increasing: %d <= %d", nonce2, nonce1) + } + if nonce3 <= nonce2 { + t.Errorf("Nonce should be increasing: %d <= %d", nonce3, nonce2) + } +} + +// TestIndodaxTrader_SpotOnlyRestrictions tests that futures-only methods return errors +func TestIndodaxTrader_SpotOnlyRestrictions(t *testing.T) { + trader := NewIndodaxTrader("test", "test") + + // OpenShort should fail + _, err := trader.OpenShort("BTCIDR", 0.001, 1) + if err == nil { + t.Error("OpenShort should return error on spot exchange") + } + + // CloseShort should fail + _, err = trader.CloseShort("BTCIDR", 0.001) + if err == nil { + t.Error("CloseShort should return error on spot exchange") + } + + // SetStopLoss should fail + err = trader.SetStopLoss("BTCIDR", "LONG", 0.001, 500000000) + if err == nil { + t.Error("SetStopLoss should return error on spot exchange") + } + + // SetTakeProfit should fail + err = trader.SetTakeProfit("BTCIDR", "LONG", 0.001, 600000000) + if err == nil { + t.Error("SetTakeProfit should return error on spot exchange") + } + + // SetLeverage should NOT fail (no-op) + err = trader.SetLeverage("BTCIDR", 10) + if err != nil { + t.Errorf("SetLeverage should not fail (no-op): %v", err) + } + + // SetMarginMode should NOT fail (no-op) + err = trader.SetMarginMode("BTCIDR", true) + if err != nil { + t.Errorf("SetMarginMode should not fail (no-op): %v", err) + } +} + +// TestIndodaxTrader_ParseFloat tests parseFloat helper +func TestIndodaxTrader_ParseFloat(t *testing.T) { + tests := []struct { + name string + input interface{} + expected float64 + }{ + {"float64", 123.45, 123.45}, + {"string", "123.45", 123.45}, + {"int", 123, 123.0}, + {"int64", int64(123), 123.0}, + {"nil", nil, 0.0}, + {"zero string", "0", 0.0}, + {"empty string", "", 0.0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := parseFloat(tt.input) + if result != tt.expected { + t.Errorf("parseFloat(%v) = %f, want %f", tt.input, result, tt.expected) + } + }) + } +} + +// TestIndodaxTrader_ClearCache tests cache clearing +func TestIndodaxTrader_ClearCache(t *testing.T) { + trader := NewIndodaxTrader("test", "test") + + // Set some cached data + trader.cachedBalance = map[string]interface{}{"test": "data"} + trader.cachedPositions = []map[string]interface{}{{"test": "data"}} + + // Clear cache + trader.clearCache() + + if trader.cachedBalance != nil { + t.Error("Cache should be cleared") + } + if trader.cachedPositions != nil { + t.Error("Position cache should be cleared") + } +} + +// ============================================================ +// Integration tests (require INDODAX_TEST_API_KEY env vars) +// ============================================================ + +// TestIndodaxConnection tests basic API connectivity +func TestIndodaxConnection(t *testing.T) { + trader := createIndodaxTestTrader(t) + + balance, err := trader.GetBalance() + if err != nil { + t.Fatalf("Failed to get balance: %v", err) + } + + t.Logf("โœ… Connection OK") + t.Logf(" totalWalletBalance: %v", balance["totalWalletBalance"]) + t.Logf(" availableBalance: %v", balance["availableBalance"]) + t.Logf(" totalEquity: %v", balance["totalEquity"]) + t.Logf(" currency: %v", balance["currency"]) + t.Logf(" user_id: %v", balance["user_id"]) +} + +// TestIndodaxGetPositions tests position retrieval +func TestIndodaxGetPositions(t *testing.T) { + trader := createIndodaxTestTrader(t) + + positions, err := trader.GetPositions() + if err != nil { + t.Fatalf("Failed to get positions: %v", err) + } + + t.Logf("๐Ÿ“Š Found %d positions (crypto balances):", len(positions)) + for i, pos := range positions { + t.Logf(" [%d] %s: qty=%.8f markPrice=%.0f value=%.0f IDR", + i+1, + pos["symbol"], + pos["positionAmt"], + pos["markPrice"], + pos["notionalValue"], + ) + } +} + +// TestIndodaxGetMarketPrice tests market price retrieval +func TestIndodaxGetMarketPrice(t *testing.T) { + trader := createIndodaxTestTrader(t) + + pairs := []string{"BTCIDR", "ETHIDR"} + + for _, pair := range pairs { + price, err := trader.GetMarketPrice(pair) + if err != nil { + t.Errorf("Failed to get price for %s: %v", pair, err) + continue + } + t.Logf(" %s: %.0f IDR", pair, price) + } +} + +// TestIndodaxGetOpenOrders tests open orders retrieval +func TestIndodaxGetOpenOrders(t *testing.T) { + trader := createIndodaxTestTrader(t) + + orders, err := trader.GetOpenOrders("BTCIDR") + if err != nil { + t.Fatalf("Failed to get open orders: %v", err) + } + + t.Logf("๐Ÿ“‹ Found %d open orders:", len(orders)) + for i, order := range orders { + t.Logf(" [%d] %s %s: price=%.0f orderID=%s", + i+1, order.Symbol, order.Side, order.Price, order.OrderID) + } +} + +// TestIndodaxGetClosedPnL tests trade history retrieval +func TestIndodaxGetClosedPnL(t *testing.T) { + trader := createIndodaxTestTrader(t) + + startTime := time.Now().Add(-7 * 24 * time.Hour) + records, err := trader.GetClosedPnL(startTime, 10) + if err != nil { + t.Fatalf("Failed to get closed PnL: %v", err) + } + + t.Logf("๐Ÿ“‹ Found %d trade records:", len(records)) + for i, record := range records { + t.Logf(" [%d] %s %s: price=%.0f fee=%.4f time=%s", + i+1, record.Symbol, record.Side, record.ExitPrice, record.Fee, + record.ExitTime.Format("2006-01-02 15:04:05")) + } +} + +// TestIndodaxLoadPairs tests loading trading pairs +func TestIndodaxLoadPairs(t *testing.T) { + trader := createIndodaxTestTrader(t) + + err := trader.loadPairs() + if err != nil { + t.Fatalf("Failed to load pairs: %v", err) + } + + trader.pairCacheMutex.RLock() + defer trader.pairCacheMutex.RUnlock() + + t.Logf("๐Ÿ“Š Loaded %d pairs", len(trader.pairCache)) + + // Check some known pairs + knownPairs := []string{"btc_idr", "eth_idr"} + for _, pairID := range knownPairs { + if pair, ok := trader.pairCache[pairID]; ok { + t.Logf(" %s: min_base=%v, min_traded=%v, precision=%d", + pair.Description, pair.TradeMinBaseCurrency, pair.TradeMinTradedCurrency, pair.PriceRound) + } else { + t.Errorf("Expected pair %s not found", pairID) + } + } +} diff --git a/web/public/exchange-icons/indodax.png b/web/public/exchange-icons/indodax.png new file mode 100644 index 0000000000000000000000000000000000000000..efa8f25074a7efc38223915df03540221350716f GIT binary patch literal 952 zcmV;p14sOcP)C0000aP)t-sM{rC8 zgtY{OwFHE<1cbH!|Nj7XtpI`CvR70*_A$JXAmhR6qq(Km}Al1yn!HT@ zlLk@&8PZ$UU z65mCpLE`&l&>7DcK)$t(Wq^E38?Xg3-+186LE;;`J}3jOK<019Fg8HGcZ>6s0aM_> zUr#m4Lgw$o2?L%0BtE44$8_v{vUa{hk2P9!)aaTbx@VKtC!SQ`%ewy}3E&~2_ARB-^8yw!* znIG=D5VZ{aRUq+!%a_~>8V2s10_5X}P@c3;D!UzD41(Kco5AmIiev(fEO=`H>9zxES?g_63=ZG@CcvoDq08N8vW&D}KgqnkuACS-6z)lF{EHtjHAmz{0 zhS|Uvziy9U`CcnO4;Rn&Dzn>P!d-0S` zl3yA4`p~=_u^SY5GS??zz%RS=y-i2(1q})`07K9JaSpadCGy`Bs45}`|9ao(-3}%? zBrJYexq*8BvYQc + + diff --git a/web/src/components/ExchangeIcons.tsx b/web/src/components/ExchangeIcons.tsx index 60f0b980..f8fab5c9 100644 --- a/web/src/components/ExchangeIcons.tsx +++ b/web/src/components/ExchangeIcons.tsx @@ -17,6 +17,7 @@ const ICON_PATHS: Record = { hyperliquid: '/exchange-icons/hyperliquid.png', aster: '/exchange-icons/aster.svg', lighter: '/exchange-icons/lighter.png', + indodax: '/exchange-icons/indodax.png', } // ้€š็”จๅ›พๆ ‡็ป„ไปถ @@ -101,7 +102,9 @@ export const getExchangeIcon = ( ? 'aster' : lowerType.includes('lighter') ? 'lighter' - : lowerType + : lowerType.includes('indodax') + ? 'indodax' + : lowerType const iconProps = { width: props.width || 24, diff --git a/web/src/components/traders/ExchangeConfigModal.tsx b/web/src/components/traders/ExchangeConfigModal.tsx index 41ae7c27..769ba666 100644 --- a/web/src/components/traders/ExchangeConfigModal.tsx +++ b/web/src/components/traders/ExchangeConfigModal.tsx @@ -30,6 +30,7 @@ const SUPPORTED_EXCHANGE_TEMPLATES = [ { exchange_type: 'hyperliquid', name: 'Hyperliquid', type: 'dex' as const }, { exchange_type: 'aster', name: 'Aster DEX', type: 'dex' as const }, { exchange_type: 'lighter', name: 'Lighter', type: 'dex' as const }, + { exchange_type: 'indodax', name: 'Indodax', type: 'cex' as const }, ] interface ExchangeConfigModalProps { @@ -204,6 +205,7 @@ export function ExchangeConfigModal({ hyperliquid: { url: 'https://app.hyperliquid.xyz/join/AITRADING', hasReferral: true }, aster: { url: 'https://www.asterdex.com/en/referral/fdfc0e', hasReferral: true }, lighter: { url: 'https://app.lighter.xyz/?referral=68151432', hasReferral: true }, + indodax: { url: 'https://indodax.com/ref/Saep23/1', hasReferral: true }, } // Initialize form when editing @@ -312,7 +314,7 @@ export function ExchangeConfigModal({ setIsSaving(true) try { - if (currentExchangeType === 'binance' || currentExchangeType === 'bybit') { + if (currentExchangeType === 'binance' || currentExchangeType === 'bybit' || currentExchangeType === 'indodax') { if (!apiKey.trim() || !secretKey.trim()) return await onSave(exchangeId, exchangeType, trimmedAccountName, apiKey.trim(), secretKey.trim(), '', testnet) } else if (currentExchangeType === 'okx' || currentExchangeType === 'bitget' || currentExchangeType === 'kucoin') { @@ -503,7 +505,7 @@ export function ExchangeConfigModal({ {/* CEX Fields */} - {(currentExchangeType === 'binance' || currentExchangeType === 'bybit' || currentExchangeType === 'okx' || currentExchangeType === 'bitget' || currentExchangeType === 'gate' || currentExchangeType === 'kucoin') && ( + {(currentExchangeType === 'binance' || currentExchangeType === 'bybit' || currentExchangeType === 'okx' || currentExchangeType === 'bitget' || currentExchangeType === 'gate' || currentExchangeType === 'kucoin' || currentExchangeType === 'indodax') && ( <> {currentExchangeType === 'binance' && (