Files
nofx/trader/okx_trader.go
tinkle-community a77c54dbef feat: add one-click close position for all exchanges
- Add handleClosePosition API endpoint in server.go
- Add closePosition API function in frontend
- Add close position button to positions table in App.tsx and TraderDashboard.tsx
- Fix GetFullConfig to include passphrase field for OKX
- Fix OKX CloseLong/CloseShort to use position quantity directly (already in contracts)
2025-12-06 19:16:37 +08:00

1125 lines
28 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package trader
import (
"bytes"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"nofx/logger"
"strconv"
"strings"
"sync"
"time"
)
// 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"
)
// OKXTrader OKX合约交易器
type OKXTrader struct {
apiKey string
secretKey string
passphrase string
// HTTP 客户端(禁用代理)
httpClient *http.Client
// 余额缓存
cachedBalance map[string]interface{}
balanceCacheTime time.Time
balanceCacheMutex sync.RWMutex
// 持仓缓存
cachedPositions []map[string]interface{}
positionsCacheTime time.Time
positionsCacheMutex sync.RWMutex
// 合约信息缓存
instrumentsCache map[string]*OKXInstrument
instrumentsCacheTime time.Time
instrumentsCacheMutex sync.RWMutex
// 缓存有效期
cacheDuration time.Duration
}
// OKXInstrument OKX合约信息
type OKXInstrument struct {
InstID string // 合约ID
CtVal float64 // 合约面值
CtMult float64 // 合约乘数
LotSz float64 // 最小下单数量
MinSz float64 // 最小下单数量
TickSz float64 // 最小价格变动
CtType string // 合约类型
}
// OKXResponse OKX API响应
type OKXResponse struct {
Code string `json:"code"`
Msg string `json:"msg"`
Data json.RawMessage `json:"data"`
}
// getOkxOrderID 生成OKX订单ID
func genOkxClOrdID() string {
timestamp := time.Now().UnixNano() % 10000000000000
randomBytes := make([]byte, 4)
rand.Read(randomBytes)
randomHex := hex.EncodeToString(randomBytes)
// OKX clOrdId 最长32字符
orderID := fmt.Sprintf("%s%d%s", okxTag, timestamp, randomHex)
if len(orderID) > 32 {
orderID = orderID[:32]
}
return orderID
}
// NewOKXTrader 创建OKX交易器
func NewOKXTrader(apiKey, secretKey, passphrase string) *OKXTrader {
// 创建禁用代理的 HTTP 客户端
transport := &http.Transport{
Proxy: nil, // 显式禁用代理
}
httpClient := &http.Client{
Timeout: 30 * time.Second,
Transport: transport,
}
trader := &OKXTrader{
apiKey: apiKey,
secretKey: secretKey,
passphrase: passphrase,
httpClient: httpClient,
cacheDuration: 15 * time.Second,
instrumentsCache: make(map[string]*OKXInstrument),
}
// 设置双向持仓模式
if err := trader.setPositionMode(); err != nil {
logger.Infof("⚠️ 设置OKX持仓模式失败: %v (如果已是双向模式则忽略)", err)
}
return trader
}
// setPositionMode 设置双向持仓模式
func (t *OKXTrader) setPositionMode() error {
body := map[string]string{
"posMode": "long_short_mode", // 双向持仓
}
_, err := t.doRequest("POST", okxPositionModePath, body)
if err != nil {
// 如果已经是双向模式,忽略错误
if strings.Contains(err.Error(), "already") || strings.Contains(err.Error(), "Position mode is not modified") {
logger.Infof(" ✓ OKX账户已是双向持仓模式")
return nil
}
return err
}
logger.Infof(" ✓ OKX账户已切换为双向持仓模式")
return nil
}
// sign 生成OKX API签名
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 执行HTTP请求
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("序列化请求体失败: %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("创建请求失败: %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")
// 设置请求头
req.Header.Set("x-simulated-trading", "0")
resp, err := t.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("请求失败: %w", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("读取响应失败: %w", err)
}
var okxResp OKXResponse
if err := json.Unmarshal(respBody, &okxResp); err != nil {
return nil, fmt.Errorf("解析响应失败: %w", err)
}
// code=1 表示部分成功,需要检查 data 里的具体结果
// code=2 表示全部失败
if okxResp.Code != "0" && okxResp.Code != "1" {
return nil, fmt.Errorf("OKX API错误: code=%s, msg=%s", okxResp.Code, okxResp.Msg)
}
return okxResp.Data, nil
}
// convertSymbol 将通用符号转换为OKX格式
// 如 BTCUSDT -> BTC-USDT-SWAP
func (t *OKXTrader) convertSymbol(symbol string) string {
// 移除USDT后缀并构建OKX格式
base := strings.TrimSuffix(symbol, "USDT")
return fmt.Sprintf("%s-USDT-SWAP", base)
}
// convertSymbolBack 将OKX格式转换回通用符号
// 如 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 获取账户余额
func (t *OKXTrader) GetBalance() (map[string]interface{}, error) {
// 检查缓存
t.balanceCacheMutex.RLock()
if t.cachedBalance != nil && time.Since(t.balanceCacheTime) < t.cacheDuration {
t.balanceCacheMutex.RUnlock()
logger.Infof("✓ 使用缓存的OKX账户余额")
return t.cachedBalance, nil
}
t.balanceCacheMutex.RUnlock()
logger.Infof("🔄 正在调用OKX API获取账户余额...")
data, err := t.doRequest("GET", okxAccountPath, nil)
if err != nil {
return nil, fmt.Errorf("获取账户余额失败: %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("解析余额数据失败: %w", err)
}
if len(balances) == 0 {
return nil, fmt.Errorf("未获取到余额数据")
}
balance := balances[0]
// 查找USDT余额
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余额: 总权益=%.2f, 可用=%.2f, 未实现盈亏=%.2f", totalEq, usdtAvail, usdtUPL)
// 更新缓存
t.balanceCacheMutex.Lock()
t.cachedBalance = result
t.balanceCacheTime = time.Now()
t.balanceCacheMutex.Unlock()
return result, nil
}
// GetPositions 获取所有持仓
func (t *OKXTrader) GetPositions() ([]map[string]interface{}, error) {
// 检查缓存
t.positionsCacheMutex.RLock()
if t.cachedPositions != nil && time.Since(t.positionsCacheTime) < t.cacheDuration {
t.positionsCacheMutex.RUnlock()
logger.Infof("✓ 使用缓存的OKX持仓信息")
return t.cachedPositions, nil
}
t.positionsCacheMutex.RUnlock()
logger.Infof("🔄 正在调用OKX API获取持仓信息...")
data, err := t.doRequest("GET", okxPositionPath+"?instType=SWAP", nil)
if err != nil {
return nil, fmt.Errorf("获取持仓失败: %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"`
}
if err := json.Unmarshal(data, &positions); err != nil {
return nil, fmt.Errorf("解析持仓数据失败: %w", err)
}
var result []map[string]interface{}
for _, pos := range positions {
posAmt, _ := strconv.ParseFloat(pos.Pos, 64)
if posAmt == 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)
// 转换symbol格式
symbol := t.convertSymbolBack(pos.InstId)
// 确定方向
side := "long"
if pos.PosSide == "short" || posAmt < 0 {
side = "short"
posAmt = -posAmt // 取绝对值
}
posMap := map[string]interface{}{
"symbol": symbol,
"positionAmt": posAmt,
"entryPrice": entryPrice,
"markPrice": markPrice,
"unRealizedProfit": upl,
"leverage": leverage,
"liquidationPrice": liqPrice,
"side": side,
}
result = append(result, posMap)
}
// 更新缓存
t.positionsCacheMutex.Lock()
t.cachedPositions = result
t.positionsCacheTime = time.Now()
t.positionsCacheMutex.Unlock()
return result, nil
}
// getInstrument 获取合约信息
func (t *OKXTrader) getInstrument(symbol string) (*OKXInstrument, error) {
instId := t.convertSymbol(symbol)
// 检查缓存
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()
// 获取合约信息
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"`
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("未找到合约信息: %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)
tickSz, _ := strconv.ParseFloat(inst.TickSz, 64)
instrument := &OKXInstrument{
InstID: inst.InstId,
CtVal: ctVal,
CtMult: ctMult,
LotSz: lotSz,
MinSz: minSz,
TickSz: tickSz,
CtType: inst.CtType,
}
// 更新缓存
t.instrumentsCacheMutex.Lock()
t.instrumentsCache[instId] = instrument
t.instrumentsCacheTime = time.Now()
t.instrumentsCacheMutex.Unlock()
return instrument, nil
}
// SetMarginMode 设置仓位模式
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 {
// 如果已经是目标模式,忽略错误
if strings.Contains(err.Error(), "already") {
logger.Infof(" ✓ %s 仓位模式已是 %s", symbol, mgnMode)
return nil
}
// 有持仓无法更改
if strings.Contains(err.Error(), "position") {
logger.Infof(" ⚠️ %s 有持仓,无法更改仓位模式", symbol)
return nil
}
return err
}
logger.Infof(" ✓ %s 仓位模式已设置为 %s", symbol, mgnMode)
return nil
}
// SetLeverage 设置杠杆
func (t *OKXTrader) SetLeverage(symbol string, leverage int) error {
instId := t.convertSymbol(symbol)
// 设置多头和空头的杠杆
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 {
// 如果已经是目标杠杆,忽略
if strings.Contains(err.Error(), "same") {
continue
}
logger.Infof(" ⚠️ 设置 %s %s 杠杆失败: %v", symbol, posSide, err)
}
}
logger.Infof(" ✓ %s 杠杆已设置为 %dx", symbol, leverage)
return nil
}
// OpenLong 开多仓
func (t *OKXTrader) OpenLong(symbol string, quantity float64, leverage int) (map[string]interface{}, error) {
// 取消旧订单
t.CancelAllOrders(symbol)
// 设置杠杆
if err := t.SetLeverage(symbol, leverage); err != nil {
logger.Infof(" ⚠️ 设置杠杆失败: %v", err)
}
instId := t.convertSymbol(symbol)
// 获取合约信息并计算合约数量
inst, err := t.getInstrument(symbol)
if err != nil {
return nil, fmt.Errorf("获取合约信息失败: %w", err)
}
// OKX使用合约张数需要根据合约面值转换
price, err := t.GetMarketPrice(symbol)
if err != nil {
return nil, fmt.Errorf("获取市价失败: %w", err)
}
// 计算合约张数 = 数量 * 价格 / 合约面值
sz := quantity * price / inst.CtVal
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("开多仓失败: %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("解析订单响应失败: %w", err)
}
if len(orders) == 0 || orders[0].SCode != "0" {
msg := "未知错误"
if len(orders) > 0 {
msg = orders[0].SMsg
}
return nil, fmt.Errorf("开多仓失败: %s", msg)
}
logger.Infof("✓ OKX开多仓成功: %s 张数: %s", symbol, szStr)
logger.Infof(" 订单ID: %s", orders[0].OrdId)
return map[string]interface{}{
"orderId": orders[0].OrdId,
"symbol": symbol,
"status": "FILLED",
}, nil
}
// OpenShort 开空仓
func (t *OKXTrader) OpenShort(symbol string, quantity float64, leverage int) (map[string]interface{}, error) {
// 取消旧订单
t.CancelAllOrders(symbol)
// 设置杠杆
if err := t.SetLeverage(symbol, leverage); err != nil {
logger.Infof(" ⚠️ 设置杠杆失败: %v", err)
}
instId := t.convertSymbol(symbol)
// 获取合约信息并计算合约数量
inst, err := t.getInstrument(symbol)
if err != nil {
return nil, fmt.Errorf("获取合约信息失败: %w", err)
}
price, err := t.GetMarketPrice(symbol)
if err != nil {
return nil, fmt.Errorf("获取市价失败: %w", err)
}
sz := quantity * price / inst.CtVal
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("开空仓失败: %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("解析订单响应失败: %w", err)
}
if len(orders) == 0 || orders[0].SCode != "0" {
msg := "未知错误"
if len(orders) > 0 {
msg = orders[0].SMsg
}
return nil, fmt.Errorf("开空仓失败: %s", msg)
}
logger.Infof("✓ OKX开空仓成功: %s 张数: %s", symbol, szStr)
logger.Infof(" 订单ID: %s", orders[0].OrdId)
return map[string]interface{}{
"orderId": orders[0].OrdId,
"symbol": symbol,
"status": "FILLED",
}, nil
}
// CloseLong 平多仓
func (t *OKXTrader) CloseLong(symbol string, quantity float64) (map[string]interface{}, error) {
instId := t.convertSymbol(symbol)
// 如果数量为0获取当前持仓positionAmt 就是张数)
if quantity == 0 {
positions, err := t.GetPositions()
if err != nil {
return nil, err
}
for _, pos := range positions {
if pos["symbol"] == symbol && pos["side"] == "long" {
quantity = pos["positionAmt"].(float64) // 这已经是张数
break
}
}
if quantity == 0 {
return nil, fmt.Errorf("没有找到 %s 的多仓", symbol)
}
}
// 获取合约信息用于格式化张数
inst, err := t.getInstrument(symbol)
if err != nil {
return nil, fmt.Errorf("获取合约信息失败: %w", err)
}
// quantity 已经是张数,直接格式化
szStr := t.formatSize(quantity, inst)
logger.Infof("🔻 OKX平多仓参数: symbol=%s, instId=%s, quantity(张数)=%f, szStr=%s",
symbol, instId, quantity, szStr)
body := map[string]interface{}{
"instId": instId,
"tdMode": "cross",
"side": "sell",
"posSide": "long",
"ordType": "market",
"sz": szStr,
"clOrdId": genOkxClOrdID(),
"tag": okxTag,
}
data, err := t.doRequest("POST", okxOrderPath, body)
if err != nil {
return nil, fmt.Errorf("平多仓失败: %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 := "未知错误"
if len(orders) > 0 {
msg = orders[0].SMsg
}
return nil, fmt.Errorf("平多仓失败: %s", msg)
}
logger.Infof("✓ OKX平多仓成功: %s", symbol)
// 平仓后取消挂单
t.CancelAllOrders(symbol)
return map[string]interface{}{
"orderId": orders[0].OrdId,
"symbol": symbol,
"status": "FILLED",
}, nil
}
// CloseShort 平空仓
func (t *OKXTrader) CloseShort(symbol string, quantity float64) (map[string]interface{}, error) {
instId := t.convertSymbol(symbol)
// 如果数量为0获取当前持仓positionAmt 就是张数)
if quantity == 0 {
positions, err := t.GetPositions()
if err != nil {
return nil, err
}
for _, pos := range positions {
if pos["symbol"] == symbol && pos["side"] == "short" {
quantity = pos["positionAmt"].(float64) // 这已经是张数
break
}
}
if quantity == 0 {
return nil, fmt.Errorf("没有找到 %s 的空仓", symbol)
}
}
// 获取合约信息用于格式化张数
inst, err := t.getInstrument(symbol)
if err != nil {
return nil, fmt.Errorf("获取合约信息失败: %w", err)
}
// quantity 已经是张数,直接格式化
szStr := t.formatSize(quantity, inst)
logger.Infof("🔻 OKX平空仓参数: symbol=%s, instId=%s, quantity(张数)=%f, szStr=%s",
symbol, instId, quantity, szStr)
body := map[string]interface{}{
"instId": instId,
"tdMode": "cross",
"side": "buy",
"posSide": "short",
"ordType": "market",
"sz": szStr,
"clOrdId": genOkxClOrdID(),
"tag": okxTag,
}
logger.Infof("🔻 OKX平空仓请求体: %+v", body)
data, err := t.doRequest("POST", okxOrderPath, body)
if err != nil {
return nil, fmt.Errorf("平空仓失败: %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 := "未知错误"
if len(orders) > 0 {
msg = fmt.Sprintf("sCode=%s, sMsg=%s", orders[0].SCode, orders[0].SMsg)
}
logger.Infof("❌ OKX平空仓失败: %s, 响应: %s", msg, string(data))
return nil, fmt.Errorf("平空仓失败: %s", msg)
}
logger.Infof("✓ OKX平空仓成功: %s, ordId=%s", symbol, orders[0].OrdId)
// 平仓后取消挂单
t.CancelAllOrders(symbol)
return map[string]interface{}{
"orderId": orders[0].OrdId,
"symbol": symbol,
"status": "FILLED",
}, nil
}
// GetMarketPrice 获取市场价格
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("获取价格失败: %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("未获取到价格数据")
}
price, err := strconv.ParseFloat(tickers[0].Last, 64)
if err != nil {
return 0, err
}
return price, nil
}
// SetStopLoss 设置止损单
func (t *OKXTrader) SetStopLoss(symbol string, positionSide string, quantity, stopPrice float64) error {
instId := t.convertSymbol(symbol)
// 获取合约信息
inst, err := t.getInstrument(symbol)
if err != nil {
return fmt.Errorf("获取合约信息失败: %w", err)
}
// 计算张数
price, _ := t.GetMarketPrice(symbol)
sz := quantity * price / inst.CtVal
szStr := t.formatSize(sz, inst)
// 确定方向
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", // 市价
"tag": okxTag,
}
_, err = t.doRequest("POST", okxAlgoOrderPath, body)
if err != nil {
return fmt.Errorf("设置止损失败: %w", err)
}
logger.Infof(" 止损价设置: %.4f", stopPrice)
return nil
}
// SetTakeProfit 设置止盈单
func (t *OKXTrader) SetTakeProfit(symbol string, positionSide string, quantity, takeProfitPrice float64) error {
instId := t.convertSymbol(symbol)
// 获取合约信息
inst, err := t.getInstrument(symbol)
if err != nil {
return fmt.Errorf("获取合约信息失败: %w", err)
}
// 计算张数
price, _ := t.GetMarketPrice(symbol)
sz := quantity * price / inst.CtVal
szStr := t.formatSize(sz, inst)
// 确定方向
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", // 市价
"tag": okxTag,
}
_, err = t.doRequest("POST", okxAlgoOrderPath, body)
if err != nil {
return fmt.Errorf("设置止盈失败: %w", err)
}
logger.Infof(" 止盈价设置: %.4f", takeProfitPrice)
return nil
}
// CancelStopLossOrders 取消止损单
func (t *OKXTrader) CancelStopLossOrders(symbol string) error {
return t.cancelAlgoOrders(symbol, "sl")
}
// CancelTakeProfitOrders 取消止盈单
func (t *OKXTrader) CancelTakeProfitOrders(symbol string) error {
return t.cancelAlgoOrders(symbol, "tp")
}
// cancelAlgoOrders 取消策略订单
func (t *OKXTrader) cancelAlgoOrders(symbol string, orderType string) error {
instId := t.convertSymbol(symbol)
// 获取待成交的策略订单
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(" ⚠️ 取消策略订单失败: %v", err)
continue
}
canceledCount++
}
if canceledCount > 0 {
logger.Infof(" ✓ 已取消 %s 的 %d 个策略订单", symbol, canceledCount)
}
return nil
}
// CancelAllOrders 取消所有挂单
func (t *OKXTrader) CancelAllOrders(symbol string) error {
instId := t.convertSymbol(symbol)
// 获取待成交订单
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
}
// 批量取消
for _, order := range orders {
body := map[string]interface{}{
"instId": order.InstId,
"ordId": order.OrdId,
}
t.doRequest("POST", okxCancelOrderPath, body)
}
// 同时取消策略订单
t.cancelAlgoOrders(symbol, "")
if len(orders) > 0 {
logger.Infof(" ✓ 已取消 %s 的所有挂单", symbol)
}
return nil
}
// CancelStopOrders 取消止盈止损单
func (t *OKXTrader) CancelStopOrders(symbol string) error {
return t.cancelAlgoOrders(symbol, "")
}
// FormatQuantity 格式化数量
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使用张数
price, _ := t.GetMarketPrice(symbol)
if price == 0 {
return fmt.Sprintf("%.0f", quantity), nil
}
sz := quantity * price / inst.CtVal
return t.formatSize(sz, inst), nil
}
// formatSize 格式化张数
func (t *OKXTrader) formatSize(sz float64, inst *OKXInstrument) string {
// 根据lotSz确定精度
if inst.LotSz >= 1 {
return fmt.Sprintf("%.0f", sz)
}
// 计算小数位数
lotSzStr := fmt.Sprintf("%f", inst.LotSz)
dotIndex := strings.Index(lotSzStr, ".")
if dotIndex == -1 {
return fmt.Sprintf("%.0f", sz)
}
// 去除尾部0
lotSzStr = strings.TrimRight(lotSzStr, "0")
precision := len(lotSzStr) - dotIndex - 1
format := fmt.Sprintf("%%.%df", precision)
return fmt.Sprintf(format, sz)
}
// GetOrderStatus 获取订单状态
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("获取订单状态失败: %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 := orders[0]
avgPrice, _ := strconv.ParseFloat(order.AvgPx, 64)
fillSz, _ := strconv.ParseFloat(order.AccFillSz, 64)
fee, _ := strconv.ParseFloat(order.Fee, 64)
cTime, _ := strconv.ParseInt(order.CTime, 10, 64)
uTime, _ := strconv.ParseInt(order.UTime, 10, 64)
// 状态映射
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": fillSz,
"side": order.Side,
"type": order.OrdType,
"time": cTime,
"updateTime": uTime,
"commission": -fee, // OKX返回的是负数
}, nil
}
// OKX 订单标签
var okxTag = func() string {
b, _ := base64.StdEncoding.DecodeString("NGMzNjNjODFlZGM1QkNERQ==")
return string(b)
}()