Files
nofx/trader/okx/trader.go
T
tinkle-community 093d2a329d feat(gate): complete Gate.io exchange integration with trader refactoring
Gate.io Integration:
- Add Gate trader with full Trader interface implementation
- Add order_sync.go for background trade synchronization
- Fix quantity display (convert contracts to actual tokens via quanto_multiplier)
- Fix fill price return in OpenLong/OpenShort/CloseLong/CloseShort
- Add Gate-specific CoinAnk K-line data source support
- Add Gate to supported exchanges in frontend and backend
- Add Gate/KuCoin logo SVG icons

Trader Package Refactoring:
- Move exchange-specific code into subdirectories (binance/, bybit/, okx/, bitget/, hyperliquid/, aster/, lighter/, gate/)
- Create types/ package for shared types to avoid circular dependencies
- Move TraderTestSuite to trader/testutil package to avoid import cycles
- Update market.GetWithExchange to support exchange-specific data
2026-01-31 23:15:17 +08:00

1683 lines
47 KiB
Go

package okx
import (
"bytes"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"nofx/logger"
"strconv"
"strings"
"sync"
"time"
"nofx/trader/types"
)
// OKX API endpoints
const (
okxBaseURL = "https://www.okx.com"
okxAccountPath = "/api/v5/account/balance"
okxPositionPath = "/api/v5/account/positions"
okxOrderPath = "/api/v5/trade/order"
okxLeveragePath = "/api/v5/account/set-leverage"
okxTickerPath = "/api/v5/market/ticker"
okxInstrumentsPath = "/api/v5/public/instruments"
okxCancelOrderPath = "/api/v5/trade/cancel-order"
okxPendingOrdersPath = "/api/v5/trade/orders-pending"
okxAlgoOrderPath = "/api/v5/trade/order-algo"
okxCancelAlgoPath = "/api/v5/trade/cancel-algos"
okxAlgoPendingPath = "/api/v5/trade/orders-algo-pending"
okxPositionModePath = "/api/v5/account/set-position-mode"
okxAccountConfigPath = "/api/v5/account/config"
)
// OKXTrader OKX futures trader
type OKXTrader struct {
apiKey string
secretKey string
passphrase string
// Margin mode setting
isCrossMargin bool
// Position mode: "long_short_mode" (hedge) or "net_mode" (one-way)
positionMode string
// HTTP client (proxy disabled)
httpClient *http.Client
// Balance cache
cachedBalance map[string]interface{}
balanceCacheTime time.Time
balanceCacheMutex sync.RWMutex
// Positions cache
cachedPositions []map[string]interface{}
positionsCacheTime time.Time
positionsCacheMutex sync.RWMutex
// Instrument info cache
instrumentsCache map[string]*OKXInstrument
instrumentsCacheTime time.Time
instrumentsCacheMutex sync.RWMutex
// Cache duration
cacheDuration time.Duration
}
// OKXInstrument OKX instrument info
type OKXInstrument struct {
InstID string // Instrument ID
CtVal float64 // Contract value
CtMult float64 // Contract multiplier
LotSz float64 // Minimum order size
MinSz float64 // Minimum order size
MaxMktSz float64 // Maximum market order size
TickSz float64 // Minimum price increment
CtType string // Contract type
}
// OKXResponse OKX API response
type OKXResponse struct {
Code string `json:"code"`
Msg string `json:"msg"`
Data json.RawMessage `json:"data"`
}
// genOkxClOrdID generates OKX order ID
func genOkxClOrdID() string {
timestamp := time.Now().UnixNano() % 10000000000000
randomBytes := make([]byte, 4)
rand.Read(randomBytes)
randomHex := hex.EncodeToString(randomBytes)
// OKX clOrdId max 32 characters
orderID := fmt.Sprintf("%s%d%s", okxTag, timestamp, randomHex)
if len(orderID) > 32 {
orderID = orderID[:32]
}
return orderID
}
// NewOKXTrader creates OKX trader
func NewOKXTrader(apiKey, secretKey, passphrase string) *OKXTrader {
// Use default transport which respects system proxy settings
// OKX requires proxy in China due to DNS pollution
httpClient := &http.Client{
Timeout: 30 * time.Second,
Transport: http.DefaultTransport,
}
trader := &OKXTrader{
apiKey: apiKey,
secretKey: secretKey,
passphrase: passphrase,
httpClient: httpClient,
cacheDuration: 15 * time.Second,
instrumentsCache: make(map[string]*OKXInstrument),
}
// Get current position mode first
if err := trader.detectPositionMode(); err != nil {
logger.Infof("⚠️ Failed to detect OKX position mode: %v, assuming dual mode", err)
trader.positionMode = "long_short_mode"
}
// Try to set dual position mode (only if not already)
if trader.positionMode != "long_short_mode" {
if err := trader.setPositionMode(); err != nil {
logger.Infof("⚠️ Failed to set OKX position mode: %v (current mode: %s)", err, trader.positionMode)
}
}
logger.Infof("✓ OKX trader initialized with position mode: %s", trader.positionMode)
return trader
}
// detectPositionMode gets current position mode from account config
func (t *OKXTrader) detectPositionMode() error {
data, err := t.doRequest("GET", okxAccountConfigPath, nil)
if err != nil {
return fmt.Errorf("failed to get account config: %w", err)
}
var configs []struct {
PosMode string `json:"posMode"`
}
if err := json.Unmarshal(data, &configs); err != nil {
return fmt.Errorf("failed to parse account config: %w", err)
}
if len(configs) > 0 {
t.positionMode = configs[0].PosMode
logger.Infof("✓ Detected OKX position mode: %s", t.positionMode)
}
return nil
}
// setPositionMode sets dual position mode
func (t *OKXTrader) setPositionMode() error {
body := map[string]string{
"posMode": "long_short_mode", // Dual position mode
}
_, err := t.doRequest("POST", okxPositionModePath, body)
if err != nil {
// Ignore error if already in dual position mode
if strings.Contains(err.Error(), "already") || strings.Contains(err.Error(), "Position mode is not modified") {
logger.Infof(" ✓ OKX account is already in dual position mode")
return nil
}
return err
}
logger.Infof(" ✓ OKX account switched to dual position mode")
return nil
}
// sign generates OKX API signature
func (t *OKXTrader) sign(timestamp, method, requestPath, body string) string {
preHash := timestamp + method + requestPath + body
h := hmac.New(sha256.New, []byte(t.secretKey))
h.Write([]byte(preHash))
return base64.StdEncoding.EncodeToString(h.Sum(nil))
}
// doRequest executes HTTP request
func (t *OKXTrader) 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 := time.Now().UTC().Format("2006-01-02T15:04:05.000Z")
signature := t.sign(timestamp, method, path, string(bodyBytes))
req, err := http.NewRequest(method, okxBaseURL+path, bytes.NewReader(bodyBytes))
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("OK-ACCESS-KEY", t.apiKey)
req.Header.Set("OK-ACCESS-SIGN", signature)
req.Header.Set("OK-ACCESS-TIMESTAMP", timestamp)
req.Header.Set("OK-ACCESS-PASSPHRASE", t.passphrase)
req.Header.Set("Content-Type", "application/json")
// Set request header
req.Header.Set("x-simulated-trading", "0")
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 okxResp OKXResponse
if err := json.Unmarshal(respBody, &okxResp); err != nil {
return nil, fmt.Errorf("failed to parse response: %w", err)
}
// code=1 indicates partial success, need to check specific results in data
// code=2 indicates complete failure
if okxResp.Code != "0" && okxResp.Code != "1" {
return nil, fmt.Errorf("OKX API error: code=%s, msg=%s", okxResp.Code, okxResp.Msg)
}
return okxResp.Data, nil
}
// convertSymbol converts generic symbol to OKX format
// e.g. BTCUSDT -> BTC-USDT-SWAP
func (t *OKXTrader) convertSymbol(symbol string) string {
// Remove USDT suffix and build OKX format
base := strings.TrimSuffix(symbol, "USDT")
return fmt.Sprintf("%s-USDT-SWAP", base)
}
// convertSymbolBack converts OKX format back to generic symbol
// e.g. BTC-USDT-SWAP -> BTCUSDT
func (t *OKXTrader) convertSymbolBack(instId string) string {
parts := strings.Split(instId, "-")
if len(parts) >= 2 {
return parts[0] + parts[1]
}
return instId
}
// GetBalance gets account balance
func (t *OKXTrader) GetBalance() (map[string]interface{}, error) {
// Check cache
t.balanceCacheMutex.RLock()
if t.cachedBalance != nil && time.Since(t.balanceCacheTime) < t.cacheDuration {
t.balanceCacheMutex.RUnlock()
logger.Infof("✓ Using cached OKX account balance")
return t.cachedBalance, nil
}
t.balanceCacheMutex.RUnlock()
logger.Infof("🔄 Calling OKX API to get account balance...")
data, err := t.doRequest("GET", okxAccountPath, nil)
if err != nil {
return nil, fmt.Errorf("failed to get account balance: %w", err)
}
var balances []struct {
TotalEq string `json:"totalEq"`
AdjEq string `json:"adjEq"`
IsoEq string `json:"isoEq"`
OrdFroz string `json:"ordFroz"`
Details []struct {
Ccy string `json:"ccy"`
Eq string `json:"eq"`
CashBal string `json:"cashBal"`
AvailBal string `json:"availBal"`
UPL string `json:"upl"`
} `json:"details"`
}
if err := json.Unmarshal(data, &balances); err != nil {
return nil, fmt.Errorf("failed to parse balance data: %w", err)
}
if len(balances) == 0 {
return nil, fmt.Errorf("no balance data received")
}
balance := balances[0]
// Find USDT balance
var usdtAvail, usdtUPL float64
for _, detail := range balance.Details {
if detail.Ccy == "USDT" {
usdtAvail, _ = strconv.ParseFloat(detail.AvailBal, 64)
usdtUPL, _ = strconv.ParseFloat(detail.UPL, 64)
break
}
}
totalEq, _ := strconv.ParseFloat(balance.TotalEq, 64)
result := map[string]interface{}{
"totalWalletBalance": totalEq,
"availableBalance": usdtAvail,
"totalUnrealizedProfit": usdtUPL,
}
logger.Infof("✓ OKX balance: Total equity=%.2f, Available=%.2f, Unrealized PnL=%.2f", totalEq, usdtAvail, usdtUPL)
// Update cache
t.balanceCacheMutex.Lock()
t.cachedBalance = result
t.balanceCacheTime = time.Now()
t.balanceCacheMutex.Unlock()
return result, nil
}
// GetPositions gets all positions
func (t *OKXTrader) GetPositions() ([]map[string]interface{}, error) {
// Check cache
t.positionsCacheMutex.RLock()
if t.cachedPositions != nil && time.Since(t.positionsCacheTime) < t.cacheDuration {
t.positionsCacheMutex.RUnlock()
logger.Infof("✓ Using cached OKX positions")
return t.cachedPositions, nil
}
t.positionsCacheMutex.RUnlock()
logger.Infof("🔄 Calling OKX API to get positions...")
data, err := t.doRequest("GET", okxPositionPath+"?instType=SWAP", nil)
if err != nil {
return nil, fmt.Errorf("failed to get positions: %w", err)
}
var positions []struct {
InstId string `json:"instId"`
PosSide string `json:"posSide"`
Pos string `json:"pos"`
AvgPx string `json:"avgPx"`
MarkPx string `json:"markPx"`
Upl string `json:"upl"`
Lever string `json:"lever"`
LiqPx string `json:"liqPx"`
Margin string `json:"margin"`
MgnMode string `json:"mgnMode"` // Margin mode: "cross" or "isolated"
CTime string `json:"cTime"` // Position created time (ms)
UTime string `json:"uTime"` // Position last update time (ms)
}
if err := json.Unmarshal(data, &positions); err != nil {
return nil, fmt.Errorf("failed to parse position data: %w", err)
}
logger.Infof("🔍 OKX raw positions response: %d positions", len(positions))
var result []map[string]interface{}
for _, pos := range positions {
logger.Infof("🔍 OKX raw position: instId=%s, posSide=%s, pos=%s, mgnMode=%s", pos.InstId, pos.PosSide, pos.Pos, pos.MgnMode)
contractCount, _ := strconv.ParseFloat(pos.Pos, 64)
if contractCount == 0 {
continue
}
entryPrice, _ := strconv.ParseFloat(pos.AvgPx, 64)
markPrice, _ := strconv.ParseFloat(pos.MarkPx, 64)
upl, _ := strconv.ParseFloat(pos.Upl, 64)
leverage, _ := strconv.ParseFloat(pos.Lever, 64)
liqPrice, _ := strconv.ParseFloat(pos.LiqPx, 64)
// Convert symbol format
symbol := t.convertSymbolBack(pos.InstId)
logger.Infof("🔍 OKX symbol conversion: %s → %s", pos.InstId, symbol)
// Determine direction and ensure contractCount is positive
side := "long"
if pos.PosSide == "short" {
side = "short"
}
// OKX short position's pos is negative, need to take absolute value
if contractCount < 0 {
contractCount = -contractCount
}
// Convert contract count to actual position amount (in base asset)
// positionAmt = contractCount * ctVal
inst, err := t.getInstrument(symbol)
posAmt := contractCount
if err == nil && inst.CtVal > 0 {
posAmt = contractCount * inst.CtVal
logger.Debugf(" 📊 OKX position %s: contracts=%.4f, ctVal=%.6f, posAmt=%.6f", symbol, contractCount, inst.CtVal, posAmt)
}
// Parse timestamps
cTime, _ := strconv.ParseInt(pos.CTime, 10, 64)
uTime, _ := strconv.ParseInt(pos.UTime, 10, 64)
// Default to cross margin mode if not specified
mgnMode := pos.MgnMode
if mgnMode == "" {
mgnMode = "cross"
}
posMap := map[string]interface{}{
"symbol": symbol,
"positionAmt": posAmt,
"entryPrice": entryPrice,
"markPrice": markPrice,
"unRealizedProfit": upl,
"leverage": leverage,
"liquidationPrice": liqPrice,
"side": side,
"mgnMode": mgnMode, // Margin mode: "cross" or "isolated"
"createdTime": cTime, // Position open time (ms)
"updatedTime": uTime, // Position last update time (ms)
}
result = append(result, posMap)
}
// Update cache
t.positionsCacheMutex.Lock()
t.cachedPositions = result
t.positionsCacheTime = time.Now()
t.positionsCacheMutex.Unlock()
return result, nil
}
// InvalidatePositionCache clears the position cache to force fresh data on next call
func (t *OKXTrader) InvalidatePositionCache() {
t.positionsCacheMutex.Lock()
t.cachedPositions = nil
t.positionsCacheTime = time.Time{}
t.positionsCacheMutex.Unlock()
}
// getInstrument gets instrument info
func (t *OKXTrader) getInstrument(symbol string) (*OKXInstrument, error) {
instId := t.convertSymbol(symbol)
// Check cache
t.instrumentsCacheMutex.RLock()
if inst, ok := t.instrumentsCache[instId]; ok && time.Since(t.instrumentsCacheTime) < 5*time.Minute {
t.instrumentsCacheMutex.RUnlock()
return inst, nil
}
t.instrumentsCacheMutex.RUnlock()
// Get instrument info
path := fmt.Sprintf("%s?instType=SWAP&instId=%s", okxInstrumentsPath, instId)
data, err := t.doRequest("GET", path, nil)
if err != nil {
return nil, err
}
var instruments []struct {
InstId string `json:"instId"`
CtVal string `json:"ctVal"`
CtMult string `json:"ctMult"`
LotSz string `json:"lotSz"`
MinSz string `json:"minSz"`
MaxMktSz string `json:"maxMktSz"` // Maximum market order size
TickSz string `json:"tickSz"`
CtType string `json:"ctType"`
}
if err := json.Unmarshal(data, &instruments); err != nil {
return nil, err
}
if len(instruments) == 0 {
return nil, fmt.Errorf("instrument info not found: %s", instId)
}
inst := instruments[0]
ctVal, _ := strconv.ParseFloat(inst.CtVal, 64)
ctMult, _ := strconv.ParseFloat(inst.CtMult, 64)
lotSz, _ := strconv.ParseFloat(inst.LotSz, 64)
minSz, _ := strconv.ParseFloat(inst.MinSz, 64)
maxMktSz, _ := strconv.ParseFloat(inst.MaxMktSz, 64)
tickSz, _ := strconv.ParseFloat(inst.TickSz, 64)
instrument := &OKXInstrument{
InstID: inst.InstId,
CtVal: ctVal,
CtMult: ctMult,
LotSz: lotSz,
MinSz: minSz,
MaxMktSz: maxMktSz,
TickSz: tickSz,
CtType: inst.CtType,
}
// Update cache
t.instrumentsCacheMutex.Lock()
t.instrumentsCache[instId] = instrument
t.instrumentsCacheTime = time.Now()
t.instrumentsCacheMutex.Unlock()
return instrument, nil
}
// SetMarginMode sets margin mode
func (t *OKXTrader) SetMarginMode(symbol string, isCrossMargin bool) error {
instId := t.convertSymbol(symbol)
mgnMode := "isolated"
if isCrossMargin {
mgnMode = "cross"
}
body := map[string]interface{}{
"instId": instId,
"mgnMode": mgnMode,
}
_, err := t.doRequest("POST", "/api/v5/account/set-isolated-mode", body)
if err != nil {
// Ignore error if already in target mode
if strings.Contains(err.Error(), "already") {
logger.Infof(" ✓ %s margin mode is already %s", symbol, mgnMode)
return nil
}
// Cannot change when there are positions
if strings.Contains(err.Error(), "position") {
logger.Infof(" ⚠️ %s has positions, cannot change margin mode", symbol)
return nil
}
return err
}
logger.Infof(" ✓ %s margin mode set to %s", symbol, mgnMode)
return nil
}
// SetLeverage sets leverage
func (t *OKXTrader) SetLeverage(symbol string, leverage int) error {
instId := t.convertSymbol(symbol)
// Set leverage for both long and short
for _, posSide := range []string{"long", "short"} {
body := map[string]interface{}{
"instId": instId,
"lever": strconv.Itoa(leverage),
"mgnMode": "cross",
"posSide": posSide,
}
_, err := t.doRequest("POST", okxLeveragePath, body)
if err != nil {
// Ignore if already at target leverage
if strings.Contains(err.Error(), "same") {
continue
}
logger.Infof(" ⚠️ Failed to set %s %s leverage: %v", symbol, posSide, err)
}
}
logger.Infof(" ✓ %s leverage set to %dx", symbol, leverage)
return nil
}
// OpenLong opens long position
func (t *OKXTrader) OpenLong(symbol string, quantity float64, leverage int) (map[string]interface{}, error) {
// Cancel old orders
t.CancelAllOrders(symbol)
// Set leverage
if err := t.SetLeverage(symbol, leverage); err != nil {
logger.Infof(" ⚠️ Failed to set leverage: %v", err)
}
instId := t.convertSymbol(symbol)
// Get instrument info and calculate contract size
inst, err := t.getInstrument(symbol)
if err != nil {
return nil, fmt.Errorf("failed to get instrument info: %w", err)
}
// OKX uses contract count, need to convert quantity (in base asset) to contract count
// sz = quantity / ctVal (number of contracts = asset amount / asset per contract)
sz := quantity / inst.CtVal
szStr := t.formatSize(sz, inst)
logger.Infof(" 📊 OKX OpenLong: quantity=%.6f, ctVal=%.6f, contracts=%.2f", quantity, inst.CtVal, sz)
// Check max market order size limit
if inst.MaxMktSz > 0 && sz > inst.MaxMktSz {
logger.Infof(" ⚠️ OKX market order size %.2f exceeds max %.2f, reducing to max", sz, inst.MaxMktSz)
sz = inst.MaxMktSz
szStr = t.formatSize(sz, inst)
}
body := map[string]interface{}{
"instId": instId,
"tdMode": "cross",
"side": "buy",
"posSide": "long",
"ordType": "market",
"sz": szStr,
"clOrdId": genOkxClOrdID(),
"tag": okxTag,
}
data, err := t.doRequest("POST", okxOrderPath, body)
if err != nil {
return nil, fmt.Errorf("failed to open long position: %w", err)
}
var orders []struct {
OrdId string `json:"ordId"`
ClOrdId string `json:"clOrdId"`
SCode string `json:"sCode"`
SMsg string `json:"sMsg"`
}
if err := json.Unmarshal(data, &orders); err != nil {
return nil, fmt.Errorf("failed to parse order response: %w", err)
}
if len(orders) == 0 || orders[0].SCode != "0" {
msg := "unknown error"
if len(orders) > 0 {
msg = orders[0].SMsg
}
return nil, fmt.Errorf("failed to open long position: %s", msg)
}
logger.Infof("✓ OKX opened long position successfully: %s size: %s", symbol, szStr)
logger.Infof(" Order ID: %s", orders[0].OrdId)
return map[string]interface{}{
"orderId": orders[0].OrdId,
"symbol": symbol,
"status": "FILLED",
}, nil
}
// OpenShort opens short position
func (t *OKXTrader) OpenShort(symbol string, quantity float64, leverage int) (map[string]interface{}, error) {
// Cancel old orders
t.CancelAllOrders(symbol)
// Set leverage
if err := t.SetLeverage(symbol, leverage); err != nil {
logger.Infof(" ⚠️ Failed to set leverage: %v", err)
}
instId := t.convertSymbol(symbol)
// Get instrument info and calculate contract size
inst, err := t.getInstrument(symbol)
if err != nil {
return nil, fmt.Errorf("failed to get instrument info: %w", err)
}
// OKX uses contract count, need to convert quantity (in base asset) to contract count
// sz = quantity / ctVal (number of contracts = asset amount / asset per contract)
sz := quantity / inst.CtVal
szStr := t.formatSize(sz, inst)
logger.Infof(" 📊 OKX OpenShort: quantity=%.6f, ctVal=%.6f, contracts=%.2f", quantity, inst.CtVal, sz)
// Check max market order size limit
if inst.MaxMktSz > 0 && sz > inst.MaxMktSz {
logger.Infof(" ⚠️ OKX market order size %.2f exceeds max %.2f, reducing to max", sz, inst.MaxMktSz)
sz = inst.MaxMktSz
szStr = t.formatSize(sz, inst)
}
body := map[string]interface{}{
"instId": instId,
"tdMode": "cross",
"side": "sell",
"posSide": "short",
"ordType": "market",
"sz": szStr,
"clOrdId": genOkxClOrdID(),
"tag": okxTag,
}
data, err := t.doRequest("POST", okxOrderPath, body)
if err != nil {
return nil, fmt.Errorf("failed to open short position: %w", err)
}
var orders []struct {
OrdId string `json:"ordId"`
ClOrdId string `json:"clOrdId"`
SCode string `json:"sCode"`
SMsg string `json:"sMsg"`
}
if err := json.Unmarshal(data, &orders); err != nil {
return nil, fmt.Errorf("failed to parse order response: %w", err)
}
if len(orders) == 0 || orders[0].SCode != "0" {
msg := "unknown error"
if len(orders) > 0 {
msg = orders[0].SMsg
}
return nil, fmt.Errorf("failed to open short position: %s", msg)
}
logger.Infof("✓ OKX opened short position successfully: %s size: %s", symbol, szStr)
logger.Infof(" Order ID: %s", orders[0].OrdId)
return map[string]interface{}{
"orderId": orders[0].OrdId,
"symbol": symbol,
"status": "FILLED",
}, nil
}
// CloseLong closes long position
func (t *OKXTrader) CloseLong(symbol string, quantity float64) (map[string]interface{}, error) {
instId := t.convertSymbol(symbol)
// Get instrument info for contract conversion
inst, err := t.getInstrument(symbol)
if err != nil {
return nil, fmt.Errorf("failed to get instrument info: %w", err)
}
// Invalidate position cache and get fresh positions
t.InvalidatePositionCache()
positions, err := t.GetPositions()
if err != nil {
return nil, fmt.Errorf("failed to get positions: %w", err)
}
// Find actual position from exchange
var actualQty float64
var posFound bool
var posMgnMode string = "cross" // Default to cross margin
logger.Infof("🔍 OKX CloseLong: searching for symbol=%s in %d positions", symbol, len(positions))
for _, pos := range positions {
logger.Infof("🔍 OKX position: symbol=%v, side=%v, positionAmt=%v, mgnMode=%v", pos["symbol"], pos["side"], pos["positionAmt"], pos["mgnMode"])
if pos["symbol"] == symbol {
side := pos["side"].(string)
// In net_mode, "long" means positive position
// In dual mode, check explicit "long" side
if side == "long" || (t.positionMode == "net_mode" && side == "long") {
actualQty = pos["positionAmt"].(float64)
posFound = true
if mgnMode, ok := pos["mgnMode"].(string); ok && mgnMode != "" {
posMgnMode = mgnMode
}
logger.Infof("🔍 OKX CloseLong: found matching position! qty=%.6f, mgnMode=%s", actualQty, posMgnMode)
break
}
}
}
if !posFound || actualQty == 0 {
logger.Infof("🔍 OKX CloseLong: NO position found for %s LONG", symbol)
return map[string]interface{}{
"status": "NO_POSITION",
"message": fmt.Sprintf("No long position found for %s on OKX", symbol),
}, nil
}
// Use actual quantity from exchange (more accurate than passed quantity)
if quantity == 0 || quantity > actualQty {
quantity = actualQty
}
// Convert quantity (base asset) to contract count
// contracts = quantity / ctVal
contracts := quantity / inst.CtVal
szStr := t.formatSize(contracts, inst)
logger.Infof("🔻 OKX close long: symbol=%s, instId=%s, quantity=%.6f, ctVal=%.6f, contracts=%.2f, szStr=%s, posMode=%s, mgnMode=%s",
symbol, instId, quantity, inst.CtVal, contracts, szStr, t.positionMode, posMgnMode)
body := map[string]interface{}{
"instId": instId,
"tdMode": posMgnMode, // Use position's actual margin mode (cross or isolated)
"side": "sell",
"ordType": "market",
"sz": szStr,
"clOrdId": genOkxClOrdID(),
"tag": okxTag,
}
// Only add posSide in dual mode (long_short_mode)
if t.positionMode == "long_short_mode" {
body["posSide"] = "long"
}
data, err := t.doRequest("POST", okxOrderPath, body)
if err != nil {
return nil, fmt.Errorf("failed to close long position: %w", err)
}
var orders []struct {
OrdId string `json:"ordId"`
SCode string `json:"sCode"`
SMsg string `json:"sMsg"`
}
if err := json.Unmarshal(data, &orders); err != nil {
return nil, err
}
if len(orders) == 0 || orders[0].SCode != "0" {
msg := "unknown error"
if len(orders) > 0 {
msg = orders[0].SMsg
}
return nil, fmt.Errorf("failed to close long position: %s", msg)
}
logger.Infof("✓ OKX closed long position successfully: %s", symbol)
// Cancel pending orders after closing position
t.CancelAllOrders(symbol)
return map[string]interface{}{
"orderId": orders[0].OrdId,
"symbol": symbol,
"status": "FILLED",
}, nil
}
// CloseShort closes short position
func (t *OKXTrader) CloseShort(symbol string, quantity float64) (map[string]interface{}, error) {
instId := t.convertSymbol(symbol)
// Get instrument info for contract conversion
inst, err := t.getInstrument(symbol)
if err != nil {
return nil, fmt.Errorf("failed to get instrument info: %w", err)
}
// Invalidate position cache and get fresh positions
t.InvalidatePositionCache()
positions, err := t.GetPositions()
if err != nil {
return nil, fmt.Errorf("failed to get positions: %w", err)
}
// Find actual position from exchange
var actualQty float64
var posFound bool
var posMgnMode string = "cross" // Default to cross margin
logger.Infof("🔍 OKX CloseShort searching positions: symbol=%s, current position count=%d", symbol, len(positions))
for _, pos := range positions {
logger.Infof("🔍 OKX position: symbol=%v, side=%v, positionAmt=%v, mgnMode=%v",
pos["symbol"], pos["side"], pos["positionAmt"], pos["mgnMode"])
if pos["symbol"] == symbol && pos["side"] == "short" {
actualQty = pos["positionAmt"].(float64)
posFound = true
if mgnMode, ok := pos["mgnMode"].(string); ok && mgnMode != "" {
posMgnMode = mgnMode
}
logger.Infof("🔍 OKX found short position: quantity=%f (base asset), mgnMode=%s", actualQty, posMgnMode)
break
}
}
if !posFound || actualQty == 0 {
return map[string]interface{}{
"status": "NO_POSITION",
"message": fmt.Sprintf("No short position found for %s on OKX", symbol),
}, nil
}
// Use actual quantity from exchange (more accurate than passed quantity)
if quantity == 0 || quantity > actualQty {
quantity = actualQty
}
// Ensure quantity is positive (OKX sz parameter must be positive)
if quantity < 0 {
quantity = -quantity
}
// Convert quantity (base asset) to contract count
// contracts = quantity / ctVal
contracts := quantity / inst.CtVal
szStr := t.formatSize(contracts, inst)
logger.Infof("🔻 OKX close short: symbol=%s, quantity=%.6f, ctVal=%.6f, contracts=%.2f, szStr=%s, posMode=%s, mgnMode=%s",
symbol, quantity, inst.CtVal, contracts, szStr, t.positionMode, posMgnMode)
body := map[string]interface{}{
"instId": instId,
"tdMode": posMgnMode, // Use position's actual margin mode (cross or isolated)
"side": "buy",
"ordType": "market",
"sz": szStr,
"clOrdId": genOkxClOrdID(),
"tag": okxTag,
}
// Only add posSide in dual mode (long_short_mode)
if t.positionMode == "long_short_mode" {
body["posSide"] = "short"
}
logger.Infof("🔻 OKX close short request body: %+v", body)
data, err := t.doRequest("POST", okxOrderPath, body)
if err != nil {
return nil, fmt.Errorf("failed to close short position: %w", err)
}
var orders []struct {
OrdId string `json:"ordId"`
SCode string `json:"sCode"`
SMsg string `json:"sMsg"`
}
if err := json.Unmarshal(data, &orders); err != nil {
return nil, err
}
if len(orders) == 0 || orders[0].SCode != "0" {
msg := "unknown error"
if len(orders) > 0 {
msg = fmt.Sprintf("sCode=%s, sMsg=%s", orders[0].SCode, orders[0].SMsg)
}
logger.Infof("❌ OKX failed to close short position: %s, response: %s", msg, string(data))
return nil, fmt.Errorf("failed to close short position: %s", msg)
}
logger.Infof("✓ OKX closed short position successfully: %s, ordId=%s", symbol, orders[0].OrdId)
// Cancel pending orders after closing position
t.CancelAllOrders(symbol)
return map[string]interface{}{
"orderId": orders[0].OrdId,
"symbol": symbol,
"status": "FILLED",
}, nil
}
// GetMarketPrice gets market price
func (t *OKXTrader) GetMarketPrice(symbol string) (float64, error) {
instId := t.convertSymbol(symbol)
path := fmt.Sprintf("%s?instId=%s", okxTickerPath, instId)
data, err := t.doRequest("GET", path, nil)
if err != nil {
return 0, fmt.Errorf("failed to get price: %w", err)
}
var tickers []struct {
Last string `json:"last"`
}
if err := json.Unmarshal(data, &tickers); err != nil {
return 0, err
}
if len(tickers) == 0 {
return 0, fmt.Errorf("no price data received")
}
price, err := strconv.ParseFloat(tickers[0].Last, 64)
if err != nil {
return 0, err
}
return price, nil
}
// SetStopLoss sets stop loss order
func (t *OKXTrader) SetStopLoss(symbol string, positionSide string, quantity, stopPrice float64) error {
instId := t.convertSymbol(symbol)
// Get instrument info
inst, err := t.getInstrument(symbol)
if err != nil {
return fmt.Errorf("failed to get instrument info: %w", err)
}
// Calculate contract size: quantity (in base asset) / ctVal (asset per contract)
sz := quantity / inst.CtVal
szStr := t.formatSize(sz, inst)
// Determine direction
side := "sell"
posSide := "long"
if strings.ToUpper(positionSide) == "SHORT" {
side = "buy"
posSide = "short"
}
body := map[string]interface{}{
"instId": instId,
"tdMode": "cross",
"side": side,
"posSide": posSide,
"ordType": "conditional",
"sz": szStr,
"slTriggerPx": fmt.Sprintf("%.8f", stopPrice),
"slOrdPx": "-1", // Market price
"tag": okxTag,
}
_, err = t.doRequest("POST", okxAlgoOrderPath, body)
if err != nil {
return fmt.Errorf("failed to set stop loss: %w", err)
}
logger.Infof(" Stop loss price set: %.4f", stopPrice)
return nil
}
// SetTakeProfit sets take profit order
func (t *OKXTrader) SetTakeProfit(symbol string, positionSide string, quantity, takeProfitPrice float64) error {
instId := t.convertSymbol(symbol)
// Get instrument info
inst, err := t.getInstrument(symbol)
if err != nil {
return fmt.Errorf("failed to get instrument info: %w", err)
}
// Calculate contract size: quantity (in base asset) / ctVal (asset per contract)
sz := quantity / inst.CtVal
szStr := t.formatSize(sz, inst)
// Determine direction
side := "sell"
posSide := "long"
if strings.ToUpper(positionSide) == "SHORT" {
side = "buy"
posSide = "short"
}
body := map[string]interface{}{
"instId": instId,
"tdMode": "cross",
"side": side,
"posSide": posSide,
"ordType": "conditional",
"sz": szStr,
"tpTriggerPx": fmt.Sprintf("%.8f", takeProfitPrice),
"tpOrdPx": "-1", // Market price
"tag": okxTag,
}
_, err = t.doRequest("POST", okxAlgoOrderPath, body)
if err != nil {
return fmt.Errorf("failed to set take profit: %w", err)
}
logger.Infof(" Take profit price set: %.4f", takeProfitPrice)
return nil
}
// CancelStopLossOrders cancels stop loss orders
func (t *OKXTrader) CancelStopLossOrders(symbol string) error {
return t.cancelAlgoOrders(symbol, "sl")
}
// CancelTakeProfitOrders cancels take profit orders
func (t *OKXTrader) CancelTakeProfitOrders(symbol string) error {
return t.cancelAlgoOrders(symbol, "tp")
}
// cancelAlgoOrders cancels algo orders
func (t *OKXTrader) cancelAlgoOrders(symbol string, orderType string) error {
instId := t.convertSymbol(symbol)
// Get pending algo orders
path := fmt.Sprintf("%s?instType=SWAP&instId=%s&ordType=conditional", okxAlgoPendingPath, instId)
data, err := t.doRequest("GET", path, nil)
if err != nil {
return err
}
var orders []struct {
AlgoId string `json:"algoId"`
InstId string `json:"instId"`
}
if err := json.Unmarshal(data, &orders); err != nil {
return err
}
canceledCount := 0
for _, order := range orders {
body := []map[string]interface{}{
{
"algoId": order.AlgoId,
"instId": order.InstId,
},
}
_, err := t.doRequest("POST", okxCancelAlgoPath, body)
if err != nil {
logger.Infof(" ⚠️ Failed to cancel algo order: %v", err)
continue
}
canceledCount++
}
if canceledCount > 0 {
logger.Infof(" ✓ Canceled %d algo orders for %s", canceledCount, symbol)
}
return nil
}
// CancelAllOrders cancels all pending orders
func (t *OKXTrader) CancelAllOrders(symbol string) error {
instId := t.convertSymbol(symbol)
// Get pending orders
path := fmt.Sprintf("%s?instType=SWAP&instId=%s", okxPendingOrdersPath, instId)
data, err := t.doRequest("GET", path, nil)
if err != nil {
return err
}
var orders []struct {
OrdId string `json:"ordId"`
InstId string `json:"instId"`
}
if err := json.Unmarshal(data, &orders); err != nil {
return err
}
// Batch cancel
for _, order := range orders {
body := map[string]interface{}{
"instId": order.InstId,
"ordId": order.OrdId,
}
t.doRequest("POST", okxCancelOrderPath, body)
}
// Also cancel algo orders
t.cancelAlgoOrders(symbol, "")
if len(orders) > 0 {
logger.Infof(" ✓ Canceled all pending orders for %s", symbol)
}
return nil
}
// CancelStopOrders cancels stop loss and take profit orders
func (t *OKXTrader) CancelStopOrders(symbol string) error {
return t.cancelAlgoOrders(symbol, "")
}
// FormatQuantity formats quantity (converts base asset quantity to contract count)
func (t *OKXTrader) FormatQuantity(symbol string, quantity float64) (string, error) {
inst, err := t.getInstrument(symbol)
if err != nil {
return fmt.Sprintf("%.3f", quantity), nil
}
// OKX uses contract count: quantity (in base asset) / ctVal (asset per contract)
sz := quantity / inst.CtVal
return t.formatSize(sz, inst), nil
}
// formatSize formats contract size
func (t *OKXTrader) formatSize(sz float64, inst *OKXInstrument) string {
// Determine precision based on lotSz
if inst.LotSz >= 1 {
return fmt.Sprintf("%.0f", sz)
}
// Calculate decimal places
lotSzStr := fmt.Sprintf("%f", inst.LotSz)
dotIndex := strings.Index(lotSzStr, ".")
if dotIndex == -1 {
return fmt.Sprintf("%.0f", sz)
}
// Remove trailing zeros
lotSzStr = strings.TrimRight(lotSzStr, "0")
precision := len(lotSzStr) - dotIndex - 1
format := fmt.Sprintf("%%.%df", precision)
return fmt.Sprintf(format, sz)
}
// GetOrderStatus gets order status
func (t *OKXTrader) GetOrderStatus(symbol string, orderID string) (map[string]interface{}, error) {
instId := t.convertSymbol(symbol)
path := fmt.Sprintf("/api/v5/trade/order?instId=%s&ordId=%s", instId, orderID)
data, err := t.doRequest("GET", path, nil)
if err != nil {
return nil, fmt.Errorf("failed to get order status: %w", err)
}
var orders []struct {
OrdId string `json:"ordId"`
State string `json:"state"`
AvgPx string `json:"avgPx"`
AccFillSz string `json:"accFillSz"`
Fee string `json:"fee"`
Side string `json:"side"`
OrdType string `json:"ordType"`
CTime string `json:"cTime"`
UTime string `json:"uTime"`
}
if err := json.Unmarshal(data, &orders); err != nil {
return nil, err
}
if len(orders) == 0 {
return nil, fmt.Errorf("order not found")
}
order := orders[0]
avgPrice, _ := strconv.ParseFloat(order.AvgPx, 64)
fillSz, _ := strconv.ParseFloat(order.AccFillSz, 64) // This is in contracts
fee, _ := strconv.ParseFloat(order.Fee, 64)
cTime, _ := strconv.ParseInt(order.CTime, 10, 64)
uTime, _ := strconv.ParseInt(order.UTime, 10, 64)
// Convert contract count to base asset quantity
// executedQty = contracts * ctVal
executedQty := fillSz
inst, err := t.getInstrument(symbol)
if err == nil && inst.CtVal > 0 {
executedQty = fillSz * inst.CtVal
logger.Debugf(" 📊 OKX order %s: fillSz(contracts)=%.4f, ctVal=%.6f, executedQty=%.6f", orderID, fillSz, inst.CtVal, executedQty)
}
// Status mapping
statusMap := map[string]string{
"filled": "FILLED",
"live": "NEW",
"partially_filled": "PARTIALLY_FILLED",
"canceled": "CANCELED",
}
status := statusMap[order.State]
if status == "" {
status = order.State
}
return map[string]interface{}{
"orderId": order.OrdId,
"symbol": symbol,
"status": status,
"avgPrice": avgPrice,
"executedQty": executedQty,
"side": order.Side,
"type": order.OrdType,
"time": cTime,
"updateTime": uTime,
"commission": -fee, // OKX returns negative value
}, nil
}
// OKX order tag
var okxTag = func() string {
b, _ := base64.StdEncoding.DecodeString("NGMzNjNjODFlZGM1QkNERQ==")
return string(b)
}()
// GetClosedPnL retrieves closed position PnL records from OKX
// OKX API: /api/v5/account/positions-history
func (t *OKXTrader) GetClosedPnL(startTime time.Time, limit int) ([]types.ClosedPnLRecord, error) {
if limit <= 0 {
limit = 100
}
if limit > 100 {
limit = 100
}
// Build query path with parameters
path := fmt.Sprintf("/api/v5/account/positions-history?instType=SWAP&limit=%d", limit)
if !startTime.IsZero() {
path += fmt.Sprintf("&after=%d", startTime.UnixMilli())
}
data, err := t.doRequest("GET", path, nil)
if err != nil {
return nil, fmt.Errorf("failed to get positions history: %w", err)
}
var resp struct {
Code string `json:"code"`
Msg string `json:"msg"`
Data []struct {
InstID string `json:"instId"` // Instrument ID (e.g., "BTC-USDT-SWAP")
Direction string `json:"direction"` // Position direction: "long" or "short"
OpenAvgPx string `json:"openAvgPx"` // Average open price
CloseAvgPx string `json:"closeAvgPx"` // Average close price
CloseTotalPos string `json:"closeTotalPos"` // Closed position quantity
RealizedPnl string `json:"realizedPnl"` // Realized PnL
Fee string `json:"fee"` // Total fee
FundingFee string `json:"fundingFee"` // Funding fee
Lever string `json:"lever"` // Leverage
CTime string `json:"cTime"` // Position open time
UTime string `json:"uTime"` // Position close time
Type string `json:"type"` // Close type: 1=close position, 2=partial close, 3=liquidation, 4=partial liquidation
PosId string `json:"posId"` // Position ID
} `json:"data"`
}
if err := json.Unmarshal(data, &resp); err != nil {
return nil, fmt.Errorf("failed to parse response: %w", err)
}
if resp.Code != "0" {
return nil, fmt.Errorf("OKX API error: %s - %s", resp.Code, resp.Msg)
}
records := make([]types.ClosedPnLRecord, 0, len(resp.Data))
for _, pos := range resp.Data {
record := types.ClosedPnLRecord{}
// Convert instrument ID to standard format (BTC-USDT-SWAP -> BTCUSDT)
parts := strings.Split(pos.InstID, "-")
if len(parts) >= 2 {
record.Symbol = parts[0] + parts[1]
} else {
record.Symbol = pos.InstID
}
// Side
record.Side = pos.Direction // OKX already returns "long" or "short"
// Prices
record.EntryPrice, _ = strconv.ParseFloat(pos.OpenAvgPx, 64)
record.ExitPrice, _ = strconv.ParseFloat(pos.CloseAvgPx, 64)
// Quantity
record.Quantity, _ = strconv.ParseFloat(pos.CloseTotalPos, 64)
// PnL
record.RealizedPnL, _ = strconv.ParseFloat(pos.RealizedPnl, 64)
// Fee
fee, _ := strconv.ParseFloat(pos.Fee, 64)
fundingFee, _ := strconv.ParseFloat(pos.FundingFee, 64)
record.Fee = -fee + fundingFee // Fee is negative in OKX
// Leverage
lev, _ := strconv.ParseFloat(pos.Lever, 64)
record.Leverage = int(lev)
// Times
cTime, _ := strconv.ParseInt(pos.CTime, 10, 64)
uTime, _ := strconv.ParseInt(pos.UTime, 10, 64)
record.EntryTime = time.UnixMilli(cTime).UTC()
record.ExitTime = time.UnixMilli(uTime).UTC()
// Close type
switch pos.Type {
case "1", "2":
record.CloseType = "unknown" // Could be manual or AI, need to cross-reference
case "3", "4":
record.CloseType = "liquidation"
default:
record.CloseType = "unknown"
}
// Exchange ID
record.ExchangeID = pos.PosId
records = append(records, record)
}
return records, nil
}
// GetOpenOrders gets all open/pending orders for a symbol
func (t *OKXTrader) GetOpenOrders(symbol string) ([]types.OpenOrder, error) {
instId := t.convertSymbol(symbol)
var result []types.OpenOrder
// 1. Get pending limit orders
path := fmt.Sprintf("%s?instId=%s&instType=SWAP", okxPendingOrdersPath, instId)
data, err := t.doRequest("GET", path, nil)
if err != nil {
logger.Warnf("[OKX] Failed to get pending orders: %v", err)
}
if err == nil && data != nil {
var orders []struct {
OrdId string `json:"ordId"`
InstId string `json:"instId"`
Side string `json:"side"` // buy/sell
PosSide string `json:"posSide"` // long/short/net
OrdType string `json:"ordType"` // limit/market/post_only
Px string `json:"px"` // price
Sz string `json:"sz"` // size
State string `json:"state"` // live/partially_filled
}
if err := json.Unmarshal(data, &orders); err == nil {
for _, order := range orders {
price, _ := strconv.ParseFloat(order.Px, 64)
quantity, _ := strconv.ParseFloat(order.Sz, 64)
// Convert OKX side to standard format
side := strings.ToUpper(order.Side)
positionSide := strings.ToUpper(order.PosSide)
if positionSide == "NET" {
positionSide = "BOTH"
}
result = append(result, types.OpenOrder{
OrderID: order.OrdId,
Symbol: symbol,
Side: side,
PositionSide: positionSide,
Type: strings.ToUpper(order.OrdType),
Price: price,
StopPrice: 0,
Quantity: quantity,
Status: "NEW",
})
}
}
}
// 2. Get pending algo orders (stop-loss/take-profit)
// OKX requires ordType parameter for algo orders API
algoPath := fmt.Sprintf("%s?instId=%s&instType=SWAP&ordType=conditional", okxAlgoPendingPath, instId)
algoData, err := t.doRequest("GET", algoPath, nil)
if err != nil {
logger.Warnf("[OKX] Failed to get algo orders: %v", err)
}
if err == nil && algoData != nil {
var algoOrders []struct {
AlgoId string `json:"algoId"`
InstId string `json:"instId"`
Side string `json:"side"`
PosSide string `json:"posSide"`
OrdType string `json:"ordType"` // conditional/oco/trigger
TriggerPx string `json:"triggerPx"`
SlTriggerPx string `json:"slTriggerPx"` // Stop loss trigger price
TpTriggerPx string `json:"tpTriggerPx"` // Take profit trigger price
Sz string `json:"sz"`
State string `json:"state"`
}
if err := json.Unmarshal(algoData, &algoOrders); err == nil {
for _, order := range algoOrders {
quantity, _ := strconv.ParseFloat(order.Sz, 64)
side := strings.ToUpper(order.Side)
positionSide := strings.ToUpper(order.PosSide)
if positionSide == "NET" {
positionSide = "BOTH"
}
// Check for stop loss order (slTriggerPx is set)
if order.SlTriggerPx != "" {
slPrice, _ := strconv.ParseFloat(order.SlTriggerPx, 64)
if slPrice > 0 {
result = append(result, types.OpenOrder{
OrderID: order.AlgoId + "_sl",
Symbol: symbol,
Side: side,
PositionSide: positionSide,
Type: "STOP_MARKET",
Price: 0,
StopPrice: slPrice,
Quantity: quantity,
Status: "NEW",
})
}
}
// Check for take profit order (tpTriggerPx is set)
if order.TpTriggerPx != "" {
tpPrice, _ := strconv.ParseFloat(order.TpTriggerPx, 64)
if tpPrice > 0 {
result = append(result, types.OpenOrder{
OrderID: order.AlgoId + "_tp",
Symbol: symbol,
Side: side,
PositionSide: positionSide,
Type: "TAKE_PROFIT_MARKET",
Price: 0,
StopPrice: tpPrice,
Quantity: quantity,
Status: "NEW",
})
}
}
// Fallback for trigger orders (triggerPx is set)
if order.TriggerPx != "" && order.SlTriggerPx == "" && order.TpTriggerPx == "" {
triggerPrice, _ := strconv.ParseFloat(order.TriggerPx, 64)
if triggerPrice > 0 {
result = append(result, types.OpenOrder{
OrderID: order.AlgoId,
Symbol: symbol,
Side: side,
PositionSide: positionSide,
Type: "STOP_MARKET",
Price: 0,
StopPrice: triggerPrice,
Quantity: quantity,
Status: "NEW",
})
}
}
}
}
}
logger.Infof("✓ OKX GetOpenOrders: found %d open orders for %s", len(result), symbol)
return result, nil
}
// PlaceLimitOrder places a limit order for grid trading
// Implements GridTrader interface
func (t *OKXTrader) PlaceLimitOrder(req *types.LimitOrderRequest) (*types.LimitOrderResult, error) {
instId := t.convertSymbol(req.Symbol)
// Get instrument info
inst, err := t.getInstrument(req.Symbol)
if err != nil {
return nil, fmt.Errorf("failed to get instrument info: %w", err)
}
// Set leverage if specified
if req.Leverage > 0 {
if err := t.SetLeverage(req.Symbol, req.Leverage); err != nil {
logger.Warnf("[OKX] Failed to set leverage: %v", err)
}
}
// Convert quantity to contract size
sz := req.Quantity / inst.CtVal
szStr := t.formatSize(sz, inst)
// Determine side and position side
side := "buy"
posSide := "long"
if req.Side == "SELL" {
side = "sell"
posSide = "short"
}
body := map[string]interface{}{
"instId": instId,
"tdMode": "cross",
"side": side,
"posSide": posSide,
"ordType": "limit",
"sz": szStr,
"px": fmt.Sprintf("%.8f", req.Price),
"clOrdId": genOkxClOrdID(),
"tag": okxTag,
}
// Add reduce only if specified
if req.ReduceOnly {
body["reduceOnly"] = true
}
logger.Infof("[OKX] PlaceLimitOrder: %s %s @ %.4f, sz=%s", instId, side, req.Price, szStr)
data, err := t.doRequest("POST", okxOrderPath, body)
if err != nil {
return nil, fmt.Errorf("failed to place limit order: %w", err)
}
var orders []struct {
OrdId string `json:"ordId"`
ClOrdId string `json:"clOrdId"`
SCode string `json:"sCode"`
SMsg string `json:"sMsg"`
}
if err := json.Unmarshal(data, &orders); err != nil {
return nil, fmt.Errorf("failed to parse order response: %w", err)
}
if len(orders) == 0 {
return nil, fmt.Errorf("empty order response")
}
if orders[0].SCode != "0" {
return nil, fmt.Errorf("OKX order failed: %s", orders[0].SMsg)
}
logger.Infof("✓ [OKX] Limit order placed: %s %s @ %.4f, orderID=%s",
instId, side, req.Price, orders[0].OrdId)
return &types.LimitOrderResult{
OrderID: orders[0].OrdId,
ClientID: orders[0].ClOrdId,
Symbol: req.Symbol,
Side: req.Side,
PositionSide: req.PositionSide,
Price: req.Price,
Quantity: req.Quantity,
Status: "NEW",
}, nil
}
// CancelOrder cancels a specific order by ID
// Implements GridTrader interface
func (t *OKXTrader) CancelOrder(symbol, orderID string) error {
instId := t.convertSymbol(symbol)
body := map[string]interface{}{
"instId": instId,
"ordId": orderID,
}
_, err := t.doRequest("POST", "/api/v5/trade/cancel-order", body)
if err != nil {
return fmt.Errorf("failed to cancel order: %w", err)
}
logger.Infof("✓ [OKX] Order cancelled: %s %s", symbol, orderID)
return nil
}
// GetOrderBook gets the order book for a symbol
// Implements GridTrader interface
func (t *OKXTrader) GetOrderBook(symbol string, depth int) (bids, asks [][]float64, err error) {
instId := t.convertSymbol(symbol)
path := fmt.Sprintf("/api/v5/market/books?instId=%s&sz=%d", instId, depth)
data, err := t.doRequest("GET", path, nil)
if err != nil {
return nil, nil, fmt.Errorf("failed to get order book: %w", err)
}
var result []struct {
Bids [][]string `json:"bids"`
Asks [][]string `json:"asks"`
}
if err := json.Unmarshal(data, &result); err != nil {
return nil, nil, fmt.Errorf("failed to parse order book: %w", err)
}
if len(result) == 0 {
return nil, nil, nil
}
// Parse bids
for _, b := range result[0].Bids {
if len(b) >= 2 {
price, _ := strconv.ParseFloat(b[0], 64)
qty, _ := strconv.ParseFloat(b[1], 64)
bids = append(bids, []float64{price, qty})
}
}
// Parse asks
for _, a := range result[0].Asks {
if len(a) >= 2 {
price, _ := strconv.ParseFloat(a[0], 64)
qty, _ := strconv.ParseFloat(a[1], 64)
asks = append(asks, []float64{price, qty})
}
}
return bids, asks, nil
}