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
This commit is contained in:
Muhammad Syaiful Anwar
2026-03-03 17:41:50 +07:00
committed by GitHub
parent 3358c5a53e
commit 27a7491cd1
10 changed files with 1331 additions and 60 deletions
+1 -1
View File
@@ -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)})
+8 -6
View File
@@ -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)
+27 -25
View File
@@ -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
+32 -25
View File
@@ -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)
}
+878
View File
@@ -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
}
}
+374
View File
@@ -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)
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 952 B

+3
View File
@@ -0,0 +1,3 @@
<svg width="300" height="300" viewBox="0 0 300 300" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M23.8095 0.263396C16.8864 1.28889 10.9524 4.62174 6.15385 10.2253C3.99267 12.7524 1.50183 17.8066 0.659341 21.3592C0.03663 24.1061 0 29.3434 0 150.022C0 270.7 0.03663 275.938 0.659341 278.684C1.50183 282.274 3.99267 287.291 6.26374 289.928C10.1832 294.58 15.7143 298.022 21.3187 299.341C24.0659 299.963 29.304 300 150 300C270.696 300 275.934 299.963 278.681 299.341C284.286 298.022 289.817 294.58 293.736 289.928C296.007 287.291 298.498 282.274 299.341 278.684C299.963 275.938 300 270.7 300 150.022C300 29.3434 299.963 24.1061 299.341 21.3592C297.033 11.6171 289.341 3.66949 279.414 0.849391L276.557 0.0436474L151.282 0.00702266C81.3919 -0.0296021 25.0549 0.0802721 23.8095 0.263396ZM92.381 77.2119C96.1905 81.0941 104.469 89.5911 110.769 96.037L122.234 107.83L124.249 108.05C126.777 108.343 128.645 109.295 130.293 111.126C131.905 112.921 132.564 114.789 132.527 117.426C132.454 122.517 128.645 126.216 123.443 126.216C118.681 126.216 115.421 123.432 114.322 118.525C114.139 117.682 108.901 112.189 89.1209 91.9717L85.1648 87.9429L77.3993 95.7074L69.5971 103.508L89.7436 123.652L109.89 143.796H116.374C119.963 143.796 122.894 143.905 122.894 144.089C122.894 144.235 110.952 156.065 96.337 170.349C81.7216 184.669 69.7802 196.425 69.7802 196.535C69.7802 196.645 73.2967 200.234 77.5824 204.519L85.4212 212.32L110.842 186.537L136.264 160.716L150.403 160.68L164.506 160.643L168.901 165.148C174.249 170.642 177.729 174.231 198.169 195.546L214.249 212.32L222.051 204.519C226.337 200.234 229.817 196.608 229.744 196.425C229.707 196.279 224.286 190.492 217.692 183.57C206.557 171.887 205.568 170.971 203.956 170.495C201.685 169.872 198.718 166.979 198.059 164.818C196.337 158.922 199.927 153.318 205.824 152.695C211.136 152.146 215.348 155.772 215.897 161.339L216.117 163.829L231.319 180.054C239.67 188.954 246.557 196.462 246.63 196.682C246.667 196.901 239.414 204.483 230.476 213.529L214.286 229.974L189.194 203.824C175.421 189.43 162.821 176.318 161.209 174.634L158.278 171.63H150.733H143.187L133.333 181.592C127.912 187.086 114.908 200.198 104.432 210.746L85.3846 229.9L68.6813 213.199L52.0147 196.535L75.2747 173.279L98.5348 150.022L75.2747 126.765L52.0147 103.508L68.6813 86.8442C77.8388 77.688 85.348 70.18 85.3846 70.18C85.4212 70.18 88.5348 73.3663 92.381 77.2119ZM231.355 86.5512C240.403 95.5243 247.802 103.032 247.802 103.215C247.802 103.435 242.381 109.039 235.788 115.704C209.121 142.697 191.209 161.009 191.209 161.375C191.209 161.559 196.374 167.382 202.674 174.304L214.139 186.903L216.154 187.342C220.366 188.295 223.26 191.957 223.26 196.352C223.26 201.553 219.341 205.435 214.103 205.398C209.414 205.398 205.751 202.102 205.018 197.304C204.762 195.473 204.652 195.363 188.205 178.443C179.084 169.067 171.612 161.229 171.612 161.046C171.612 160.863 172.711 159.691 174.103 158.409C177.802 154.93 229.67 104.168 229.78 103.875C229.927 103.508 214.506 88.0528 214.139 88.1993C213.846 88.3092 194.029 107.684 169.78 131.563L161.172 140.06L136.813 139.877L112.454 139.694L99.0843 126.399C86.4103 113.8 85.641 113.104 84.1392 112.884C81.0256 112.408 78.0586 110.138 76.8865 107.317C76.1538 105.559 76.2637 101.787 77.0696 100.029C77.9487 98.1246 80.696 95.5975 82.4542 95.1214C87.9487 93.5831 93.2234 96.623 94.359 101.934L94.7985 104.058L107.106 116.217L119.414 128.377H137.912L156.41 128.413L185.531 99.2966C201.538 83.2916 214.689 70.18 214.762 70.18C214.835 70.18 222.271 77.5415 231.355 86.5512ZM216.813 95.4876C218.718 96.0004 221.978 99.26 222.491 101.164C224.103 106.915 220.733 112.481 215.128 113.251C213.626 113.47 212.344 114.642 191.392 135.042L169.231 156.578L151.832 156.614H134.432L114.615 176.465C96.63 194.484 94.7253 196.499 94.5055 197.817C93.2967 204.812 85.2747 207.742 79.4506 203.311C75.3846 200.198 75.4212 192.69 79.5238 189.284C80.9158 188.112 83.7729 187.013 85.3846 187.013C85.9341 187.013 92.3443 180.86 107.802 165.404L129.414 143.796H146.923H164.469L184.432 124.311C204.249 104.937 204.396 104.79 204.652 103.032C205.568 97.209 210.952 93.8761 216.813 95.4876Z" fill="#0184B5"/>
</svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

+4 -1
View File
@@ -17,6 +17,7 @@ const ICON_PATHS: Record<string, string> = {
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,
@@ -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({
</div>
{/* 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' && (
<div