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)
370 lines
11 KiB
Go
370 lines
11 KiB
Go
package kucoin
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/hmac"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"math"
|
|
"net/http"
|
|
"nofx/logger"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// KuCoin Futures API endpoints
|
|
const (
|
|
kucoinBaseURL = "https://api-futures.kucoin.com"
|
|
kucoinAccountPath = "/api/v1/account-overview"
|
|
kucoinPositionPath = "/api/v1/positions"
|
|
kucoinOrderPath = "/api/v1/orders"
|
|
kucoinLeveragePath = "/api/v1/position/margin/leverage"
|
|
kucoinTickerPath = "/api/v1/ticker"
|
|
kucoinContractsPath = "/api/v1/contracts/active"
|
|
kucoinCancelOrderPath = "/api/v1/orders"
|
|
kucoinStopOrderPath = "/api/v1/stopOrders"
|
|
kucoinCancelStopPath = "/api/v1/stopOrders"
|
|
kucoinPositionModePath = "/api/v1/position/margin/auto-deposit-status"
|
|
kucoinFillsPath = "/api/v1/fills"
|
|
kucoinRecentFillsPath = "/api/v1/recentFills"
|
|
)
|
|
|
|
// API channel configuration
|
|
const (
|
|
kcPartnerID = "NoFxFutures"
|
|
kcPartnerKey = "d7c05b0c-c81b-4630-8fa8-ca6d049d3aae"
|
|
)
|
|
|
|
// KuCoinTrader implements types.Trader interface for KuCoin Futures
|
|
type KuCoinTrader struct {
|
|
apiKey string
|
|
secretKey string
|
|
passphrase string
|
|
|
|
// HTTP client
|
|
httpClient *http.Client
|
|
|
|
// Server time offset (local - server) in milliseconds
|
|
serverTimeOffset int64
|
|
serverTimeMutex sync.RWMutex
|
|
|
|
// Balance cache
|
|
cachedBalance map[string]interface{}
|
|
balanceCacheTime time.Time
|
|
balanceCacheMutex sync.RWMutex
|
|
|
|
// Positions cache
|
|
cachedPositions []map[string]interface{}
|
|
positionsCacheTime time.Time
|
|
positionsCacheMutex sync.RWMutex
|
|
|
|
// Contract info cache
|
|
contractsCache map[string]*KuCoinContract
|
|
contractsCacheTime time.Time
|
|
contractsCacheMutex sync.RWMutex
|
|
|
|
// Cache duration
|
|
cacheDuration time.Duration
|
|
}
|
|
|
|
// KuCoinContract represents contract info
|
|
type KuCoinContract struct {
|
|
Symbol string // Symbol
|
|
BaseCurrency string // Base currency
|
|
Multiplier float64 // Contract multiplier
|
|
LotSize float64 // Minimum order quantity (lot size)
|
|
TickSize float64 // Minimum price increment
|
|
MaxOrderQty float64 // Maximum order quantity
|
|
MaxLeverage float64 // Maximum leverage
|
|
MarkPrice float64 // Current mark price
|
|
IsInverse bool // Is inverse contract
|
|
QuoteCurrency string // Quote currency
|
|
IndexPriceScale int // Index price decimal places
|
|
}
|
|
|
|
// KuCoinResponse represents KuCoin API response
|
|
type KuCoinResponse struct {
|
|
Code string `json:"code"`
|
|
Msg string `json:"msg"`
|
|
Data json.RawMessage `json:"data"`
|
|
}
|
|
|
|
// NewKuCoinTrader creates a new KuCoin trader instance
|
|
func NewKuCoinTrader(apiKey, secretKey, passphrase string) *KuCoinTrader {
|
|
httpClient := &http.Client{
|
|
Timeout: 30 * time.Second,
|
|
Transport: http.DefaultTransport,
|
|
}
|
|
|
|
trader := &KuCoinTrader{
|
|
apiKey: apiKey,
|
|
secretKey: secretKey,
|
|
passphrase: passphrase,
|
|
httpClient: httpClient,
|
|
cacheDuration: 15 * time.Second,
|
|
contractsCache: make(map[string]*KuCoinContract),
|
|
}
|
|
|
|
// Sync server time on initialization
|
|
if err := trader.syncServerTime(); err != nil {
|
|
logger.Warnf("⚠️ Failed to sync KuCoin server time: %v (will retry on first request)", err)
|
|
}
|
|
|
|
logger.Infof("✓ KuCoin Futures trader initialized")
|
|
return trader
|
|
}
|
|
|
|
// syncServerTime fetches KuCoin server time and calculates offset
|
|
func (t *KuCoinTrader) syncServerTime() error {
|
|
resp, err := t.httpClient.Get(kucoinBaseURL + "/api/v1/timestamp")
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get server time: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to read response: %w", err)
|
|
}
|
|
|
|
var result struct {
|
|
Code string `json:"code"`
|
|
Data int64 `json:"data"`
|
|
}
|
|
|
|
if err := json.Unmarshal(body, &result); err != nil {
|
|
return fmt.Errorf("failed to parse response: %w", err)
|
|
}
|
|
|
|
if result.Code != "200000" {
|
|
return fmt.Errorf("server time API error: %s", result.Code)
|
|
}
|
|
|
|
serverTime := result.Data
|
|
localTime := time.Now().UnixMilli()
|
|
offset := localTime - serverTime
|
|
|
|
t.serverTimeMutex.Lock()
|
|
t.serverTimeOffset = offset
|
|
t.serverTimeMutex.Unlock()
|
|
|
|
logger.Infof("✓ KuCoin time synced: offset=%dms (local %d - server %d)", offset, localTime, serverTime)
|
|
return nil
|
|
}
|
|
|
|
// getTimestamp returns the current timestamp adjusted for server time offset
|
|
func (t *KuCoinTrader) getTimestamp() string {
|
|
t.serverTimeMutex.RLock()
|
|
offset := t.serverTimeOffset
|
|
t.serverTimeMutex.RUnlock()
|
|
|
|
// Subtract offset to get server time from local time
|
|
timestamp := time.Now().UnixMilli() - offset
|
|
return strconv.FormatInt(timestamp, 10)
|
|
}
|
|
|
|
// sign generates KuCoin API signature
|
|
func (t *KuCoinTrader) sign(timestamp, method, requestPath, body string) string {
|
|
// KuCoin signature: base64(HMAC-SHA256(timestamp + method + endpoint + body, secretKey))
|
|
preHash := timestamp + method + requestPath + body
|
|
h := hmac.New(sha256.New, []byte(t.secretKey))
|
|
h.Write([]byte(preHash))
|
|
return base64.StdEncoding.EncodeToString(h.Sum(nil))
|
|
}
|
|
|
|
// signPassphrase signs the passphrase with API v2
|
|
func (t *KuCoinTrader) signPassphrase(passphrase string) string {
|
|
h := hmac.New(sha256.New, []byte(t.secretKey))
|
|
h.Write([]byte(passphrase))
|
|
return base64.StdEncoding.EncodeToString(h.Sum(nil))
|
|
}
|
|
|
|
// signPartner generates partner signature: base64(HMAC-SHA256(timestamp + partner + apiKey, partnerKey))
|
|
func (t *KuCoinTrader) signPartner(timestamp string) string {
|
|
preHash := timestamp + kcPartnerID + t.apiKey
|
|
h := hmac.New(sha256.New, []byte(kcPartnerKey))
|
|
h.Write([]byte(preHash))
|
|
return base64.StdEncoding.EncodeToString(h.Sum(nil))
|
|
}
|
|
|
|
// doRequest executes HTTP request
|
|
func (t *KuCoinTrader) doRequest(method, path string, body interface{}) ([]byte, error) {
|
|
var bodyBytes []byte
|
|
var err error
|
|
|
|
if body != nil {
|
|
bodyBytes, err = json.Marshal(body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to serialize request body: %w", err)
|
|
}
|
|
}
|
|
|
|
timestamp := t.getTimestamp()
|
|
signature := t.sign(timestamp, method, path, string(bodyBytes))
|
|
signedPassphrase := t.signPassphrase(t.passphrase)
|
|
|
|
req, err := http.NewRequest(method, kucoinBaseURL+path, bytes.NewReader(bodyBytes))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
|
}
|
|
|
|
// Authentication headers
|
|
req.Header.Set("KC-API-KEY", t.apiKey)
|
|
req.Header.Set("KC-API-SIGN", signature)
|
|
req.Header.Set("KC-API-TIMESTAMP", timestamp)
|
|
req.Header.Set("KC-API-PASSPHRASE", signedPassphrase)
|
|
req.Header.Set("KC-API-KEY-VERSION", "3")
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
// Partner headers
|
|
req.Header.Set("KC-API-PARTNER", kcPartnerID)
|
|
req.Header.Set("KC-API-PARTNER-SIGN", t.signPartner(timestamp))
|
|
req.Header.Set("KC-API-PARTNER-VERIFY", "true")
|
|
|
|
resp, err := t.httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("request failed: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
respBody, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read response: %w", err)
|
|
}
|
|
|
|
var kcResp KuCoinResponse
|
|
if err := json.Unmarshal(respBody, &kcResp); err != nil {
|
|
return nil, fmt.Errorf("failed to parse response: %w, body: %s", err, string(respBody))
|
|
}
|
|
|
|
if kcResp.Code != "200000" {
|
|
// If timestamp error, try to re-sync server time
|
|
if kcResp.Code == "400002" || strings.Contains(kcResp.Msg, "TIMESTAMP") {
|
|
logger.Warnf("⚠️ KuCoin timestamp error, re-syncing server time...")
|
|
if err := t.syncServerTime(); err != nil {
|
|
logger.Warnf("⚠️ Failed to re-sync server time: %v", err)
|
|
}
|
|
}
|
|
return nil, fmt.Errorf("KuCoin API error: code=%s, msg=%s", kcResp.Code, kcResp.Msg)
|
|
}
|
|
|
|
return kcResp.Data, nil
|
|
}
|
|
|
|
// convertSymbol converts generic symbol to KuCoin format
|
|
// e.g. BTCUSDT -> XBTUSDTM (KuCoin uses XBT for BTC)
|
|
func (t *KuCoinTrader) convertSymbol(symbol string) string {
|
|
// Remove USDT suffix
|
|
base := strings.TrimSuffix(symbol, "USDT")
|
|
// KuCoin uses XBT instead of BTC
|
|
if base == "BTC" {
|
|
base = "XBT"
|
|
}
|
|
return fmt.Sprintf("%sUSDTM", base)
|
|
}
|
|
|
|
// convertSymbolBack converts KuCoin format back to generic symbol
|
|
// e.g. XBTUSDTM -> BTCUSDT
|
|
func (t *KuCoinTrader) convertSymbolBack(kcSymbol string) string {
|
|
// Remove M suffix
|
|
sym := strings.TrimSuffix(kcSymbol, "M")
|
|
// Convert XBT back to BTC
|
|
if strings.HasPrefix(sym, "XBT") {
|
|
sym = "BTC" + strings.TrimPrefix(sym, "XBT")
|
|
}
|
|
return sym
|
|
}
|
|
|
|
// getContract gets contract info
|
|
func (t *KuCoinTrader) getContract(symbol string) (*KuCoinContract, error) {
|
|
kcSymbol := t.convertSymbol(symbol)
|
|
|
|
// Check cache
|
|
t.contractsCacheMutex.RLock()
|
|
if contract, ok := t.contractsCache[kcSymbol]; ok && time.Since(t.contractsCacheTime) < 5*time.Minute {
|
|
t.contractsCacheMutex.RUnlock()
|
|
return contract, nil
|
|
}
|
|
t.contractsCacheMutex.RUnlock()
|
|
|
|
// Get contract info
|
|
data, err := t.doRequest("GET", kucoinContractsPath, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var contracts []struct {
|
|
Symbol string `json:"symbol"`
|
|
BaseCurrency string `json:"baseCurrency"`
|
|
Multiplier float64 `json:"multiplier"`
|
|
LotSize int64 `json:"lotSize"`
|
|
TickSize float64 `json:"tickSize"`
|
|
MaxOrderQty int64 `json:"maxOrderQty"`
|
|
MaxLeverage int `json:"maxLeverage"`
|
|
MarkPrice float64 `json:"markPrice"`
|
|
IsInverse bool `json:"isInverse"`
|
|
QuoteCurrency string `json:"quoteCurrency"`
|
|
}
|
|
|
|
if err := json.Unmarshal(data, &contracts); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Update cache with all contracts
|
|
t.contractsCacheMutex.Lock()
|
|
for _, c := range contracts {
|
|
t.contractsCache[c.Symbol] = &KuCoinContract{
|
|
Symbol: c.Symbol,
|
|
BaseCurrency: c.BaseCurrency,
|
|
Multiplier: c.Multiplier,
|
|
LotSize: float64(c.LotSize),
|
|
TickSize: c.TickSize,
|
|
MaxOrderQty: float64(c.MaxOrderQty),
|
|
MaxLeverage: float64(c.MaxLeverage),
|
|
MarkPrice: c.MarkPrice,
|
|
IsInverse: c.IsInverse,
|
|
QuoteCurrency: c.QuoteCurrency,
|
|
}
|
|
}
|
|
t.contractsCacheTime = time.Now()
|
|
t.contractsCacheMutex.Unlock()
|
|
|
|
// Return requested contract
|
|
t.contractsCacheMutex.RLock()
|
|
contract, ok := t.contractsCache[kcSymbol]
|
|
t.contractsCacheMutex.RUnlock()
|
|
|
|
if !ok {
|
|
return nil, fmt.Errorf("contract info not found: %s", kcSymbol)
|
|
}
|
|
|
|
return contract, nil
|
|
}
|
|
|
|
// quantityToLots converts quantity (in base asset) to lots
|
|
func (t *KuCoinTrader) quantityToLots(symbol string, quantity float64) (int64, error) {
|
|
contract, err := t.getContract(symbol)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
// lots = quantity / multiplier
|
|
lots := quantity / contract.Multiplier
|
|
|
|
// Round to integer (KuCoin uses integer lots)
|
|
lotsInt := int64(math.Round(lots))
|
|
|
|
// Check max order quantity
|
|
if contract.MaxOrderQty > 0 && float64(lotsInt) > contract.MaxOrderQty {
|
|
logger.Infof("⚠️ KuCoin order quantity %d exceeds max %d, reducing to max", lotsInt, int64(contract.MaxOrderQty))
|
|
lotsInt = int64(contract.MaxOrderQty)
|
|
}
|
|
|
|
return lotsInt, nil
|
|
}
|