mirror of
https://github.com/laoxong/nofx.git
synced 2026-06-04 09:58:22 +08:00
cb31782be4
- Rename experience/ to telemetry/ for clarity - Split 15+ large Go files (800-2200 lines) into focused modules: kernel/engine.go, backtest/runner.go, market/data.go, store/position.go, api/handler_trader.go, trader/auto_trader_grid.go, and 9 exchange traders - Split frontend monoliths: types.ts, api.ts, AITradersPage.tsx, BacktestPage.tsx into domain-specific modules with barrel re-exports - Remove stale files: screenshots, .yml.old, pyproject.toml - Remove unused scripts/ and cmd/ directories - Remove broken/outdated test files (network-dependent, stale expectations)
322 lines
8.3 KiB
Go
322 lines
8.3 KiB
Go
package indodax
|
|
|
|
import (
|
|
"crypto/hmac"
|
|
"crypto/sha512"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"nofx/logger"
|
|
"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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
}
|