Files
tinkle-community cb31782be4 refactor: split large files and clean up project structure
- 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)
2026-03-12 12:53:57 +08:00

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
}