mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-06-06 05:51:19 +08:00
- Move duplicated claw402 wallet resolution logic into store.AIModelStore.ResolveClaw402WalletKey - api/strategy.go and manager/trader_manager.go now delegate to the shared method - Add detailed doc comment on OKX SetMarginMode explaining the local-state-only approach and why the legacy /api/v5/account/set-isolated-mode endpoint is not called - Add 3 new test cases: cross mode leverage, OpenShort tdMode, SetTakeProfit tdMode
274 lines
8.2 KiB
Go
274 lines
8.2 KiB
Go
package okx
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"nofx/logger"
|
|
"nofx/trader/types"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// 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
|
|
}
|
|
|
|
// SetMarginMode configures the margin mode (cross/isolated) that will be applied
|
|
// to all subsequent leverage and order requests for this trader instance.
|
|
//
|
|
// OKX V5 unified accounts do not expose a per-symbol mode-switch endpoint that
|
|
// works reliably — the legacy /api/v5/account/set-isolated-mode endpoint returns
|
|
// error 51000 ("Parameter isoMode error") when called on a unified account.
|
|
// Instead, OKX applies the mode per-request via the mgnMode field on
|
|
// /api/v5/account/set-leverage and via the tdMode field on order placement.
|
|
//
|
|
// This implementation therefore stores the configured mode locally and injects it
|
|
// into each subsequent API request, rather than making an API call here.
|
|
// NOTE: unlike Binance/Bybit implementations of this interface, no network call
|
|
// is made — the method only updates local state.
|
|
func (t *OKXTrader) SetMarginMode(symbol string, isCrossMargin bool) error {
|
|
t.isCrossMargin = isCrossMargin
|
|
mgnMode := t.marginMode()
|
|
|
|
// OKX V5 unified account applies cross/isolated per order via tdMode,
|
|
// while leverage uses mgnMode on /account/set-leverage.
|
|
// Persist the configured mode locally so subsequent leverage/order calls use it,
|
|
// instead of calling the legacy isolated-mode endpoint that returns 51000 errors.
|
|
logger.Infof(" ✓ %s margin mode configured as %s (applied via tdMode/mgnMode on subsequent requests)", symbol, mgnMode)
|
|
return nil
|
|
}
|
|
|
|
// SetLeverage sets leverage
|
|
func (t *OKXTrader) SetLeverage(symbol string, leverage int) error {
|
|
instId := t.convertSymbol(symbol)
|
|
marginMode := t.marginMode()
|
|
|
|
// Set leverage for both long and short
|
|
for _, posSide := range []string{"long", "short"} {
|
|
body := map[string]interface{}{
|
|
"instId": instId,
|
|
"lever": strconv.Itoa(leverage),
|
|
"mgnMode": marginMode,
|
|
"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 (%s)", symbol, leverage, marginMode)
|
|
return 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
|
|
}
|
|
|
|
// 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
|
|
}
|