Files
exchange-monitor-go/exchange/hyperliquid_trade.go
T
jackyu66gitandClaude Opus 4.7 f97ac16b1c feat: 实际成交价追踪 + reduceOnly保护 + 数据库增强
- 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>
2026-05-05 02:59:18 +08:00

267 lines
7.6 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
}
// 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")
}