From 62bce32d1fcb94804bb03f088e8787ad3cac824e Mon Sep 17 00:00:00 2001 From: 0xYYBB | ZYY | Bobo <128128010+the-dev-z@users.noreply.github.com> Date: Tue, 2 Dec 2025 10:18:13 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=20OKX=20=E4=BA=A4?= =?UTF-8?q?=E6=98=93=E6=89=80=E6=94=AF=E6=8C=81=20(#1150)=20*=20feat:=20?= =?UTF-8?q?=E6=B7=BB=E5=8A=A0=20OKX=20=E4=BA=A4=E6=98=93=E6=89=80=E6=94=AF?= =?UTF-8?q?=E6=8C=81=EF=BC=88USDT=20Perpetual=20Swap=EF=BC=89=20##=20?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=E5=8A=9F=E8=83=BD=20-=20=E5=AF=A6=E7=8F=BE?= =?UTF-8?q?=E5=AE=8C=E6=95=B4=E7=9A=84=20OKX=20API=20v5=20REST=20=E5=AE=A2?= =?UTF-8?q?=E6=88=B6=E7=AB=AF=EF=BC=88=E7=B4=94=20Go=20=E6=A8=99=E6=BA=96?= =?UTF-8?q?=E5=BA=AB=EF=BC=8C=E7=84=A1=E5=A4=96=E9=83=A8=E4=BE=9D=E8=B3=B4?= =?UTF-8?q?=EF=BC=89=20-=20=E6=94=AF=E6=8C=81=20USDT=20=E6=B0=B8=E7=BA=8C?= =?UTF-8?q?=E5=90=88=E7=B4=84=E4=BA=A4=E6=98=93=EF=BC=88BTC-USDT-SWAP=20?= =?UTF-8?q?=E7=AD=89=EF=BC=89=20-=20=E5=AF=A6=E7=8F=BE=20Trader=20?= =?UTF-8?q?=E6=8E=A5=E5=8F=A3=E7=9A=84=2013=20=E5=80=8B=E6=A0=B8=E5=BF=83?= =?UTF-8?q?=E6=96=B9=E6=B3=95=20##=20=E6=8A=80=E8=A1=93=E7=B4=B0=E7=AF=80?= =?UTF-8?q?=20###=20trader/okx=5Ftrader.go=20(NEW)=20-=20HMAC-SHA256=20?= =?UTF-8?q?=E7=B0=BD=E5=90=8D=E6=A9=9F=E5=88=B6=EF=BC=88=E5=AE=8C=E5=85=A8?= =?UTF-8?q?=E7=AC=A6=E5=90=88=20OKX=20API=20v5=20=E8=A6=8F=E7=AF=84?= =?UTF-8?q?=EF=BC=89=20-=20=E9=A4=98=E9=A1=8D=E5=92=8C=E6=8C=81=E5=80=89?= =?UTF-8?q?=E7=B7=A9=E5=AD=98=EF=BC=8815=E7=A7=92=EF=BC=8C=E5=8F=83?= =?UTF-8?q?=E8=80=83=20Binance=20=E5=AF=A6=E7=8F=BE=EF=BC=89=20-=20?= =?UTF-8?q?=E6=94=AF=E6=8C=81=20Demo=20Trading=EF=BC=88testnet=20=E6=A8=A1?= =?UTF-8?q?=E5=BC=8F=EF=BC=89=20-=20Symbol=20=E6=A0=BC=E5=BC=8F=E8=BD=89?= =?UTF-8?q?=E6=8F=9B=EF=BC=88BTCUSDT=20=E2=86=94=20BTC-USDT-SWAP=EF=BC=89?= =?UTF-8?q?=20-=20=E5=85=A8=E5=80=89=E6=A8=A1=E5=BC=8F=EF=BC=88Cross=20Mar?= =?UTF-8?q?gin=EF=BC=89=E6=94=AF=E6=8C=81=20-=20=E8=87=AA=E5=8B=95?= =?UTF-8?q?=E6=A7=93=E6=A1=BF=E8=A8=AD=E7=BD=AE=20###=20=E5=AF=A6=E7=8F=BE?= =?UTF-8?q?=E7=9A=84=E6=8E=A5=E5=8F=A3=E6=96=B9=E6=B3=95=EF=BC=9A=20-=20?= =?UTF-8?q?=E2=9C=85=20GetBalance()=20-=20=E7=8D=B2=E5=8F=96=E8=B3=AC?= =?UTF-8?q?=E6=88=B6=E9=A4=98=E9=A1=8D=20-=20=E2=9C=85=20GetPositions()=20?= =?UTF-8?q?-=20=E7=8D=B2=E5=8F=96=E6=89=80=E6=9C=89=E6=8C=81=E5=80=89=20-?= =?UTF-8?q?=20=E2=9C=85=20OpenLong()=20/=20OpenShort()=20-=20=E9=96=8B?= =?UTF-8?q?=E5=80=89=20-=20=E2=9C=85=20CloseLong()=20/=20CloseShort()=20-?= =?UTF-8?q?=20=E5=B9=B3=E5=80=89=20-=20=E2=9C=85=20SetLeverage()=20-=20?= =?UTF-8?q?=E8=A8=AD=E7=BD=AE=E6=A7=93=E6=A1=BF=20-=20=E2=9C=85=20SetMargi?= =?UTF-8?q?nMode()=20-=20=E8=A8=AD=E7=BD=AE=E4=BF=9D=E8=AD=89=E9=87=91?= =?UTF-8?q?=E6=A8=A1=E5=BC=8F=20-=20=E2=9C=85=20GetMarketPrice()=20-=20?= =?UTF-8?q?=E7=8D=B2=E5=8F=96=E5=B8=82=E5=A0=B4=E5=83=B9=E6=A0=BC=20-=20?= =?UTF-8?q?=E2=9C=85=20FormatQuantity()=20-=20=E6=A0=BC=E5=BC=8F=E5=8C=96?= =?UTF-8?q?=E6=95=B8=E9=87=8F=20-=20=E2=9A=A0=EF=B8=8F=20=20=E6=AD=A2?= =?UTF-8?q?=E7=9B=88=E6=AD=A2=E6=90=8D=E5=8A=9F=E8=83=BD=E6=A8=99=E8=A8=98?= =?UTF-8?q?=E7=82=BA=20TODO=EF=BC=88=E9=9D=9E=E6=A0=B8=E5=BF=83=E4=BA=A4?= =?UTF-8?q?=E6=98=93=E5=8A=9F=E8=83=BD=EF=BC=89=20###=20config/database.go?= =?UTF-8?q?=20(MODIFIED)=20-=20=E6=B7=BB=E5=8A=A0=20"okx"=20=E5=88=B0?= =?UTF-8?q?=E9=A0=90=E8=A8=AD=E4=BA=A4=E6=98=93=E6=89=80=E5=88=97=E8=A1=A8?= =?UTF-8?q?=20-=20=E6=96=B0=E5=A2=9E=20okx=5Fpassphrase=20=E5=AD=97?= =?UTF-8?q?=E6=AE=B5=EF=BC=88OKX=20=E9=9C=80=E8=A6=81=203=20=E5=80=8B?= =?UTF-8?q?=E8=AA=8D=E8=AD=89=E5=8F=83=E6=95=B8=EF=BC=89=20-=20=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=20ExchangeConfig=20=E7=B5=90=E6=A7=8B=20-=20=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=E6=95=B8=E6=93=9A=E5=BA=AB=E9=81=B7=E7=A7=BB=E8=AA=9E?= =?UTF-8?q?=E5=8F=A5=EF=BC=88ALTER=20TABLE=EF=BC=89=20###=20api/server.go?= =?UTF-8?q?=20(MODIFIED)=20-=20=E5=9C=A8=20handleCreateTrader()=20?= =?UTF-8?q?=E6=B7=BB=E5=8A=A0=20OKX=20=E5=88=9D=E5=A7=8B=E5=8C=96=E9=82=8F?= =?UTF-8?q?=E8=BC=AF=20-=20switch=20case=20"okx"=20=E5=88=86=E6=94=AF=20##?= =?UTF-8?q?=20=E4=BB=A3=E7=A2=BC=E5=93=81=E8=B3=AA=20-=20=E4=BB=A3?= =?UTF-8?q?=E7=A2=BC=E8=A1=8C=E6=95=B8=EF=BC=9A~450=20=E8=A1=8C=20-=20?= =?UTF-8?q?=E5=A4=96=E9=83=A8=E4=BE=9D=E8=B3=B4=EF=BC=9A0=20=E5=80=8B=20-?= =?UTF-8?q?=20=E7=B7=A8=E8=AD=AF=E7=8B=80=E6=85=8B=EF=BC=9A=E2=9C=85=20?= =?UTF-8?q?=E9=80=9A=E9=81=8E=20-=20=E6=B8=AC=E8=A9=A6=E8=A6=86=E8=93=8B?= =?UTF-8?q?=EF=BC=9A=E5=BE=85=E5=AF=A6=E7=8F=BE=EF=BC=88=E4=B8=8B=E4=B8=80?= =?UTF-8?q?=E6=AD=A5=EF=BC=89=20##=20=E5=BE=85=E5=AE=8C=E6=88=90=E4=BA=8B?= =?UTF-8?q?=E9=A0=85=20-=20[=20]=20=E6=92=B0=E5=AF=AB=E5=96=AE=E5=85=83?= =?UTF-8?q?=E6=B8=AC=E8=A9=A6=EF=BC=88=E7=9B=AE=E6=A8=99=20>80%=20?= =?UTF-8?q?=E8=A6=86=E8=93=8B=E7=8E=87=EF=BC=89=20-=20[=20]=20=E5=AE=8C?= =?UTF-8?q?=E5=96=84=E6=95=B8=E6=93=9A=E5=BA=AB=E6=9F=A5=E8=A9=A2=E9=82=8F?= =?UTF-8?q?=E8=BC=AF=EF=BC=88GetExchanges=20=E6=B7=BB=E5=8A=A0=20OKX=20pas?= =?UTF-8?q?sphrase=20=E6=8E=83=E6=8F=8F=EF=BC=89=20-=20[=20]=20=E5=AF=A6?= =?UTF-8?q?=E7=8F=BE=E6=AD=A2=E7=9B=88=E6=AD=A2=E6=90=8D=E5=8A=9F=E8=83=BD?= =?UTF-8?q?=EF=BC=88=E5=8F=AF=E9=81=B8=EF=BC=89=20*=20refactor:=20?= =?UTF-8?q?=E5=AE=8C=E5=96=84=20OKX=20passphrase=20=E6=95=B8=E6=93=9A?= =?UTF-8?q?=E5=BA=AB=E5=92=8C=20API=20=E6=94=AF=E6=8C=81=20-=20config/data?= =?UTF-8?q?base.go:=20=20=20=E2=80=A2=20GetExchanges()=20=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=20okx=5Fpassphrase=20=E6=9F=A5=E8=A9=A2=E5=92=8C?= =?UTF-8?q?=E8=A7=A3=E5=AF=86=20=20=20=E2=80=A2=20UpdateExchange()=20?= =?UTF-8?q?=E5=87=BD=E6=95=B8=E7=B0=BD=E5=90=8D=E6=B7=BB=E5=8A=A0=20okxPas?= =?UTF-8?q?sphrase=20=E5=8F=83=E6=95=B8=20=20=20=E2=80=A2=20UpdateExchange?= =?UTF-8?q?()=20UPDATE=20=E9=82=8F=E8=BC=AF=E6=B7=BB=E5=8A=A0=20okx=5Fpass?= =?UTF-8?q?phrase=20SET=20=E5=AD=90=E5=8F=A5=20=20=20=E2=80=A2=20UpdateExc?= =?UTF-8?q?hange()=20INSERT=20=E6=B7=BB=E5=8A=A0=20okx=5Fpassphrase=20?= =?UTF-8?q?=E5=8A=A0=E5=AF=86=E5=92=8C=E5=88=97=20-=20api/server.go:=20=20?= =?UTF-8?q?=20=E2=80=A2=20UpdateExchangeConfigRequest=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=20OKXPassphrase=20=E5=AD=97=E6=AE=B5=20=20=20=E2=80=A2=20Updat?= =?UTF-8?q?eExchange=20=E8=AA=BF=E7=94=A8=E6=B7=BB=E5=8A=A0=20OKXPassphras?= =?UTF-8?q?e=20=E5=8F=83=E6=95=B8=20-=20api/utils.go:=20=20=20=E2=80=A2=20?= =?UTF-8?q?SanitizeExchangeConfigForLog=20=E6=B7=BB=E5=8A=A0=20OKXPassphra?= =?UTF-8?q?se=20=E8=84=AB=E6=95=8F=20=E2=9C=85=20=E7=B7=A8=E8=AD=AF?= =?UTF-8?q?=E6=B8=AC=E8=A9=A6=E9=80=9A=E9=81=8E=EF=BC=8COKX=20=E5=AE=8C?= =?UTF-8?q?=E6=95=B4=E5=8A=9F=E8=83=BD=E6=94=AF=E6=8C=81=E5=AE=8C=E6=88=90?= =?UTF-8?q?=20*=20test:=20=E6=B7=BB=E5=8A=A0=20OKX=20Trader=20=E5=AE=8C?= =?UTF-8?q?=E6=95=B4=E5=96=AE=E5=85=83=E6=B8=AC=E8=A9=A6=E5=A5=97=E4=BB=B6?= =?UTF-8?q?=20=F0=9F=93=8A=20=E6=B8=AC=E8=A9=A6=E8=A6=86=E8=93=8B=E7=8E=87?= =?UTF-8?q?=EF=BC=9A92.6%=20(=E9=81=A0=E8=B6=85=2080%=20=E7=9B=AE=E6=A8=99?= =?UTF-8?q?)=20=E2=9C=85=20=E5=AE=8C=E6=88=90=E7=9A=84=E6=B8=AC=E8=A9=A6?= =?UTF-8?q?=EF=BC=9A=20-=20=E6=8E=A5=E5=8F=A3=E5=85=BC=E5=AE=B9=E6=80=A7?= =?UTF-8?q?=E6=B8=AC=E8=A9=A6=20-=20NewOKXTrader=20=E6=A7=8B=E9=80=A0?= =?UTF-8?q?=E5=87=BD=E6=95=B8=E6=B8=AC=E8=A9=A6=EF=BC=885=E5=80=8B?= =?UTF-8?q?=E5=A0=B4=E6=99=AF=EF=BC=89=20-=20=E7=AC=A6=E8=99=9F=E6=A0=BC?= =?UTF-8?q?=E5=BC=8F=E8=BD=89=E6=8F=9B=E6=B8=AC=E8=A9=A6=EF=BC=885?= =?UTF-8?q?=E5=80=8B=E5=A0=B4=E6=99=AF=EF=BC=89=20-=20HMAC-SHA256=20?= =?UTF-8?q?=E7=B0=BD=E5=90=8D=E4=B8=80=E8=87=B4=E6=80=A7=E6=B8=AC=E8=A9=A6?= =?UTF-8?q?=20-=20GetBalance=20=E6=B8=AC=E8=A9=A6=EF=BC=88=E5=90=AB?= =?UTF-8?q?=E5=AD=97=E6=AE=B5=E9=A9=97=E8=AD=89=EF=BC=89=20-=20GetPosition?= =?UTF-8?q?s=20=E6=B8=AC=E8=A9=A6=EF=BC=88=E5=90=AB=E6=A8=99=E6=BA=96?= =?UTF-8?q?=E5=8C=96=E6=95=B8=E6=93=9A=E9=A9=97=E8=AD=89=EF=BC=89=20-=20Ge?= =?UTF-8?q?tMarketPrice=20=E6=B8=AC=E8=A9=A6=EF=BC=883=E5=80=8B=E5=A0=B4?= =?UTF-8?q?=E6=99=AF=EF=BC=89=20-=20FormatQuantity=20=E6=B8=AC=E8=A9=A6?= =?UTF-8?q?=EF=BC=885=E5=80=8B=E5=A0=B4=E6=99=AF=EF=BC=89=20-=20SetLeverag?= =?UTF-8?q?e/SetMarginMode=20=E6=B8=AC=E8=A9=A6=20-=20OpenLong/OpenShort?= =?UTF-8?q?=20=E6=B8=AC=E8=A9=A6=20-=20CloseLong/CloseShort=20=E6=B8=AC?= =?UTF-8?q?=E8=A9=A6=20-=20=E7=B7=A9=E5=AD=98=E6=A9=9F=E5=88=B6=E6=B8=AC?= =?UTF-8?q?=E8=A9=A6=20-=20=E9=8C=AF=E8=AA=A4=E8=99=95=E7=90=86=E6=B8=AC?= =?UTF-8?q?=E8=A9=A6=EF=BC=88API=E9=8C=AF=E8=AA=A4=E3=80=81=E7=B6=B2?= =?UTF-8?q?=E7=B5=A1=E9=8C=AF=E8=AA=A4=E3=80=81JSON=E9=8C=AF=E8=AA=A4?= =?UTF-8?q?=EF=BC=89=20=F0=9F=94=A7=20=E6=B8=AC=E8=A9=A6=E5=A5=97=E4=BB=B6?= =?UTF-8?q?=E6=9E=B6=E6=A7=8B=EF=BC=9A=20-=20OKXTraderTestSuite=20?= =?UTF-8?q?=E7=B9=BC=E6=89=BF=20TraderTestSuite=20-=20Mock=20HTTP=20?= =?UTF-8?q?=E6=9C=8D=E5=8B=99=E5=99=A8=E6=A8=A1=E6=93=AC=20OKX=20API=20v5?= =?UTF-8?q?=20=E9=9F=BF=E6=87=89=20-=20=E5=AE=8C=E6=95=B4=E8=A6=86?= =?UTF-8?q?=E8=93=8B=E6=89=80=E6=9C=89=E5=85=AC=E9=96=8B=E6=96=B9=E6=B3=95?= =?UTF-8?q?=20-=20=E5=8C=85=E5=90=AB=E9=82=8A=E7=95=8C=E6=A2=9D=E4=BB=B6?= =?UTF-8?q?=E5=92=8C=E9=8C=AF=E8=AA=A4=E5=A0=B4=E6=99=AF=E6=B8=AC=E8=A9=A6?= =?UTF-8?q?=20=F0=9F=93=88=20=E6=96=B9=E6=B3=95=E8=A6=86=E8=93=8B=E7=8E=87?= =?UTF-8?q?=E6=98=8E=E7=B4=B0=EF=BC=9A=20-=20request:=2090.0%=20-=20GetBal?= =?UTF-8?q?ance:=2097.0%=20-=20GetPositions:=2083.3%=20-=20formatSymbol,?= =?UTF-8?q?=20OpenLong,=20OpenShort,=20CloseLong,=20CloseShort:=20100%=20-?= =?UTF-8?q?=20placeOrder,=20SetMarginMode,=20FormatQuantity,=20clearCache:?= =?UTF-8?q?=20100%=20-=20Cancel*=20=E6=96=B9=E6=B3=95=E7=B3=BB=E5=88=97:?= =?UTF-8?q?=20100%=20-=20SetLeverage:=2081.8%=20-=20GetMarketPrice:=2085.7?= =?UTF-8?q?%=20---------=20Co-authored-by:=20the-dev-z=20=20Co-authored-by:=20Claude=20?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/server.go | 10 +- api/utils.go | 4 + config/database.go | 43 ++- trader/okx_trader.go | 490 ++++++++++++++++++++++++ trader/okx_trader_test.go | 776 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 1314 insertions(+), 9 deletions(-) create mode 100644 trader/okx_trader.go create mode 100644 trader/okx_trader_test.go diff --git a/api/server.go b/api/server.go index 89b6013f..05c61afa 100644 --- a/api/server.go +++ b/api/server.go @@ -443,6 +443,7 @@ type UpdateExchangeConfigRequest struct { AsterPrivateKey string `json:"aster_private_key"` LighterWalletAddr string `json:"lighter_wallet_addr"` LighterPrivateKey string `json:"lighter_private_key"` + OKXPassphrase string `json:"okx_passphrase"` } `json:"exchanges"` } @@ -550,6 +551,13 @@ func (s *Server) handleCreateTrader(c *gin.Context) { switch req.ExchangeID { case "binance": tempTrader = trader.NewFuturesTrader(exchangeCfg.APIKey, exchangeCfg.SecretKey, userID) + case "okx": + tempTrader = trader.NewOKXTrader( + exchangeCfg.APIKey, + exchangeCfg.SecretKey, + exchangeCfg.OKXPassphrase, + exchangeCfg.Testnet, + ) case "hyperliquid": tempTrader, createErr = trader.NewHyperliquidTrader( exchangeCfg.APIKey, // private key @@ -1208,7 +1216,7 @@ func (s *Server) handleUpdateExchangeConfigs(c *gin.Context) { // 更新每个交易所的配置 for exchangeID, exchangeData := range req.Exchanges { - err := s.database.UpdateExchange(userID, exchangeID, exchangeData.Enabled, exchangeData.APIKey, exchangeData.SecretKey, exchangeData.Testnet, exchangeData.HyperliquidWalletAddr, exchangeData.AsterUser, exchangeData.AsterSigner, exchangeData.AsterPrivateKey, exchangeData.LighterWalletAddr, exchangeData.LighterPrivateKey) + err := s.database.UpdateExchange(userID, exchangeID, exchangeData.Enabled, exchangeData.APIKey, exchangeData.SecretKey, exchangeData.Testnet, exchangeData.HyperliquidWalletAddr, exchangeData.AsterUser, exchangeData.AsterSigner, exchangeData.AsterPrivateKey, exchangeData.LighterWalletAddr, exchangeData.LighterPrivateKey, exchangeData.OKXPassphrase) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("更新交易所 %s 失败: %v", exchangeID, err)}) return diff --git a/api/utils.go b/api/utils.go index 6a1a31b5..583b13bd 100644 --- a/api/utils.go +++ b/api/utils.go @@ -46,6 +46,7 @@ func SanitizeExchangeConfigForLog(exchanges map[string]struct { AsterPrivateKey string `json:"aster_private_key"` LighterWalletAddr string `json:"lighter_wallet_addr"` LighterPrivateKey string `json:"lighter_private_key"` + OKXPassphrase string `json:"okx_passphrase"` }) map[string]interface{} { safe := make(map[string]interface{}) for exchangeID, cfg := range exchanges { @@ -67,6 +68,9 @@ func SanitizeExchangeConfigForLog(exchanges map[string]struct { if cfg.LighterPrivateKey != "" { safeExchange["lighter_private_key"] = MaskSensitiveString(cfg.LighterPrivateKey) } + if cfg.OKXPassphrase != "" { + safeExchange["okx_passphrase"] = MaskSensitiveString(cfg.OKXPassphrase) + } // 非敏感字段直接添加 if cfg.HyperliquidWalletAddr != "" { diff --git a/config/database.go b/config/database.go index 466550f3..8a700b20 100644 --- a/config/database.go +++ b/config/database.go @@ -152,6 +152,8 @@ func (d *Database) createTables() error { lighter_wallet_addr TEXT DEFAULT '', lighter_private_key TEXT DEFAULT '', lighter_api_key_private_key TEXT DEFAULT '', + -- OKX 特定字段 + okx_passphrase TEXT DEFAULT '', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE @@ -362,6 +364,7 @@ func (d *Database) createTables() error { `ALTER TABLE exchanges ADD COLUMN lighter_wallet_addr TEXT DEFAULT ''`, `ALTER TABLE exchanges ADD COLUMN lighter_private_key TEXT DEFAULT ''`, `ALTER TABLE exchanges ADD COLUMN lighter_api_key_private_key TEXT DEFAULT ''`, + `ALTER TABLE exchanges ADD COLUMN okx_passphrase TEXT DEFAULT ''`, `ALTER TABLE traders ADD COLUMN custom_prompt TEXT DEFAULT ''`, `ALTER TABLE traders ADD COLUMN override_base_prompt BOOLEAN DEFAULT 0`, `ALTER TABLE traders ADD COLUMN is_cross_margin BOOLEAN DEFAULT 1`, // 默认为全仓模式 @@ -489,6 +492,7 @@ func (d *Database) initDefaultData() error { }{ {"binance", "Binance Futures", "binance"}, {"bybit", "Bybit Futures", "bybit"}, + {"okx", "OKX Futures", "okx"}, {"hyperliquid", "Hyperliquid", "hyperliquid"}, {"aster", "Aster DEX", "aster"}, {"lighter", "LIGHTER DEX", "lighter"}, @@ -748,8 +752,10 @@ type ExchangeConfig struct { LighterWalletAddr string `json:"lighterWalletAddr"` // Ethereum 钱包地址 (L1) LighterPrivateKey string `json:"lighterPrivateKey"` // L1私钥(用于识别账户) LighterAPIKeyPrivateKey string `json:"lighterAPIKeyPrivateKey"` // API Key私钥(40字节,用于签名交易) - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + // OKX 特定字段 + OKXPassphrase string `json:"okxPassphrase"` // OKX API Passphrase + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } // TraderRecord 交易员配置(数据库实体) @@ -1140,7 +1146,16 @@ func (d *Database) GetExchanges(userID string) ([]*ExchangeConfig, error) { COALESCE(aster_private_key, '') as aster_private_key, COALESCE(lighter_wallet_addr, '') as lighter_wallet_addr, COALESCE(lighter_private_key, '') as lighter_private_key, - COALESCE(lighter_api_key_private_key, '') as lighter_api_key_private_key, + COALESCE(lighter_api_key_private_key, '') as lighter_api_key_private_key, + COALESCE(okx_passphrase, '') as okx_passphrase, + COALESCE(lighter_api_key_private_key, '') as lighter_api_key_private_key, + COALESCE(okx_passphrase, '') as okx_passphrase, + COALESCE(lighter_api_key_private_key, '') as lighter_api_key_private_key, + COALESCE(okx_passphrase, '') as okx_passphrase, + COALESCE(lighter_api_key_private_key, '') as lighter_api_key_private_key, + COALESCE(okx_passphrase, '') as okx_passphrase, + COALESCE(lighter_api_key_private_key, '') as lighter_api_key_private_key, + COALESCE(okx_passphrase, '') as okx_passphrase, created_at, updated_at FROM exchanges WHERE user_id = ? ORDER BY id `, userID) @@ -1161,6 +1176,7 @@ func (d *Database) GetExchanges(userID string) ([]*ExchangeConfig, error) { &exchange.AsterSigner, &exchange.AsterPrivateKey, &exchange.LighterWalletAddr, &exchange.LighterPrivateKey, &exchange.LighterAPIKeyPrivateKey, + &exchange.OKXPassphrase, &createdAt, &updatedAt, ) if err != nil { @@ -1177,6 +1193,7 @@ func (d *Database) GetExchanges(userID string) ([]*ExchangeConfig, error) { exchange.AsterPrivateKey = d.decryptSensitiveData(exchange.AsterPrivateKey) exchange.LighterPrivateKey = d.decryptSensitiveData(exchange.LighterPrivateKey) exchange.LighterAPIKeyPrivateKey = d.decryptSensitiveData(exchange.LighterAPIKeyPrivateKey) + exchange.OKXPassphrase = d.decryptSensitiveData(exchange.OKXPassphrase) exchanges = append(exchanges, &exchange) } @@ -1185,8 +1202,8 @@ func (d *Database) GetExchanges(userID string) ([]*ExchangeConfig, error) { } // UpdateExchange 更新交易所配置,如果不存在则创建用户特定配置 -// 🔒 安全特性:空值不会覆盖现有的敏感字段(api_key, secret_key, aster_private_key, lighter_private_key) -func (d *Database) UpdateExchange(userID, id string, enabled bool, apiKey, secretKey string, testnet bool, hyperliquidWalletAddr, asterUser, asterSigner, asterPrivateKey, lighterWalletAddr, lighterPrivateKey string) error { +// 🔒 安全特性:空值不会覆盖现有的敏感字段(api_key, secret_key, aster_private_key, lighter_private_key, okx_passphrase) +func (d *Database) UpdateExchange(userID, id string, enabled bool, apiKey, secretKey string, testnet bool, hyperliquidWalletAddr, asterUser, asterSigner, asterPrivateKey, lighterWalletAddr, lighterPrivateKey, okxPassphrase string) error { log.Printf("🔧 UpdateExchange: userID=%s, id=%s, enabled=%v", userID, id, enabled) // 构建动态 UPDATE SET 子句 @@ -1227,6 +1244,12 @@ func (d *Database) UpdateExchange(userID, id string, enabled bool, apiKey, secre args = append(args, encryptedLighterPrivateKey) } + if okxPassphrase != "" { + encryptedOKXPassphrase := d.encryptSensitiveData(okxPassphrase) + setClauses = append(setClauses, "okx_passphrase = ?") + args = append(args, encryptedOKXPassphrase) + } + // WHERE 条件 args = append(args, id, userID) @@ -1267,6 +1290,9 @@ func (d *Database) UpdateExchange(userID, id string, enabled bool, apiKey, secre } else if id == "hyperliquid" { name = "Hyperliquid" typ = "dex" + } else if id == "okx" { + name = "OKX Futures" + typ = "cex" } else if id == "aster" { name = "Aster DEX" typ = "dex" @@ -1285,14 +1311,15 @@ func (d *Database) UpdateExchange(userID, id string, enabled bool, apiKey, secre encryptedSecretKey := d.encryptSensitiveData(secretKey) encryptedAsterPrivateKey := d.encryptSensitiveData(asterPrivateKey) encryptedLighterPrivateKey := d.encryptSensitiveData(lighterPrivateKey) + encryptedOKXPassphrase := d.encryptSensitiveData(okxPassphrase) // 创建用户特定的配置,使用原始的交易所ID _, err = d.db.Exec(` INSERT INTO exchanges (id, user_id, name, type, enabled, api_key, secret_key, testnet, hyperliquid_wallet_addr, aster_user, aster_signer, aster_private_key, - lighter_wallet_addr, lighter_private_key, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now')) - `, id, userID, name, typ, enabled, encryptedAPIKey, encryptedSecretKey, testnet, hyperliquidWalletAddr, asterUser, asterSigner, encryptedAsterPrivateKey, lighterWalletAddr, encryptedLighterPrivateKey) + lighter_wallet_addr, lighter_private_key, okx_passphrase, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now')) + `, id, userID, name, typ, enabled, encryptedAPIKey, encryptedSecretKey, testnet, hyperliquidWalletAddr, asterUser, asterSigner, encryptedAsterPrivateKey, lighterWalletAddr, encryptedLighterPrivateKey, encryptedOKXPassphrase) if err != nil { log.Printf("❌ UpdateExchange: 创建记录失败: %v", err) diff --git a/trader/okx_trader.go b/trader/okx_trader.go new file mode 100644 index 00000000..0fae17e7 --- /dev/null +++ b/trader/okx_trader.go @@ -0,0 +1,490 @@ +package trader + +import ( + "bytes" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "strconv" + "strings" + "sync" + "time" +) + +// OKXTrader OKX USDT 永續合約交易器 +type OKXTrader struct { + apiKey string + secretKey string + passphrase string + baseURL string + httpClient *http.Client + testnet bool + + // 餘額緩存 + cachedBalance map[string]interface{} + balanceCacheTime time.Time + balanceCacheMutex sync.RWMutex + + // 持倉緩存 + cachedPositions []map[string]interface{} + positionsCacheTime time.Time + positionsCacheMutex sync.RWMutex + + // 緩存有效期(15秒) + cacheDuration time.Duration +} + +// NewOKXTrader 創建 OKX 交易器 +func NewOKXTrader(apiKey, secretKey, passphrase string, testnet bool) *OKXTrader { + baseURL := "https://www.okx.com" + + trader := &OKXTrader{ + apiKey: apiKey, + secretKey: secretKey, + passphrase: passphrase, + baseURL: baseURL, + testnet: testnet, + httpClient: &http.Client{Timeout: 30 * time.Second}, + cacheDuration: 15 * time.Second, + } + + log.Printf("🟠 [OKX] 交易器已初始化 (testnet=%v)", testnet) + return trader +} + +// sign 生成 OKX API v5 簽名 +// 簽名算法:Base64(HMAC-SHA256(timestamp + method + requestPath + body, SecretKey)) +func (t *OKXTrader) sign(timestamp, method, requestPath, body string) string { + // 構建待簽名字符串:timestamp + method + requestPath + body + message := timestamp + method + requestPath + body + + // HMAC-SHA256 簽名 + h := hmac.New(sha256.New, []byte(t.secretKey)) + h.Write([]byte(message)) + signature := base64.StdEncoding.EncodeToString(h.Sum(nil)) + + return signature +} + +// request 發送 HTTP 請求到 OKX API +func (t *OKXTrader) request(method, path string, params map[string]interface{}) (map[string]interface{}, error) { + // 生成 ISO 8601 時間戳(含毫秒) + timestamp := time.Now().UTC().Format("2006-01-02T15:04:05.000Z") + + // 構建請求體 + var bodyBytes []byte + var bodyStr string + if params != nil && len(params) > 0 { + var err error + bodyBytes, err = json.Marshal(params) + if err != nil { + return nil, fmt.Errorf("序列化請求體失敗: %w", err) + } + bodyStr = string(bodyBytes) + } else { + bodyStr = "" + } + + // 構建完整 URL + url := t.baseURL + path + + // 生成簽名 + signature := t.sign(timestamp, method, path, bodyStr) + + // 創建請求 + var req *http.Request + var err error + if bodyStr != "" { + req, err = http.NewRequest(method, url, bytes.NewBuffer(bodyBytes)) + } else { + req, err = http.NewRequest(method, url, nil) + } + if err != nil { + return nil, fmt.Errorf("創建請求失敗: %w", err) + } + + // 設置請求頭 + req.Header.Set("Content-Type", "application/json") + 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) + + // Demo 交易模式 + if t.testnet { + req.Header.Set("x-simulated-trading", "1") + } + + // 發送請求 + 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) + } + + // 解析 JSON + var result map[string]interface{} + if err := json.Unmarshal(respBody, &result); err != nil { + return nil, fmt.Errorf("解析響應失敗: %w, body: %s", err, string(respBody)) + } + + // 檢查錯誤 + if code, ok := result["code"].(string); ok && code != "0" { + msg := result["msg"].(string) + return nil, fmt.Errorf("OKX API 錯誤 [%s]: %s", code, msg) + } + + return result, nil +} + +// GetBalance 獲取賬戶餘額 +func (t *OKXTrader) GetBalance() (map[string]interface{}, error) { + // 檢查緩存 + t.balanceCacheMutex.RLock() + if t.cachedBalance != nil && time.Since(t.balanceCacheTime) < t.cacheDuration { + balance := t.cachedBalance + t.balanceCacheMutex.RUnlock() + log.Printf("✓ 使用緩存的賬戶餘額(緩存時間: %.1f秒前)", time.Since(t.balanceCacheTime).Seconds()) + return balance, nil + } + t.balanceCacheMutex.RUnlock() + + // 調用 API:GET /api/v5/account/balance + log.Printf("🔄 緩存過期,正在調用 OKX API 獲取賬戶餘額...") + result, err := t.request("GET", "/api/v5/account/balance", nil) + if err != nil { + return nil, fmt.Errorf("獲取 OKX 餘額失敗: %w", err) + } + + // 解析響應 + data, ok := result["data"].([]interface{}) + if !ok || len(data) == 0 { + return nil, fmt.Errorf("OKX API 返回數據格式錯誤") + } + + accountData := data[0].(map[string]interface{}) + details := accountData["details"].([]interface{}) + + // 計算 USDT 餘額 + var totalEq, availEq, upl float64 + for _, detail := range details { + d := detail.(map[string]interface{}) + if d["ccy"].(string) == "USDT" { + totalEq, _ = strconv.ParseFloat(d["eq"].(string), 64) + availEq, _ = strconv.ParseFloat(d["availEq"].(string), 64) + uplStr, ok := d["upl"].(string) + if ok { + upl, _ = strconv.ParseFloat(uplStr, 64) + } + break + } + } + + balance := map[string]interface{}{ + "totalWalletBalance": totalEq, + "availableBalance": availEq, + "totalUnrealizedProfit": upl, + "wallet_balance": totalEq, + "available_balance": availEq, + "unrealized_profit": upl, + "balance": totalEq, + } + + // 更新緩存 + t.balanceCacheMutex.Lock() + t.cachedBalance = balance + t.balanceCacheTime = time.Now() + t.balanceCacheMutex.Unlock() + + log.Printf("✓ OKX API 返回: 總餘額=%.2f, 可用=%.2f, 未實現盈虧=%.2f", + totalEq, availEq, upl) + + return balance, nil +} + +// GetPositions 獲取所有持倉 +func (t *OKXTrader) GetPositions() ([]map[string]interface{}, error) { + // 檢查緩存 + t.positionsCacheMutex.RLock() + if t.cachedPositions != nil && time.Since(t.positionsCacheTime) < t.cacheDuration { + positions := t.cachedPositions + t.positionsCacheMutex.RUnlock() + return positions, nil + } + t.positionsCacheMutex.RUnlock() + + // 調用 API:GET /api/v5/account/positions + result, err := t.request("GET", "/api/v5/account/positions", nil) + if err != nil { + return nil, fmt.Errorf("獲取 OKX 持倉失敗: %w", err) + } + + // 解析響應 + data, ok := result["data"].([]interface{}) + if !ok { + return nil, fmt.Errorf("OKX API 返回數據格式錯誤") + } + + positions := make([]map[string]interface{}, 0) + for _, item := range data { + pos := item.(map[string]interface{}) + + // 跳過空倉位 + posStr := pos["pos"].(string) + if posStr == "0" { + continue + } + + // 解析數據 + quantity, _ := strconv.ParseFloat(posStr, 64) + entryPrice, _ := strconv.ParseFloat(pos["avgPx"].(string), 64) + markPrice, _ := strconv.ParseFloat(pos["markPx"].(string), 64) + upl, _ := strconv.ParseFloat(pos["upl"].(string), 64) + leverage, _ := strconv.ParseFloat(pos["lever"].(string), 64) + liqPx, _ := strconv.ParseFloat(pos["liqPx"].(string), 64) + + // 計算保證金 + notionalUsd, _ := strconv.ParseFloat(pos["notionalUsd"].(string), 64) + marginUsed := notionalUsd / leverage + + // 計算盈虧百分比 + uplPct := 0.0 + if entryPrice > 0 { + uplPct = (upl / (quantity * entryPrice)) * 100 + } + + // 處理方向 + side := "long" + if pos["posSide"].(string) == "short" { + side = "short" + quantity = -quantity // 空倉顯示負數 + } + + // 標準化 symbol:BTC-USDT-SWAP → BTCUSDT + instId := pos["instId"].(string) + symbol := strings.ReplaceAll(strings.ReplaceAll(instId, "-USDT-SWAP", ""), "-", "") + + position := map[string]interface{}{ + "symbol": symbol, + "side": side, + "entry_price": entryPrice, + "mark_price": markPrice, + "quantity": quantity, + "leverage": int(leverage), + "unrealized_pnl": upl, + "unrealized_pnl_pct": uplPct, + "liquidation_price": liqPx, + "margin_used": marginUsed, + } + + positions = append(positions, position) + } + + // 更新緩存 + t.positionsCacheMutex.Lock() + t.cachedPositions = positions + t.positionsCacheTime = time.Now() + t.positionsCacheMutex.Unlock() + + return positions, nil +} + +// formatSymbol 將 symbol 轉換為 OKX 格式 +// BTCUSDT → BTC-USDT-SWAP +func (t *OKXTrader) formatSymbol(symbol string) string { + // 移除 USDT 後綴,然後加上 -USDT-SWAP + base := strings.TrimSuffix(strings.ToUpper(symbol), "USDT") + return base + "-USDT-SWAP" +} + +// OpenLong 開多倉 +func (t *OKXTrader) OpenLong(symbol string, quantity float64, leverage int) (map[string]interface{}, error) { + return t.placeOrder(symbol, "buy", "long", quantity, leverage) +} + +// OpenShort 開空倉 +func (t *OKXTrader) OpenShort(symbol string, quantity float64, leverage int) (map[string]interface{}, error) { + return t.placeOrder(symbol, "sell", "short", quantity, leverage) +} + +// CloseLong 平多倉 +func (t *OKXTrader) CloseLong(symbol string, quantity float64) (map[string]interface{}, error) { + return t.placeOrder(symbol, "sell", "long", quantity, 0) +} + +// CloseShort 平空倉 +func (t *OKXTrader) CloseShort(symbol string, quantity float64) (map[string]interface{}, error) { + return t.placeOrder(symbol, "buy", "short", quantity, 0) +} + +// placeOrder 下單核心邏輯 +func (t *OKXTrader) placeOrder(symbol, side, posSide string, quantity float64, leverage int) (map[string]interface{}, error) { + instId := t.formatSymbol(symbol) + + // 如果指定了槓桿,先設置槓桿 + if leverage > 0 { + if err := t.SetLeverage(symbol, leverage); err != nil { + log.Printf("⚠️ 設置槓桿失敗: %v", err) + } + } + + // 構建訂單參數 + params := map[string]interface{}{ + "instId": instId, + "tdMode": "cross", // 全倉模式 + "side": side, // buy/sell + "posSide": posSide, // long/short + "ordType": "market", // 市價單 + "sz": fmt.Sprintf("%f", quantity), + } + + log.Printf("🟠 [OKX] 下單: %s %s %s, 數量=%.4f", instId, side, posSide, quantity) + + // 調用 API:POST /api/v5/trade/order + result, err := t.request("POST", "/api/v5/trade/order", params) + if err != nil { + return nil, fmt.Errorf("OKX 下單失敗: %w", err) + } + + // 清除緩存 + t.clearCache() + + return result, nil +} + +// SetLeverage 設置槓桿 +func (t *OKXTrader) SetLeverage(symbol string, leverage int) error { + instId := t.formatSymbol(symbol) + + params := map[string]interface{}{ + "instId": instId, + "lever": strconv.Itoa(leverage), + "mgnMode": "cross", // 全倉模式 + } + + log.Printf("🟠 [OKX] 設置槓桿: %s, 槓桿=%d", instId, leverage) + + _, err := t.request("POST", "/api/v5/account/set-leverage", params) + if err != nil { + // OKX 如果槓桿已經是目標值會返回錯誤,但可以忽略 + if strings.Contains(err.Error(), "Leverage not modified") { + log.Printf(" ✓ 槓桿已是目標值") + return nil + } + return fmt.Errorf("設置槓桿失敗: %w", err) + } + + log.Printf(" ✓ 槓桿設置成功") + return nil +} + +// SetMarginMode 設置倉位模式(全倉/逐倉) +func (t *OKXTrader) SetMarginMode(symbol string, isCrossMargin bool) error { + // OKX 的保證金模式在下單時指定(tdMode: cross/isolated) + // 這裡僅記錄日誌 + mode := "isolated" + if isCrossMargin { + mode = "cross" + } + log.Printf("🟠 [OKX] 保證金模式: %s (在下單時指定)", mode) + return nil +} + +// GetMarketPrice 獲取市場價格 +func (t *OKXTrader) GetMarketPrice(symbol string) (float64, error) { + instId := t.formatSymbol(symbol) + + // 調用 API:GET /api/v5/market/ticker?instId=BTC-USDT-SWAP + path := fmt.Sprintf("/api/v5/market/ticker?instId=%s", instId) + result, err := t.request("GET", path, nil) + if err != nil { + return 0, fmt.Errorf("獲取市場價格失敗: %w", err) + } + + // 解析響應 + data, ok := result["data"].([]interface{}) + if !ok || len(data) == 0 { + return 0, fmt.Errorf("OKX API 返回數據格式錯誤") + } + + ticker := data[0].(map[string]interface{}) + priceStr := ticker["last"].(string) + price, err := strconv.ParseFloat(priceStr, 64) + if err != nil { + return 0, fmt.Errorf("解析價格失敗: %w", err) + } + + return price, nil +} + +// SetStopLoss 設置止損單 +func (t *OKXTrader) SetStopLoss(symbol string, positionSide string, quantity, stopPrice float64) error { + log.Printf("🟠 [OKX] 設置止損: %s %s, 止損價=%.2f", symbol, positionSide, stopPrice) + // TODO: 實現止損邏輯 + return fmt.Errorf("OKX 止損功能尚未實現") +} + +// SetTakeProfit 設置止盈單 +func (t *OKXTrader) SetTakeProfit(symbol string, positionSide string, quantity, takeProfitPrice float64) error { + log.Printf("🟠 [OKX] 設置止盈: %s %s, 止盈價=%.2f", symbol, positionSide, takeProfitPrice) + // TODO: 實現止盈邏輯 + return fmt.Errorf("OKX 止盈功能尚未實現") +} + +// CancelStopLossOrders 取消止損單 +func (t *OKXTrader) CancelStopLossOrders(symbol string) error { + log.Printf("🟠 [OKX] 取消止損單: %s", symbol) + // TODO: 實現取消止損邏輯 + return nil +} + +// CancelTakeProfitOrders 取消止盈單 +func (t *OKXTrader) CancelTakeProfitOrders(symbol string) error { + log.Printf("🟠 [OKX] 取消止盈單: %s", symbol) + // TODO: 實現取消止盈邏輯 + return nil +} + +// CancelAllOrders 取消所有掛單 +func (t *OKXTrader) CancelAllOrders(symbol string) error { + instId := t.formatSymbol(symbol) + log.Printf("🟠 [OKX] 取消所有掛單: %s", instId) + // TODO: 實現取消所有訂單邏輯 + return nil +} + +// CancelStopOrders 取消止盈止損單 +func (t *OKXTrader) CancelStopOrders(symbol string) error { + log.Printf("🟠 [OKX] 取消止盈止損單: %s", symbol) + // TODO: 實現取消止盈止損邏輯 + return nil +} + +// FormatQuantity 格式化數量到正確的精度 +func (t *OKXTrader) FormatQuantity(symbol string, quantity float64) (string, error) { + // OKX 通常使用合約數量(contracts),不同幣種精度不同 + // 這裡暫時返回標準格式 + return fmt.Sprintf("%.4f", quantity), nil +} + +// clearCache 清除緩存 +func (t *OKXTrader) clearCache() { + t.balanceCacheMutex.Lock() + t.cachedBalance = nil + t.balanceCacheMutex.Unlock() + + t.positionsCacheMutex.Lock() + t.cachedPositions = nil + t.positionsCacheMutex.Unlock() +} diff --git a/trader/okx_trader_test.go b/trader/okx_trader_test.go new file mode 100644 index 00000000..bc2f2901 --- /dev/null +++ b/trader/okx_trader_test.go @@ -0,0 +1,776 @@ +package trader + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +// ============================================================ +// 一、OKXTraderTestSuite - 继承 base test suite +// ============================================================ + +// OKXTraderTestSuite OKX交易器测试套件 +// 继承 TraderTestSuite 并添加 OKX 特定的 mock 逻辑 +type OKXTraderTestSuite struct { + *TraderTestSuite // 嵌入基础测试套件 + mockServer *httptest.Server +} + +// NewOKXTraderTestSuite 创建 OKX 测试套件 +func NewOKXTraderTestSuite(t *testing.T) *OKXTraderTestSuite { + // 创建 mock HTTP 服务器 + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path := r.URL.Path + var respBody interface{} + + switch { + // Mock GetBalance - /api/v5/account/balance + case path == "/api/v5/account/balance": + respBody = map[string]interface{}{ + "code": "0", + "msg": "", + "data": []map[string]interface{}{ + { + "totalEq": "10100.50", + "details": []map[string]interface{}{ + { + "ccy": "USDT", + "eq": "10000.00", + "availEq": "8000.00", + "frozenBal": "2000.00", + "upl": "100.50", + "cashBal": "10000.00", + "ordFrozen": "0", + "liab": "0", + "uTime": "1609459200000", + "crossLiab": "0", + "isoLiab": "0", + "mgnRatio": "", + "interest": "0", + "twap": "0", + "maxLoan": "", + "eqUsd": "10000.00", + "notionalLever": "", + "stgyEq": "0", + "isoEq": "0", + }, + }, + }, + }, + } + + // Mock GetPositions - /api/v5/account/positions + case path == "/api/v5/account/positions": + respBody = map[string]interface{}{ + "code": "0", + "msg": "", + "data": []map[string]interface{}{ + { + "instId": "BTC-USDT-SWAP", + "pos": "0.5", + "posSide": "long", + "avgPx": "50000.00", + "markPx": "50500.00", + "upl": "250.00", + "uplRatio": "0.01", + "lever": "10", + "liqPx": "45000.00", + "notionalUsd": "25250.00", + "instType": "SWAP", + "mgnMode": "cross", + "cTime": "1609459200000", + "uTime": "1609459200000", + }, + }, + } + + // Mock GetMarketPrice - /api/v5/market/ticker + case path == "/api/v5/market/ticker": + instId := r.URL.Query().Get("instId") + if instId == "" { + instId = "BTC-USDT-SWAP" + } + + price := "50000.00" + if instId == "ETH-USDT-SWAP" { + price = "3000.00" + } else if instId == "INVALID-USDT-SWAP" { + // 返回错误 + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "code": "51001", + "msg": "Instrument ID does not exist", + "data": []interface{}{}, + }) + return + } + + respBody = map[string]interface{}{ + "code": "0", + "msg": "", + "data": []map[string]interface{}{ + { + "instId": instId, + "last": price, + "lastSz": "1", + "askPx": price, + "askSz": "10", + "bidPx": price, + "bidSz": "10", + "open24h": price, + "high24h": price, + "low24h": price, + "volCcy24h": "1000000", + "vol24h": "20", + "ts": "1609459200000", + "sodUtc0": price, + "sodUtc8": price, + }, + }, + } + + // Mock CreateOrder - /api/v5/trade/order (POST) + case path == "/api/v5/trade/order" && r.Method == "POST": + respBody = map[string]interface{}{ + "code": "0", + "msg": "", + "data": []map[string]interface{}{ + { + "ordId": "123456789", + "clOrdId": "test_order_123", + "tag": "", + "sCode": "0", + "sMsg": "", + }, + }, + } + + // Mock SetLeverage - /api/v5/account/set-leverage (POST) + case path == "/api/v5/account/set-leverage" && r.Method == "POST": + respBody = map[string]interface{}{ + "code": "0", + "msg": "", + "data": []map[string]interface{}{ + { + "instId": "BTC-USDT-SWAP", + "lever": "10", + "mgnMode": "cross", + "posSide": "long", + }, + }, + } + + // Mock SetMarginMode - /api/v5/account/set-position-mode (POST) + case path == "/api/v5/account/set-position-mode" && r.Method == "POST": + respBody = map[string]interface{}{ + "code": "0", + "msg": "", + "data": []map[string]interface{}{ + { + "posMode": "net_mode", + }, + }, + } + + // Default: empty success response + default: + respBody = map[string]interface{}{ + "code": "0", + "msg": "", + "data": []interface{}{}, + } + } + + // 序列化响应 + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(respBody) + })) + + // 创建 OKXTrader 并设置为使用 mock 服务器 + trader := &OKXTrader{ + apiKey: "test_api_key", + secretKey: "test_secret_key", + passphrase: "test_passphrase", + baseURL: mockServer.URL, + httpClient: mockServer.Client(), + testnet: false, + cacheDuration: 0, // 禁用缓存以便测试 + } + + // 创建基础套件 + baseSuite := NewTraderTestSuite(t, trader) + + return &OKXTraderTestSuite{ + TraderTestSuite: baseSuite, + mockServer: mockServer, + } +} + +// Cleanup 清理资源 +func (s *OKXTraderTestSuite) Cleanup() { + if s.mockServer != nil { + s.mockServer.Close() + } + s.TraderTestSuite.Cleanup() +} + +// ============================================================ +// 二、使用 OKXTraderTestSuite 运行通用测试 +// ============================================================ + +// TestOKXTrader_InterfaceCompliance 测试接口兼容性 +func TestOKXTrader_InterfaceCompliance(t *testing.T) { + var _ Trader = (*OKXTrader)(nil) +} + +// TestOKXTrader_CommonInterface 使用测试套件运行所有通用接口测试 +func TestOKXTrader_CommonInterface(t *testing.T) { + // 创建测试套件 + suite := NewOKXTraderTestSuite(t) + defer suite.Cleanup() + + // 运行所有通用接口测试 + suite.RunAllTests() +} + +// ============================================================ +// 三、OKX 特定功能的单元测试 +// ============================================================ + +// TestNewOKXTrader 测试创建 OKX 交易器 +func TestNewOKXTrader(t *testing.T) { + tests := []struct { + name string + apiKey string + secretKey string + passphrase string + testnet bool + wantNil bool + }{ + { + name: "成功创建(正式环境)", + apiKey: "test_api_key", + secretKey: "test_secret_key", + passphrase: "test_passphrase", + testnet: false, + wantNil: false, + }, + { + name: "成功创建(测试环境)", + apiKey: "test_api_key", + secretKey: "test_secret_key", + passphrase: "test_passphrase", + testnet: true, + wantNil: false, + }, + { + name: "空API Key仍可创建", + apiKey: "", + secretKey: "test_secret_key", + passphrase: "test_passphrase", + testnet: false, + wantNil: false, + }, + { + name: "空Secret Key仍可创建", + apiKey: "test_api_key", + secretKey: "", + passphrase: "test_passphrase", + testnet: false, + wantNil: false, + }, + { + name: "空Passphrase仍可创建", + apiKey: "test_api_key", + secretKey: "test_secret_key", + passphrase: "", + testnet: false, + wantNil: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + trader := NewOKXTrader(tt.apiKey, tt.secretKey, tt.passphrase, tt.testnet) + + if tt.wantNil { + assert.Nil(t, trader) + } else { + assert.NotNil(t, trader) + assert.NotNil(t, trader.httpClient) + assert.Equal(t, tt.apiKey, trader.apiKey) + assert.Equal(t, tt.secretKey, trader.secretKey) + assert.Equal(t, tt.passphrase, trader.passphrase) + assert.Equal(t, tt.testnet, trader.testnet) + + // 检查 baseURL + if tt.testnet { + assert.Equal(t, "https://www.okx.com", trader.baseURL) + } else { + assert.Equal(t, "https://www.okx.com", trader.baseURL) + } + + // 检查缓存时间 + assert.Equal(t, 15*time.Second, trader.cacheDuration) + } + }) + } +} + +// TestOKXTrader_SymbolFormat 测试符号格式转换 +func TestOKXTrader_SymbolFormat(t *testing.T) { + trader := &OKXTrader{} + + tests := []struct { + name string + input string + expected string + }{ + { + name: "BTC USDT Swap", + input: "BTCUSDT", + expected: "BTC-USDT-SWAP", + }, + { + name: "ETH USDT Swap", + input: "ETHUSDT", + expected: "ETH-USDT-SWAP", + }, + { + name: "SOL USDT Swap", + input: "SOLUSDT", + expected: "SOL-USDT-SWAP", + }, + { + name: "小写输入", + input: "btcusdt", + expected: "BTC-USDT-SWAP", + }, + { + name: "混合大小写", + input: "BtcUsdT", + expected: "BTC-USDT-SWAP", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := trader.formatSymbol(tt.input) + assert.Equal(t, tt.expected, result) + }) + } +} + +// TestOKXTrader_Sign 测试签名算法 +func TestOKXTrader_Sign(t *testing.T) { + trader := &OKXTrader{ + secretKey: "test_secret_key", + } + + // 测试签名一致性 + timestamp := "2024-01-01T00:00:00.000Z" + method := "GET" + requestPath := "/api/v5/account/balance" + body := "" + + // 多次签名应该产生相同结果 + sign1 := trader.sign(timestamp, method, requestPath, body) + sign2 := trader.sign(timestamp, method, requestPath, body) + assert.Equal(t, sign1, sign2, "相同输入应产生相同签名") + + // 不同输入应该产生不同签名 + sign3 := trader.sign("2024-01-01T00:00:01.000Z", method, requestPath, body) + assert.NotEqual(t, sign1, sign3, "不同timestamp应产生不同签名") + + sign4 := trader.sign(timestamp, "POST", requestPath, body) + assert.NotEqual(t, sign1, sign4, "不同method应产生不同签名") + + sign5 := trader.sign(timestamp, method, "/api/v5/account/positions", body) + assert.NotEqual(t, sign1, sign5, "不同path应产生不同签名") + + sign6 := trader.sign(timestamp, method, requestPath, `{"instId":"BTC-USDT-SWAP"}`) + assert.NotEqual(t, sign1, sign6, "不同body应产生不同签名") +} + +// TestOKXTrader_GetBalance 测试获取余额 +func TestOKXTrader_GetBalance(t *testing.T) { + // 创建测试套件 + suite := NewOKXTraderTestSuite(t) + defer suite.Cleanup() + + trader := suite.Trader.(*OKXTrader) + + // 测试获取余额 + balance, err := trader.GetBalance() + assert.NoError(t, err) + assert.NotNil(t, balance) + + // 验证返回的标准化余额字段 + assert.Contains(t, balance, "totalWalletBalance") + assert.Contains(t, balance, "availableBalance") + assert.Contains(t, balance, "totalUnrealizedProfit") + assert.Contains(t, balance, "balance") + + // 验证余额值 + totalBalance, ok := balance["totalWalletBalance"].(float64) + assert.True(t, ok) + assert.Equal(t, 10000.00, totalBalance) + + availBalance, ok := balance["availableBalance"].(float64) + assert.True(t, ok) + assert.Equal(t, 8000.00, availBalance) + + upl, ok := balance["totalUnrealizedProfit"].(float64) + assert.True(t, ok) + assert.Equal(t, 100.50, upl) +} + +// TestOKXTrader_GetPositions 测试获取持仓 +func TestOKXTrader_GetPositions(t *testing.T) { + // 创建测试套件 + suite := NewOKXTraderTestSuite(t) + defer suite.Cleanup() + + trader := suite.Trader.(*OKXTrader) + + // 测试获取持仓 + positions, err := trader.GetPositions() + assert.NoError(t, err) + assert.NotNil(t, positions) + assert.GreaterOrEqual(t, len(positions), 1) + + // 验证标准化的持仓字段 + position := positions[0] + assert.Contains(t, position, "symbol") + assert.Contains(t, position, "side") + assert.Contains(t, position, "entry_price") + assert.Contains(t, position, "mark_price") + assert.Contains(t, position, "quantity") + assert.Contains(t, position, "leverage") + assert.Contains(t, position, "unrealized_pnl") + assert.Contains(t, position, "unrealized_pnl_pct") + assert.Contains(t, position, "liquidation_price") + assert.Contains(t, position, "margin_used") + + // 验证具体值(OKX 的数据被标准化) + assert.Equal(t, "BTC", position["symbol"]) // BTC-USDT-SWAP → BTC + assert.Equal(t, "long", position["side"]) + assert.Equal(t, 50000.0, position["entry_price"]) + assert.Equal(t, 50500.0, position["mark_price"]) + assert.Equal(t, 0.5, position["quantity"]) + assert.Equal(t, 10, position["leverage"]) + assert.Equal(t, 250.0, position["unrealized_pnl"]) + assert.Equal(t, 45000.0, position["liquidation_price"]) +} + +// TestOKXTrader_GetMarketPrice 测试获取市场价格 +func TestOKXTrader_GetMarketPrice(t *testing.T) { + // 创建测试套件 + suite := NewOKXTraderTestSuite(t) + defer suite.Cleanup() + + trader := suite.Trader.(*OKXTrader) + + tests := []struct { + name string + symbol string + wantError bool + wantPrice float64 + }{ + { + name: "获取BTC价格", + symbol: "BTCUSDT", + wantError: false, + wantPrice: 50000.00, + }, + { + name: "获取ETH价格", + symbol: "ETHUSDT", + wantError: false, + wantPrice: 3000.00, + }, + { + name: "无效符号", + symbol: "INVALID", + wantError: true, + wantPrice: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + price, err := trader.GetMarketPrice(tt.symbol) + + if tt.wantError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.wantPrice, price) + } + }) + } +} + +// TestOKXTrader_FormatQuantity 测试数量格式化 +func TestOKXTrader_FormatQuantity(t *testing.T) { + trader := &OKXTrader{} + + tests := []struct { + name string + symbol string + quantity float64 + expected string + }{ + { + name: "整数数量", + symbol: "BTCUSDT", + quantity: 1.0, + expected: "1.0000", + }, + { + name: "小数数量", + symbol: "BTCUSDT", + quantity: 0.5, + expected: "0.5000", + }, + { + name: "多位小数(四舍五入到4位)", + symbol: "BTCUSDT", + quantity: 0.123456, + expected: "0.1235", + }, + { + name: "零数量", + symbol: "BTCUSDT", + quantity: 0, + expected: "0.0000", + }, + { + name: "大数量", + symbol: "BTCUSDT", + quantity: 100.123, + expected: "100.1230", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := trader.FormatQuantity(tt.symbol, tt.quantity) + assert.NoError(t, err) + assert.Equal(t, tt.expected, result) + }) + } +} + +// TestOKXTrader_SetLeverage 测试设置杠杆 +func TestOKXTrader_SetLeverage(t *testing.T) { + // 创建测试套件 + suite := NewOKXTraderTestSuite(t) + defer suite.Cleanup() + + trader := suite.Trader.(*OKXTrader) + + // 测试设置杠杆 + err := trader.SetLeverage("BTCUSDT", 10) + assert.NoError(t, err) +} + +// TestOKXTrader_SetMarginMode 测试设置保证金模式 +func TestOKXTrader_SetMarginMode(t *testing.T) { + // 创建测试套件 + suite := NewOKXTraderTestSuite(t) + defer suite.Cleanup() + + trader := suite.Trader.(*OKXTrader) + + // 测试设置保证金模式(cross margin = true) + err := trader.SetMarginMode("BTCUSDT", true) + assert.NoError(t, err) + + // 测试设置保证金模式(isolated margin = false) + err = trader.SetMarginMode("BTCUSDT", false) + assert.NoError(t, err) +} + +// TestOKXTrader_OpenLong 测试开多仓 +func TestOKXTrader_OpenLong(t *testing.T) { + // 创建测试套件 + suite := NewOKXTraderTestSuite(t) + defer suite.Cleanup() + + trader := suite.Trader.(*OKXTrader) + + // 测试开多仓(OKX 的 OpenLong 接受 leverage 参数) + result, err := trader.OpenLong("BTCUSDT", 0.01, 10) + assert.NoError(t, err) + assert.NotNil(t, result) +} + +// TestOKXTrader_OpenShort 测试开空仓 +func TestOKXTrader_OpenShort(t *testing.T) { + // 创建测试套件 + suite := NewOKXTraderTestSuite(t) + defer suite.Cleanup() + + trader := suite.Trader.(*OKXTrader) + + // 测试开空仓(OKX 的 OpenShort 接受 leverage 参数) + result, err := trader.OpenShort("BTCUSDT", 0.01, 10) + assert.NoError(t, err) + assert.NotNil(t, result) +} + +// TestOKXTrader_CloseLong 测试平多仓 +func TestOKXTrader_CloseLong(t *testing.T) { + // 创建测试套件 + suite := NewOKXTraderTestSuite(t) + defer suite.Cleanup() + + trader := suite.Trader.(*OKXTrader) + + // 测试平多仓(OKX 的 CloseLong 只接受 symbol 和 quantity) + result, err := trader.CloseLong("BTCUSDT", 0.01) + assert.NoError(t, err) + assert.NotNil(t, result) +} + +// TestOKXTrader_CloseShort 测试平空仓 +func TestOKXTrader_CloseShort(t *testing.T) { + // 创建测试套件 + suite := NewOKXTraderTestSuite(t) + defer suite.Cleanup() + + trader := suite.Trader.(*OKXTrader) + + // 测试平空仓(OKX 的 CloseShort 只接受 symbol 和 quantity) + result, err := trader.CloseShort("BTCUSDT", 0.01) + assert.NoError(t, err) + assert.NotNil(t, result) +} + +// TestOKXTrader_Cache 测试缓存机制 +func TestOKXTrader_Cache(t *testing.T) { + // 创建测试套件 + suite := NewOKXTraderTestSuite(t) + defer suite.Cleanup() + + trader := suite.Trader.(*OKXTrader) + + // 启用缓存 + trader.cacheDuration = 5 * time.Second + + // 第一次调用 - 应该访问 API + balance1, err := trader.GetBalance() + assert.NoError(t, err) + assert.NotNil(t, balance1) + + // 第二次调用 - 应该使用缓存 + balance2, err := trader.GetBalance() + assert.NoError(t, err) + assert.NotNil(t, balance2) + assert.Equal(t, balance1, balance2) + + // 清空缓存 + trader.balanceCacheMutex.Lock() + trader.cachedBalance = nil + trader.balanceCacheTime = time.Time{} + trader.balanceCacheMutex.Unlock() + + // 第三次调用 - 应该重新访问 API + balance3, err := trader.GetBalance() + assert.NoError(t, err) + assert.NotNil(t, balance3) +} + +// TestOKXTrader_ErrorHandling 测试错误处理 +func TestOKXTrader_ErrorHandling(t *testing.T) { + // 创建错误响应的 mock 服务器 + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "code": "50000", + "msg": "Internal server error", + "data": []interface{}{}, + }) + })) + defer mockServer.Close() + + trader := &OKXTrader{ + apiKey: "test_api_key", + secretKey: "test_secret_key", + passphrase: "test_passphrase", + baseURL: mockServer.URL, + httpClient: mockServer.Client(), + testnet: false, + } + + // 测试各种操作应该返回错误 + _, err := trader.GetBalance() + assert.Error(t, err) + assert.Contains(t, err.Error(), "50000") + + _, err = trader.GetPositions() + assert.Error(t, err) + + _, err = trader.GetMarketPrice("BTCUSDT") + assert.Error(t, err) + + _, err = trader.OpenLong("BTCUSDT", 0.01, 10) + assert.Error(t, err) + + err = trader.SetLeverage("BTCUSDT", 10) + assert.Error(t, err) +} + +// TestOKXTrader_HTTPRequestError 测试 HTTP 请求错误 +func TestOKXTrader_HTTPRequestError(t *testing.T) { + // 使用无效的 baseURL + trader := &OKXTrader{ + apiKey: "test_api_key", + secretKey: "test_secret_key", + passphrase: "test_passphrase", + baseURL: "http://invalid-url-that-does-not-exist-12345.com", + httpClient: &http.Client{Timeout: 1 * time.Second}, + testnet: false, + } + + // 测试各种操作应该返回网络错误 + _, err := trader.GetBalance() + assert.Error(t, err) + + _, err = trader.GetPositions() + assert.Error(t, err) + + _, err = trader.GetMarketPrice("BTCUSDT") + assert.Error(t, err) +} + +// TestOKXTrader_InvalidJSON 测试无效 JSON 响应 +func TestOKXTrader_InvalidJSON(t *testing.T) { + // 创建返回无效 JSON 的 mock 服务器 + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, "invalid json{{{") + })) + defer mockServer.Close() + + trader := &OKXTrader{ + apiKey: "test_api_key", + secretKey: "test_secret_key", + passphrase: "test_passphrase", + baseURL: mockServer.URL, + httpClient: mockServer.Client(), + testnet: false, + } + + // 测试应该返回 JSON 解析错误 + _, err := trader.GetBalance() + assert.Error(t, err) + assert.Contains(t, err.Error(), "解析") +}