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>
This commit is contained in:
jackyu66git
2026-05-05 02:59:18 +08:00
co-authored by Claude Opus 4.7
parent c0cafb0400
commit f97ac16b1c
4 changed files with 355 additions and 71 deletions
+52 -7
View File
@@ -168,32 +168,77 @@ func (h *HyperLiquidTrade) PlaceMarketOrder(coin, side, sz string) (string, erro
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) {
// 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 {
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, fmt.Errorf("parse: %w", err)
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 sz * px * takerFeePct / 100, nil
return px, sz, nil
}
}
return 0, fmt.Errorf("no filled status in response")
return 0, 0, fmt.Errorf("no filled data in response")
}
func (h *HyperLiquidTrade) GetBalance() (float64, error) {