- Bitget GetTradeFee 返回实际成交均价(weighted avg fill price)及手续费, 支持最多5次重试 - HyperLiquid ParseFillFromResponse 提取订单成交均价, 新增 GetSize/GetBalance 方法 - 开仓/加仓/平仓均从交易所获取实际成交价替代WS估算价 - HL 平仓使用 reduceOnly 防止反向开仓 - 所有 OrderID 保存到数据库 orders 表 - 加仓零成交检测及实际手续费获取 - PnL 计算修正为按USD计算手续费 - 新增 GetAllClosedTrades / GetClosedStats 数据库查询 - 服务器重启 restore 未完成交易修复(DBTradeID 缺失) - 黑名单强平添加 USD PnL/手续费预计算 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
267 lines
7.6 KiB
Go
267 lines
7.6 KiB
Go
package exchange
|
||
|
||
import (
|
||
"context"
|
||
"crypto/ecdsa"
|
||
"encoding/hex"
|
||
"encoding/json"
|
||
"fmt"
|
||
"log"
|
||
"math"
|
||
"strconv"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
"github.com/ethereum/go-ethereum/crypto"
|
||
hl "github.com/sonirico/go-hyperliquid"
|
||
)
|
||
|
||
type HyperLiquidTrade struct {
|
||
exchange *hl.Exchange
|
||
info *hl.Info
|
||
privateKey *ecdsa.PrivateKey
|
||
mainAddress string
|
||
nonceMu sync.Mutex
|
||
lastNonce int64
|
||
configured bool
|
||
|
||
// szDecimals maps coin name -> decimal places for size formatting
|
||
// Populated from HL Meta on initExchange()
|
||
szDecimals map[string]int
|
||
}
|
||
|
||
func NewHyperLiquidTrade(privateKeyHex, mainAddress, apiAddress string) (*HyperLiquidTrade, error) {
|
||
if privateKeyHex == "" {
|
||
return &HyperLiquidTrade{}, nil
|
||
}
|
||
|
||
keyHex := strings.TrimPrefix(privateKeyHex, "0x")
|
||
keyBytes, err := hex.DecodeString(keyHex)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("decode private key: %w", err)
|
||
}
|
||
|
||
privKey, err := crypto.ToECDSA(keyBytes)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("to ECDSA: %w", err)
|
||
}
|
||
|
||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||
defer cancel()
|
||
|
||
info := hl.NewInfo(ctx, hl.MainnetAPIURL, true, nil, nil, nil)
|
||
|
||
return &HyperLiquidTrade{
|
||
privateKey: privKey,
|
||
mainAddress: mainAddress,
|
||
info: info,
|
||
configured: true,
|
||
}, nil
|
||
}
|
||
|
||
// InitExchange ensures the HL exchange is initialized (fetches metadata, szDecimals, etc.).
|
||
// Safe to call multiple times — no-op after first initialization.
|
||
// Must be called before GetSize or PlaceMarketOrder for accurate size formatting.
|
||
func (h *HyperLiquidTrade) InitExchange() error {
|
||
return h.initExchange()
|
||
}
|
||
|
||
func (h *HyperLiquidTrade) initExchange() error {
|
||
if h.exchange != nil {
|
||
return nil
|
||
}
|
||
if !h.configured {
|
||
return fmt.Errorf("HL not configured")
|
||
}
|
||
|
||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||
defer cancel()
|
||
|
||
meta, err := h.info.Meta(ctx)
|
||
if err != nil {
|
||
return fmt.Errorf("meta: %w", err)
|
||
}
|
||
spotMeta, err := h.info.SpotMeta(ctx)
|
||
if err != nil {
|
||
return fmt.Errorf("spot meta: %w", err)
|
||
}
|
||
|
||
h.exchange = hl.NewExchange(ctx, h.privateKey, hl.MainnetAPIURL, meta, "", h.mainAddress, spotMeta, nil)
|
||
|
||
// Build szDecimals map from HL Meta for correct size formatting
|
||
h.szDecimals = make(map[string]int, len(meta.Universe))
|
||
for _, asset := range meta.Universe {
|
||
h.szDecimals[asset.Name] = asset.SzDecimals
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// GetSize returns a formatted size string for HL orders using the correct szDecimals.
|
||
func (h *HyperLiquidTrade) GetSize(coin string, amountUSD, price float64) string {
|
||
sz := amountUSD / price
|
||
decimals, ok := h.szDecimals[coin]
|
||
if !ok {
|
||
// Fallback: 4 decimal places
|
||
return fmt.Sprintf("%.4f", math.Floor(sz*10000)/10000)
|
||
}
|
||
switch decimals {
|
||
case 0:
|
||
sz = math.Floor(sz)
|
||
if sz < 1 {
|
||
sz = 1
|
||
}
|
||
return fmt.Sprintf("%.0f", sz)
|
||
case 1:
|
||
sz = math.Floor(sz*10) / 10
|
||
if sz < 0.1 {
|
||
sz = 0.1
|
||
}
|
||
return fmt.Sprintf("%.1f", sz)
|
||
case 2:
|
||
sz = math.Floor(sz*100) / 100
|
||
if sz < 0.01 {
|
||
sz = 0.01
|
||
}
|
||
return fmt.Sprintf("%.2f", sz)
|
||
default:
|
||
mult := math.Pow10(decimals)
|
||
sz = math.Floor(sz*mult) / mult
|
||
if sz < 1/mult {
|
||
sz = 1 / mult
|
||
}
|
||
return fmt.Sprintf("%."+strconv.Itoa(decimals)+"f", sz)
|
||
}
|
||
}
|
||
|
||
func (h *HyperLiquidTrade) IsConfigured() bool {
|
||
return h.configured
|
||
}
|
||
|
||
// PlaceMarketOrder places a market order and returns the raw JSON response.
|
||
func (h *HyperLiquidTrade) PlaceMarketOrder(coin, side, sz string) (string, error) {
|
||
if !h.configured {
|
||
return "", fmt.Errorf("HL not configured")
|
||
}
|
||
if err := h.initExchange(); err != nil {
|
||
return "", fmt.Errorf("init: %w", err)
|
||
}
|
||
|
||
isBuy := side == "buy"
|
||
size, _ := strconv.ParseFloat(sz, 64)
|
||
|
||
// Find szDecimals for this coin
|
||
decimals := 4
|
||
if d, ok := h.szDecimals[coin]; ok {
|
||
decimals = d
|
||
}
|
||
|
||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||
defer cancel()
|
||
|
||
log.Printf("[Order] HL MarketOpen | coin=%s isBuy=%v size=%.*f szDecimals=%d slippage=0.05 px=nil", coin, isBuy, decimals, size, decimals)
|
||
result, err := h.exchange.MarketOpen(ctx, coin, isBuy, size, nil, 0.05, nil, nil)
|
||
if err != nil {
|
||
return "", fmt.Errorf("market open: %w", err)
|
||
}
|
||
respJSON, _ := json.Marshal(result)
|
||
return string(respJSON), nil
|
||
}
|
||
|
||
// PlaceMarketCloseOrder closes a position on HL with reduceOnly protection.
|
||
// Uses the SDK's MarketClose which sets ReduceOnly=true to prevent accidental reversals.
|
||
// sz is the size string (same format as PlaceMarketOrder). Pass "0" or "" to close full position.
|
||
func (h *HyperLiquidTrade) PlaceMarketCloseOrder(coin, sz string) (string, error) {
|
||
if !h.configured {
|
||
return "", fmt.Errorf("HL not configured")
|
||
}
|
||
if err := h.initExchange(); err != nil {
|
||
return "", fmt.Errorf("init: %w", err)
|
||
}
|
||
|
||
var size *float64
|
||
if f, err := strconv.ParseFloat(sz, 64); err == nil && f > 0 {
|
||
size = &f
|
||
}
|
||
|
||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||
defer cancel()
|
||
|
||
log.Printf("[Order] HL MarketClose | coin=%s size=%v reduceOnly=true slippage=0.05", coin, size)
|
||
result, err := h.exchange.MarketClose(ctx, coin, size, nil, 0.05, nil, nil)
|
||
if err != nil {
|
||
return "", fmt.Errorf("market close: %w", err)
|
||
}
|
||
respJSON, _ := json.Marshal(result)
|
||
return string(respJSON), nil
|
||
}
|
||
|
||
// EstimateFeeFromResponse calculates the fee using the response's filled size × price
|
||
// × configured taker rate. This is NOT an actual fee from HL — HL does not return
|
||
// fee amounts in the order response. The result is equivalent to estimating from
|
||
// TradeAmountUSD, but more accurate for partial fills since it uses actual filled sz/px.
|
||
func (h *HyperLiquidTrade) EstimateFeeFromResponse(orderResponseJSON string, takerFeePct float64) (feeUSD float64, err error) {
|
||
var resp struct {
|
||
Filled *struct {
|
||
TotalSz string `json:"totalSz"`
|
||
AvgPx string `json:"avgPx"`
|
||
} `json:"filled,omitempty"`
|
||
}
|
||
if err := json.Unmarshal([]byte(orderResponseJSON), &resp); err != nil || resp.Filled == nil {
|
||
return 0, fmt.Errorf("no filled data in response")
|
||
}
|
||
sz, _ := strconv.ParseFloat(resp.Filled.TotalSz, 64)
|
||
px, _ := strconv.ParseFloat(resp.Filled.AvgPx, 64)
|
||
if sz > 0 && px > 0 {
|
||
return sz * px * takerFeePct / 100, nil
|
||
}
|
||
return 0, fmt.Errorf("no filled status in response")
|
||
}
|
||
|
||
// ParseFillFromResponse extracts the average fill price and total filled size
|
||
// from an HL MarketOpen/MarketClose response. Returns (avgFillPrice, filledSize, error).
|
||
func (h *HyperLiquidTrade) ParseFillFromResponse(orderResponseJSON string) (avgPrice, filledSize float64, err error) {
|
||
var resp struct {
|
||
Filled *struct {
|
||
TotalSz string `json:"totalSz"`
|
||
AvgPx string `json:"avgPx"`
|
||
} `json:"filled,omitempty"`
|
||
Error *string `json:"error,omitempty"`
|
||
}
|
||
if err := json.Unmarshal([]byte(orderResponseJSON), &resp); err != nil {
|
||
return 0, 0, fmt.Errorf("parse: %w", err)
|
||
}
|
||
if resp.Filled != nil {
|
||
sz, _ := strconv.ParseFloat(resp.Filled.TotalSz, 64)
|
||
px, _ := strconv.ParseFloat(resp.Filled.AvgPx, 64)
|
||
if sz > 0 && px > 0 {
|
||
return px, sz, nil
|
||
}
|
||
}
|
||
return 0, 0, fmt.Errorf("no filled data in response")
|
||
}
|
||
|
||
func (h *HyperLiquidTrade) GetBalance() (float64, error) {
|
||
if !h.configured {
|
||
return 0, fmt.Errorf("HL not configured")
|
||
}
|
||
|
||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||
defer cancel()
|
||
|
||
// HL testnet USDC is on spot, not perp. Use SpotUserState.
|
||
state, err := h.info.SpotUserState(ctx, h.mainAddress)
|
||
if err != nil {
|
||
return 0, fmt.Errorf("spot user state: %w", err)
|
||
}
|
||
|
||
for _, b := range state.Balances {
|
||
if b.Coin == "USDC" {
|
||
total, _ := strconv.ParseFloat(b.Total, 64)
|
||
hold, _ := strconv.ParseFloat(b.Hold, 64)
|
||
return total - hold, nil
|
||
}
|
||
}
|
||
return 0, fmt.Errorf("USDC balance not found in spot state")
|
||
}
|