Files
exchange-monitor-go/exchange/hyperliquid_trade.go
T
jackyu66git d01a261828 fix: 删除零成交误判逻辑 + 各种稳定性修复
- 删除: Bitget GetTradeFee 零成交检查(PlaceMarketOrder 成功即成交)
- 修复: GetTradeFee 加 1s 延迟 + 查不到返回 0(用配置估算费兜底)
- 修复: HL InitExchange 在 NewTrader 中提前调用,避免 szDecimals 延迟
- 修复: close_failed 30 次重试上限,超限标记 failed 并清理
- 修复: DB 恢复时校验 Legs 完整性,跳过非法记录
- 修复: checkScaleIn/checkExit nil guard 防 panic
- 修复: config.go 参数调整(手续费、阈值等)
- 移除: scanner.go 中 MEW/USTC 等低流动性币对
- 添加: 更详细的下单日志(szStr、amountUSD、price)
- 添加: bin/ 到 .gitignore
2026-05-05 00:41:39 +08:00

222 lines
6.0 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 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
}
// 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) {
// HL MarketOpen returns a single OrderStatus object (NOT wrapped in statuses array):
// {"resting":..., "filled":{"totalSz":"82.5","avgPx":"0.12153","oid":52463955193}, "error":...}
var resp struct {
Resting *json.RawMessage `json:"resting,omitempty"`
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, 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 sz * px * takerFeePct / 100, nil
}
}
return 0, fmt.Errorf("no filled status 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")
}