mirror of
https://github.com/laoxong/nofx.git
synced 2026-06-04 09:58:22 +08:00
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:
committed by
GitHub
parent
3358c5a53e
commit
27a7491cd1
+32
-25
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user