feat: 重构为三所价差异动监控系统
删除 HyperLiquid + 全部交易功能,构建自适应 surge 检测器。 - 新增 surge_detector.go: 每币独立滚动窗口基线,检测三所价差异常飙升 - 新增 SpreadCard/SurgeCard 前端组件 - 保留 momentum/trend/cumulative/trend_filter 扫描功能 - 更新文档和配置以反映新系统 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
559d7bb870
commit
d38782490c
@@ -1,279 +0,0 @@
|
||||
package exchange
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type BitgetTrade struct {
|
||||
APIKey string
|
||||
APISecret string
|
||||
Passphrase string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func NewBitgetTrade(apiKey, apiSecret, passphrase string) *BitgetTrade {
|
||||
return &BitgetTrade{
|
||||
APIKey: apiKey,
|
||||
APISecret: apiSecret,
|
||||
Passphrase: passphrase,
|
||||
client: &http.Client{Timeout: 10 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
func (b *BitgetTrade) PlaceMarketOrder(side, symbol, size, tradeSide, holdSide string) (string, error) {
|
||||
ts := fmt.Sprintf("%d", time.Now().UnixMilli())
|
||||
method := "POST"
|
||||
|
||||
requestPath := "/api/v2/mix/order/place-order"
|
||||
host := "https://api.bitget.com"
|
||||
|
||||
body := map[string]interface{}{
|
||||
"marginCoin": "USDT",
|
||||
"symbol": symbol,
|
||||
"productType": "USDT-FUTURES",
|
||||
"side": side,
|
||||
"orderType": "market",
|
||||
"timeInForce": "IOC",
|
||||
"marginMode": "crossed",
|
||||
"tradeSide": tradeSide,
|
||||
"size": size,
|
||||
}
|
||||
// Close orders require holdSide to identify which position to close
|
||||
if tradeSide == "close" && holdSide != "" {
|
||||
body["holdSide"] = holdSide
|
||||
}
|
||||
bodyJSON, _ := json.Marshal(body)
|
||||
|
||||
sign := b.sign(method, requestPath, ts, string(bodyJSON))
|
||||
url := host + requestPath
|
||||
req, _ := http.NewRequest(method, url, strings.NewReader(string(bodyJSON)))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("ACCESS-KEY", b.APIKey)
|
||||
req.Header.Set("ACCESS-SIGN", sign)
|
||||
req.Header.Set("ACCESS-TIMESTAMP", ts)
|
||||
req.Header.Set("ACCESS-PASSPHRASE", b.Passphrase)
|
||||
|
||||
resp, err := b.client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("http request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
|
||||
var result struct {
|
||||
Code string `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Data struct {
|
||||
OrderID string `json:"orderId"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(respBody, &result); err != nil {
|
||||
return "", fmt.Errorf("parse: %s", string(respBody))
|
||||
}
|
||||
if result.Code != "00000" {
|
||||
return "", fmt.Errorf("bitget error: %s - %s", result.Code, result.Msg)
|
||||
}
|
||||
return result.Data.OrderID, nil
|
||||
}
|
||||
// GetTradeFee queries the fills endpoint for actual fee charged and average fill price.
|
||||
// Retries up to 5 times with 500ms intervals because Bitget's fills API may lag.
|
||||
// Returns (average fill price, fee in USD, error). avgPrice=0 on any fills issue.
|
||||
func (b *BitgetTrade) GetTradeFee(symbol, orderID string) (avgPrice, feeUSD float64, err error) {
|
||||
for i := 0; i < 5; i++ {
|
||||
if i > 0 {
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
}
|
||||
ts := fmt.Sprintf("%d", time.Now().UnixMilli())
|
||||
method := "GET"
|
||||
requestPath := "/api/v2/mix/order/fills?symbol=" + symbol + "&orderId=" + orderID + "&productType=USDT-FUTURES"
|
||||
host := "https://api.bitget.com"
|
||||
|
||||
sign := b.sign(method, requestPath, ts, "")
|
||||
url := host + requestPath
|
||||
req, _ := http.NewRequest(method, url, nil)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("ACCESS-KEY", b.APIKey)
|
||||
req.Header.Set("ACCESS-SIGN", sign)
|
||||
req.Header.Set("ACCESS-TIMESTAMP", ts)
|
||||
req.Header.Set("ACCESS-PASSPHRASE", b.Passphrase)
|
||||
|
||||
resp, err := b.client.Do(req)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("http: %w", err)
|
||||
}
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
|
||||
var raw struct {
|
||||
Code string `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Data struct {
|
||||
FillList []json.RawMessage `json:"fillList"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(respBody, &raw); err != nil {
|
||||
return 0, 0, fmt.Errorf("parse: %s", string(respBody))
|
||||
}
|
||||
if raw.Code != "00000" {
|
||||
return 0, 0, fmt.Errorf("bitget error: %s - %s", raw.Code, raw.Msg)
|
||||
}
|
||||
|
||||
var totalFee, totalQty, totalCost float64
|
||||
for _, item := range raw.Data.FillList {
|
||||
var fill struct {
|
||||
FillPrice string `json:"fillPrice"`
|
||||
FillSize string `json:"fillBaseSize"`
|
||||
FillFee string `json:"fillFee"`
|
||||
}
|
||||
if err := json.Unmarshal(item, &fill); err != nil {
|
||||
continue
|
||||
}
|
||||
f, _ := strconv.ParseFloat(fill.FillFee, 64)
|
||||
p, _ := strconv.ParseFloat(fill.FillPrice, 64)
|
||||
q, _ := strconv.ParseFloat(fill.FillSize, 64)
|
||||
totalFee += math.Abs(f)
|
||||
totalCost += p * q
|
||||
totalQty += q
|
||||
}
|
||||
if totalQty > 0 {
|
||||
return totalCost / totalQty, totalFee, nil
|
||||
}
|
||||
}
|
||||
return 0, 0, fmt.Errorf("no fill data after 5 attempts")
|
||||
}
|
||||
|
||||
// CheckPosition returns the available position size for a coin, or 0 if no position.
|
||||
// Returns (total as float64, raw total string from API) — the raw string can be used
|
||||
// for close orders to ensure correct precision.
|
||||
func (b *BitgetTrade) CheckPosition(symbol string) (float64, string) {
|
||||
ts := fmt.Sprintf("%d", time.Now().UnixMilli())
|
||||
method := "GET"
|
||||
requestPath := "/api/v2/mix/position/single-position?symbol=" + symbol + "&productType=USDT-FUTURES&marginCoin=USDT"
|
||||
host := "https://api.bitget.com"
|
||||
sign := b.sign(method, requestPath, ts, "")
|
||||
url := host + requestPath
|
||||
req, _ := http.NewRequest(method, url, nil)
|
||||
req.Header.Set("ACCESS-KEY", b.APIKey)
|
||||
req.Header.Set("ACCESS-SIGN", sign)
|
||||
req.Header.Set("ACCESS-TIMESTAMP", ts)
|
||||
req.Header.Set("ACCESS-PASSPHRASE", b.Passphrase)
|
||||
|
||||
resp, err := b.client.Do(req)
|
||||
if err != nil {
|
||||
return 0, ""
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
|
||||
var raw struct {
|
||||
Code string `json:"code"`
|
||||
Data []struct {
|
||||
Total string `json:"total"`
|
||||
} `json:"data"`
|
||||
}
|
||||
json.Unmarshal(respBody, &raw)
|
||||
if raw.Code != "00000" || len(raw.Data) == 0 {
|
||||
return 0, ""
|
||||
}
|
||||
total, _ := strconv.ParseFloat(raw.Data[0].Total, 64)
|
||||
return total, raw.Data[0].Total
|
||||
}
|
||||
|
||||
func (b *BitgetTrade) sign(method, requestPath, timestamp, body string) string {
|
||||
raw := timestamp + method + requestPath + body
|
||||
mac := hmac.New(sha256.New, []byte(b.APISecret))
|
||||
mac.Write([]byte(raw))
|
||||
return base64.StdEncoding.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
func (b *BitgetTrade) GetBalance() (float64, error) {
|
||||
ts := fmt.Sprintf("%d", time.Now().UnixMilli())
|
||||
method := "GET"
|
||||
host := "https://api.bitget.com"
|
||||
requestPath := "/api/v2/mix/account/accounts?productType=USDT-FUTURES"
|
||||
|
||||
sign := b.sign(method, requestPath, ts, "")
|
||||
url := host + requestPath
|
||||
req, _ := http.NewRequest(method, url, nil)
|
||||
req.Header.Set("ACCESS-KEY", b.APIKey)
|
||||
req.Header.Set("ACCESS-SIGN", sign)
|
||||
req.Header.Set("ACCESS-TIMESTAMP", ts)
|
||||
req.Header.Set("ACCESS-PASSPHRASE", b.Passphrase)
|
||||
|
||||
resp, err := b.client.Do(req)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("http: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
|
||||
var raw map[string]interface{}
|
||||
if err := json.Unmarshal(respBody, &raw); err != nil {
|
||||
return 0, fmt.Errorf("parse: %s", string(respBody))
|
||||
}
|
||||
code, _ := raw["code"].(string)
|
||||
if code != "00000" && code != "" {
|
||||
msg, _ := raw["msg"].(string)
|
||||
return 0, fmt.Errorf("bitget error: %s - %s", code, msg)
|
||||
}
|
||||
|
||||
dataRaw, ok := raw["data"]
|
||||
if !ok || dataRaw == nil {
|
||||
return 0, fmt.Errorf("no data in response")
|
||||
}
|
||||
dataArr, ok := dataRaw.([]interface{})
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("unexpected data format")
|
||||
}
|
||||
for _, item := range dataArr {
|
||||
acct, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if acct["marginCoin"] == "USDT" {
|
||||
bal, _ := strconv.ParseFloat(fmt.Sprint(acct["available"]), 64)
|
||||
return bal, nil
|
||||
}
|
||||
}
|
||||
return 0, fmt.Errorf("no USDT account found")
|
||||
}
|
||||
|
||||
func GetBitgetSize(symbol string, amountUSD, price float64) string {
|
||||
if amountUSD < 5 {
|
||||
amountUSD = 5
|
||||
}
|
||||
sz := amountUSD / price
|
||||
switch symbol {
|
||||
case "DOGEUSDT":
|
||||
if sz < 1 { sz = 1 }
|
||||
return fmt.Sprintf("%.0f", math.Floor(sz))
|
||||
case "ONDOUSDT":
|
||||
sz = math.Floor(sz*10)/10
|
||||
if sz < 0.1 { sz = 0.1 }
|
||||
return fmt.Sprintf("%.1f", sz)
|
||||
case "OPUSDT":
|
||||
sz = math.Floor(sz*10)/10
|
||||
if sz < 0.1 { sz = 0.1 }
|
||||
return fmt.Sprintf("%.1f", sz)
|
||||
case "WIFUSDT":
|
||||
sz = math.Floor(sz*10)/10
|
||||
if sz < 0.1 { sz = 0.1 }
|
||||
return fmt.Sprintf("%.1f", sz)
|
||||
case "ARBUSDT":
|
||||
sz = math.Floor(sz*100)/100
|
||||
if sz < 0.01 { sz = 0.01 }
|
||||
return fmt.Sprintf("%.2f", sz)
|
||||
default:
|
||||
return fmt.Sprintf("%.4f", sz)
|
||||
}
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
package exchange
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
type HyperLiquidWS struct {
|
||||
Tracked []string
|
||||
}
|
||||
|
||||
type hlAllMidsMsg struct {
|
||||
Channel string `json:"channel"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
|
||||
type hlMidsData struct {
|
||||
Mids map[string]string `json:"mids"`
|
||||
}
|
||||
|
||||
func NewHyperLiquidWS(tracked []string) *HyperLiquidWS {
|
||||
return &HyperLiquidWS{Tracked: tracked}
|
||||
}
|
||||
// Run connects to HyperLiquid WS and streams mid prices.
|
||||
func (h *HyperLiquidWS) Run(updateFn func(coin string, price, bid, ask float64)) error {
|
||||
conn := NewPriceConnector("wss://api.hyperliquid.xyz/ws", "HyperLiquid", 120*time.Second, 30*time.Second)
|
||||
conn.PingInterval = 45 * time.Second
|
||||
|
||||
conn.OnConnect = func() {
|
||||
log.Printf("[HL WS] Connected")
|
||||
sub := map[string]interface{}{
|
||||
"method": "subscribe",
|
||||
"subscription": map[string]string{
|
||||
"type": "allMids",
|
||||
},
|
||||
}
|
||||
if err := conn.SendJSON(sub); err != nil {
|
||||
log.Printf("[HL WS] Subscribe error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
conn.OnMessage = func(msg []byte) {
|
||||
var raw hlAllMidsMsg
|
||||
if err := json.Unmarshal(msg, &raw); err != nil {
|
||||
return
|
||||
}
|
||||
if raw.Channel != "allMids" {
|
||||
return
|
||||
}
|
||||
var data hlMidsData
|
||||
if err := json.Unmarshal(raw.Data, &data); err != nil {
|
||||
return
|
||||
}
|
||||
for coin, priceStr := range data.Mids {
|
||||
price, err := strconv.ParseFloat(priceStr, 64)
|
||||
if err != nil || price <= 0 {
|
||||
continue
|
||||
}
|
||||
updateFn(coin, price, 0, 0)
|
||||
}
|
||||
}
|
||||
|
||||
return conn.Run()
|
||||
}
|
||||
@@ -1,266 +0,0 @@
|
||||
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")
|
||||
}
|
||||
Reference in New Issue
Block a user