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 00000000..efa8f250
Binary files /dev/null and b/web/public/exchange-icons/indodax.png differ
diff --git a/web/public/exchange-icons/indodax.svg b/web/public/exchange-icons/indodax.svg
new file mode 100644
index 00000000..6766c07d
--- /dev/null
+++ b/web/public/exchange-icons/indodax.svg
@@ -0,0 +1,3 @@
+
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' && (