mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-06 20:41:14 +08:00
- 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)
1125 lines
28 KiB
Go
1125 lines
28 KiB
Go
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)
|
||
}()
|