fix: use position's actual margin mode when closing OKX positions

- Parse mgnMode field from OKX positions API response
- Use position's mgnMode (cross/isolated) in close orders instead of hardcoding cross
- This fixes 'no position in this direction' error when closing isolated margin positions
This commit is contained in:
tinkle-community
2025-12-27 22:11:36 +08:00
parent e204707845
commit 9c66afd7a0

View File

@@ -33,6 +33,7 @@ const (
okxCancelAlgoPath = "/api/v5/trade/cancel-algos" okxCancelAlgoPath = "/api/v5/trade/cancel-algos"
okxAlgoPendingPath = "/api/v5/trade/orders-algo-pending" okxAlgoPendingPath = "/api/v5/trade/orders-algo-pending"
okxPositionModePath = "/api/v5/account/set-position-mode" okxPositionModePath = "/api/v5/account/set-position-mode"
okxAccountConfigPath = "/api/v5/account/config"
) )
// OKXTrader OKX futures trader // OKXTrader OKX futures trader
@@ -44,6 +45,9 @@ type OKXTrader struct {
// Margin mode setting // Margin mode setting
isCrossMargin bool isCrossMargin bool
// Position mode: "long_short_mode" (hedge) or "net_mode" (one-way)
positionMode string
// HTTP client (proxy disabled) // HTTP client (proxy disabled)
httpClient *http.Client httpClient *http.Client
@@ -117,14 +121,46 @@ func NewOKXTrader(apiKey, secretKey, passphrase string) *OKXTrader {
instrumentsCache: make(map[string]*OKXInstrument), instrumentsCache: make(map[string]*OKXInstrument),
} }
// Set dual position mode // Get current position mode first
if err := trader.setPositionMode(); err != nil { if err := trader.detectPositionMode(); err != nil {
logger.Infof("⚠️ Failed to set OKX position mode: %v (ignore if already in dual mode)", err) 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 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 // setPositionMode sets dual position mode
func (t *OKXTrader) setPositionMode() error { func (t *OKXTrader) setPositionMode() error {
body := map[string]string{ body := map[string]string{
@@ -321,16 +357,19 @@ func (t *OKXTrader) GetPositions() ([]map[string]interface{}, error) {
Lever string `json:"lever"` Lever string `json:"lever"`
LiqPx string `json:"liqPx"` LiqPx string `json:"liqPx"`
Margin string `json:"margin"` Margin string `json:"margin"`
CTime string `json:"cTime"` // Position created time (ms) MgnMode string `json:"mgnMode"` // Margin mode: "cross" or "isolated"
UTime string `json:"uTime"` // Position last update time (ms) 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 { if err := json.Unmarshal(data, &positions); err != nil {
return nil, fmt.Errorf("failed to parse position data: %w", err) 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{} var result []map[string]interface{}
for _, pos := range positions { 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) contractCount, _ := strconv.ParseFloat(pos.Pos, 64)
if contractCount == 0 { if contractCount == 0 {
continue continue
@@ -344,6 +383,7 @@ func (t *OKXTrader) GetPositions() ([]map[string]interface{}, error) {
// Convert symbol format // Convert symbol format
symbol := t.convertSymbolBack(pos.InstId) symbol := t.convertSymbolBack(pos.InstId)
logger.Infof("🔍 OKX symbol conversion: %s → %s", pos.InstId, symbol)
// Determine direction and ensure contractCount is positive // Determine direction and ensure contractCount is positive
side := "long" side := "long"
@@ -368,6 +408,12 @@ func (t *OKXTrader) GetPositions() ([]map[string]interface{}, error) {
cTime, _ := strconv.ParseInt(pos.CTime, 10, 64) cTime, _ := strconv.ParseInt(pos.CTime, 10, 64)
uTime, _ := strconv.ParseInt(pos.UTime, 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{}{ posMap := map[string]interface{}{
"symbol": symbol, "symbol": symbol,
"positionAmt": posAmt, "positionAmt": posAmt,
@@ -377,8 +423,9 @@ func (t *OKXTrader) GetPositions() ([]map[string]interface{}, error) {
"leverage": leverage, "leverage": leverage,
"liquidationPrice": liqPrice, "liquidationPrice": liqPrice,
"side": side, "side": side,
"createdTime": cTime, // Position open time (ms) "mgnMode": mgnMode, // Margin mode: "cross" or "isolated"
"updatedTime": uTime, // Position last update time (ms) "createdTime": cTime, // Position open time (ms)
"updatedTime": uTime, // Position last update time (ms)
} }
result = append(result, posMap) result = append(result, posMap)
} }
@@ -392,6 +439,14 @@ func (t *OKXTrader) GetPositions() ([]map[string]interface{}, error) {
return result, nil 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 // getInstrument gets instrument info
func (t *OKXTrader) getInstrument(symbol string) (*OKXInstrument, error) { func (t *OKXTrader) getInstrument(symbol string) (*OKXInstrument, error) {
instId := t.convertSymbol(symbol) instId := t.convertSymbol(symbol)
@@ -682,21 +737,47 @@ func (t *OKXTrader) CloseLong(symbol string, quantity float64) (map[string]inter
return nil, fmt.Errorf("failed to get instrument info: %w", err) return nil, fmt.Errorf("failed to get instrument info: %w", err)
} }
// If quantity is 0, get current position (positionAmt is in base asset, e.g. BTC) // Invalidate position cache and get fresh positions
if quantity == 0 { t.InvalidatePositionCache()
positions, err := t.GetPositions() positions, err := t.GetPositions()
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("failed to get positions: %w", err)
} }
for _, pos := range positions {
if pos["symbol"] == symbol && pos["side"] == "long" { // Find actual position from exchange
quantity = pos["positionAmt"].(float64) // This is in base asset (BTC) 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 break
} }
} }
if quantity == 0 { }
return nil, fmt.Errorf("long position not found for %s", symbol)
} 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 // Convert quantity (base asset) to contract count
@@ -704,20 +785,24 @@ func (t *OKXTrader) CloseLong(symbol string, quantity float64) (map[string]inter
contracts := quantity / inst.CtVal contracts := quantity / inst.CtVal
szStr := t.formatSize(contracts, inst) szStr := t.formatSize(contracts, inst)
logger.Infof("🔻 OKX close long: symbol=%s, quantity=%.6f, ctVal=%.6f, contracts=%.2f, szStr=%s", logger.Infof("🔻 OKX close long: symbol=%s, instId=%s, quantity=%.6f, ctVal=%.6f, contracts=%.2f, szStr=%s, posMode=%s, mgnMode=%s",
symbol, quantity, inst.CtVal, contracts, szStr) symbol, instId, quantity, inst.CtVal, contracts, szStr, t.positionMode, posMgnMode)
body := map[string]interface{}{ body := map[string]interface{}{
"instId": instId, "instId": instId,
"tdMode": "cross", "tdMode": posMgnMode, // Use position's actual margin mode (cross or isolated)
"side": "sell", "side": "sell",
"posSide": "long",
"ordType": "market", "ordType": "market",
"sz": szStr, "sz": szStr,
"clOrdId": genOkxClOrdID(), "clOrdId": genOkxClOrdID(),
"tag": okxTag, "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) data, err := t.doRequest("POST", okxOrderPath, body)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to close long position: %w", err) return nil, fmt.Errorf("failed to close long position: %w", err)
@@ -763,25 +848,42 @@ func (t *OKXTrader) CloseShort(symbol string, quantity float64) (map[string]inte
return nil, fmt.Errorf("failed to get instrument info: %w", err) return nil, fmt.Errorf("failed to get instrument info: %w", err)
} }
// If quantity is 0, get current position (positionAmt is in base asset, e.g. BTC) // Invalidate position cache and get fresh positions
if quantity == 0 { t.InvalidatePositionCache()
positions, err := t.GetPositions() positions, err := t.GetPositions()
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("failed to get positions: %w", err)
} }
logger.Infof("🔍 OKX CloseShort searching positions: symbol=%s, current position count=%d", symbol, len(positions))
for _, pos := range positions { // Find actual position from exchange
logger.Infof("🔍 OKX position: symbol=%v, side=%v, positionAmt=%v", var actualQty float64
pos["symbol"], pos["side"], pos["positionAmt"]) var posFound bool
if pos["symbol"] == symbol && pos["side"] == "short" { var posMgnMode string = "cross" // Default to cross margin
quantity = pos["positionAmt"].(float64) // This is in base asset (BTC) logger.Infof("🔍 OKX CloseShort searching positions: symbol=%s, current position count=%d", symbol, len(positions))
logger.Infof("🔍 OKX found short position: quantity=%f (base asset)", quantity) for _, pos := range positions {
break 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 quantity == 0 { }
return nil, fmt.Errorf("short position not found for %s", symbol)
} 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) // Ensure quantity is positive (OKX sz parameter must be positive)
@@ -794,20 +896,24 @@ func (t *OKXTrader) CloseShort(symbol string, quantity float64) (map[string]inte
contracts := quantity / inst.CtVal contracts := quantity / inst.CtVal
szStr := t.formatSize(contracts, inst) szStr := t.formatSize(contracts, inst)
logger.Infof("🔻 OKX close short: symbol=%s, quantity=%.6f, ctVal=%.6f, contracts=%.2f, szStr=%s", 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) symbol, quantity, inst.CtVal, contracts, szStr, t.positionMode, posMgnMode)
body := map[string]interface{}{ body := map[string]interface{}{
"instId": instId, "instId": instId,
"tdMode": "cross", "tdMode": posMgnMode, // Use position's actual margin mode (cross or isolated)
"side": "buy", "side": "buy",
"posSide": "short",
"ordType": "market", "ordType": "market",
"sz": szStr, "sz": szStr,
"clOrdId": genOkxClOrdID(), "clOrdId": genOkxClOrdID(),
"tag": okxTag, "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) logger.Infof("🔻 OKX close short request body: %+v", body)
data, err := t.doRequest("POST", okxOrderPath, body) data, err := t.doRequest("POST", okxOrderPath, body)