diff --git a/db/trade_repo.go b/db/trade_repo.go index 1492d2d..d59c027 100644 --- a/db/trade_repo.go +++ b/db/trade_repo.go @@ -111,6 +111,13 @@ func (d *DB) SetTradeStatus(id int64, status string) error { return err } +// UpdateTradeEntry updates entry-related fields on an existing trade (prices, exchanges, spread). +func (d *DB) UpdateTradeEntry(id int64, t *TradeRecord) error { + _, err := d.Exec(`UPDATE trades SET long_entry=?, short_entry=?, long_exchange=?, short_exchange=?, entry_spread=? WHERE id=?`, + t.LongEntry, t.ShortEntry, t.LongExchange, t.ShortExchange, t.EntrySpread, id) + return err +} + // GetOpenTrades returns all non-closed trades (status='open' or status='entering'). func (d *DB) GetOpenTrades() ([]TradeRecord, error) { rows, err := d.Query(`SELECT id, coin, direction, status, entry_spread, exit_spread, diff --git a/exchange/bitget_trade.go b/exchange/bitget_trade.go index 3810e96..70a40c2 100644 --- a/exchange/bitget_trade.go +++ b/exchange/bitget_trade.go @@ -48,7 +48,7 @@ func (b *BitgetTrade) PlaceMarketOrder(side, symbol, size, tradeSide, holdSide s "tradeSide": tradeSide, "size": size, } - // When closing, Bitget requires holdSide to identify which position to close + // Close orders require holdSide to identify which position to close if tradeSide == "close" && holdSide != "" { body["holdSide"] = holdSide } @@ -85,21 +85,84 @@ func (b *BitgetTrade) PlaceMarketOrder(side, symbol, size, tradeSide, holdSide s } return result.Data.OrderID, nil } -// GetTradeFee queries the fills endpoint for actual fee charged. -// Waits 1s before querying because Bitget's fills API may lag behind -// the place-order response. Returns 0 if no fills yet (caller uses -// estimated fee from config as fallback). -func (b *BitgetTrade) GetTradeFee(symbol, orderID string) (feeUSD float64, err error) { - time.Sleep(1 * time.Second) +// 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/order/fills?symbol=" + symbol + "&orderId=" + orderID + "&productType=USDT-FUTURES" + 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("Content-Type", "application/json") req.Header.Set("ACCESS-KEY", b.APIKey) req.Header.Set("ACCESS-SIGN", sign) req.Header.Set("ACCESS-TIMESTAMP", ts) @@ -107,37 +170,23 @@ func (b *BitgetTrade) GetTradeFee(symbol, orderID string) (feeUSD float64, err e resp, err := b.client.Do(req) if err != nil { - return 0, fmt.Errorf("http: %w", err) + return 0, "" } defer resp.Body.Close() respBody, _ := io.ReadAll(resp.Body) var raw struct { Code string `json:"code"` - Msg string `json:"msg"` - Data struct { - FillList []json.RawMessage `json:"fillList"` + Data []struct { + Total string `json:"total"` } `json:"data"` } - if err := json.Unmarshal(respBody, &raw); err != nil { - return 0, fmt.Errorf("parse: %s", string(respBody)) + json.Unmarshal(respBody, &raw) + if raw.Code != "00000" || len(raw.Data) == 0 { + return 0, "" } - if raw.Code != "00000" { - return 0, fmt.Errorf("bitget error: %s - %s", raw.Code, raw.Msg) - } - - var totalFee float64 - for _, item := range raw.Data.FillList { - var fill struct { - FillFee string `json:"fillFee"` - } - if err := json.Unmarshal(item, &fill); err != nil { - continue - } - f, _ := strconv.ParseFloat(fill.FillFee, 64) - totalFee += math.Abs(f) - } - return totalFee, nil + total, _ := strconv.ParseFloat(raw.Data[0].Total, 64) + return total, raw.Data[0].Total } func (b *BitgetTrade) sign(method, requestPath, timestamp, body string) string { diff --git a/exchange/hyperliquid_trade.go b/exchange/hyperliquid_trade.go index 18d3990..9475cf8 100644 --- a/exchange/hyperliquid_trade.go +++ b/exchange/hyperliquid_trade.go @@ -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) { diff --git a/trader.go b/trader.go index 77e2630..8c6800c 100644 --- a/trader.go +++ b/trader.go @@ -366,11 +366,17 @@ func (t *Trader) ClosePosition(coin string) error { return fmt.Errorf("close failed: %s", closeErr) } - // Use entry prices as exit price estimate (manual close — no live price snapshot) - longAvg := weightedAvgPrice(pos.LongEntryPrices, t.cfg.TradeAmountUSD) - shortAvg := weightedAvgPrice(pos.ShortEntryPrices, t.cfg.TradeAmountUSD) + // Use actual exit fill prices from exchange (captured by closeLeg), fall back to entry prices + longExitPx := pos.LongLeg.ExitPrice + if longExitPx <= 0 { + longExitPx = weightedAvgPrice(pos.LongEntryPrices, t.cfg.TradeAmountUSD) + } + shortExitPx := pos.ShortLeg.ExitPrice + if shortExitPx <= 0 { + shortExitPx = weightedAvgPrice(pos.ShortEntryPrices, t.cfg.TradeAmountUSD) + } - t.finalizeClosedPosition(pos, longAvg, shortAvg, 0, 0, 0, 0, 0, longAvg, shortAvg, "手动", "手动平仓", elapsed) + t.finalizeClosedPosition(pos, longExitPx, shortExitPx, 0, 0, 0, 0, 0, longExitPx, shortExitPx, "手动", "手动平仓", elapsed) log.Printf("[Trader] Manually closed %s %s — persisted to DB", coin, pos.Direction) return nil } @@ -395,9 +401,15 @@ func (t *Trader) CloseAllPositions() int { log.Printf("[Trader] ❌ Force-close %s failed: %s", pos.Coin, closeErr) continue } - longAvg := weightedAvgPrice(pos.LongEntryPrices, t.cfg.TradeAmountUSD) - shortAvg := weightedAvgPrice(pos.ShortEntryPrices, t.cfg.TradeAmountUSD) - t.finalizeClosedPosition(pos, longAvg, shortAvg, 0, 0, 0, 0, 0, longAvg, shortAvg, "手动", "全部平仓", elapsed) + longExitPx := pos.LongLeg.ExitPrice + if longExitPx <= 0 { + longExitPx = weightedAvgPrice(pos.LongEntryPrices, t.cfg.TradeAmountUSD) + } + shortExitPx := pos.ShortLeg.ExitPrice + if shortExitPx <= 0 { + shortExitPx = weightedAvgPrice(pos.ShortEntryPrices, t.cfg.TradeAmountUSD) + } + t.finalizeClosedPosition(pos, longExitPx, shortExitPx, 0, 0, 0, 0, 0, longExitPx, shortExitPx, "手动", "全部平仓", elapsed) log.Printf("[Trader] Force-closed %s %s — persisted to DB", pos.Coin, pos.Direction) count++ } @@ -696,6 +708,47 @@ func (t *Trader) executeEntry(opp *ArbOpportunity, store *PriceStore, notifier * pos.DBTradeID = pendingTradeID log.Printf("[Trader] %s: DB status entering->open (id=%d)", pos.Coin, pendingTradeID) } + // Save entry prices, orders, and fees (not saved in TryEntry's pending record) + now := time.Now() + status := "filled" + tradeUnit := t.cfg.TradeAmountUSD + es := pos.EntrySpread + t.db.UpdateTradeEntry(pendingTradeID, &db.TradeRecord{ + LongEntry: &pos.LongLeg.EntryPrice, + ShortEntry: &pos.ShortLeg.EntryPrice, + LongExchange: pos.LongLeg.Exchange, + ShortExchange: pos.ShortLeg.Exchange, + EntrySpread: &es, + }) + if longFeeUSD <= 0 { + longFeeUSD = tradeUnit * takerFees[pos.LongLeg.Exchange] / 100 + } + if shortFeeUSD <= 0 { + shortFeeUSD = tradeUnit * takerFees[pos.ShortLeg.Exchange] / 100 + } + longShares := tradeUnit / pos.LongLeg.EntryPrice + shortShares := tradeUnit / pos.ShortLeg.EntryPrice + longOID, _ := t.db.SaveOrder(&db.OrderRecord{ + TradeID: pendingTradeID, Leg: "long", Type: "entry", + Exchange: pos.LongLeg.Exchange, Side: "buy", + Price: &pos.LongLeg.EntryPrice, Size: &longShares, + Fee: &longFeeUSD, Status: &status, CreatedAt: now, + OrderID: &pos.LongLeg.OrderID, + }) + shortOID, _ := t.db.SaveOrder(&db.OrderRecord{ + TradeID: pendingTradeID, Leg: "short", Type: "entry", + Exchange: pos.ShortLeg.Exchange, Side: "sell", + Price: &pos.ShortLeg.EntryPrice, Size: &shortShares, + Fee: &shortFeeUSD, Status: &status, CreatedAt: now, + OrderID: &pos.ShortLeg.OrderID, + }) + t.db.SaveSystemOrder(&db.SystemOrderRecord{ + TradeID: pendingTradeID, Type: "entry", Status: "filled", + Spread: &es, + LongPrice: &pos.LongLeg.EntryPrice, ShortPrice: &pos.ShortLeg.EntryPrice, + LongOrderID: &longOID, ShortOrderID: &shortOID, + CreatedAt: now, + }) } else { // Fallback: no pending record, insert fresh now := time.Now() @@ -733,12 +786,14 @@ func (t *Trader) executeEntry(opp *ArbOpportunity, store *PriceStore, notifier * Exchange: pos.LongLeg.Exchange, Side: "buy", Price: &pos.LongLeg.EntryPrice, Size: &longShares, Fee: &longFeeUSD, Status: &status, CreatedAt: now, + OrderID: &pos.LongLeg.OrderID, }) shortOID, _ := t.db.SaveOrder(&db.OrderRecord{ TradeID: tradeID, Leg: "short", Type: "entry", Exchange: pos.ShortLeg.Exchange, Side: "sell", Price: &pos.ShortLeg.EntryPrice, Size: &shortShares, Fee: &shortFeeUSD, Status: &status, CreatedAt: now, + OrderID: &pos.ShortLeg.OrderID, }) t.db.SaveSystemOrder(&db.SystemOrderRecord{ TradeID: tradeID, Type: "entry", Status: "filled", @@ -820,13 +875,15 @@ func (t *Trader) checkScaleIn(pos *ArbPosition, bgP, hlP, diffPct float64, store longPrice, shortPrice = hlP, bgP } - if err := t.placeOrderAt(pos.LongLeg, "buy", store, longPrice); err != "" { - log.Printf("[Trader] %s: Scale-in long failed: %s", pos.Coin, err) + longErr, longFeeActual := t.placeOrderAt(pos.LongLeg, "buy", store, longPrice) + if longErr != "" { + log.Printf("[Trader] %s: Scale-in long failed: %s", pos.Coin, longErr) return } time.Sleep(t.cfg.LegDelay) - if err := t.placeOrderAt(pos.ShortLeg, "sell", store, shortPrice); err != "" { - log.Printf("[Trader] %s: Scale-in short failed: %s — position partially scaled (long only)", pos.Coin, err) + shortErr, shortFeeActual := t.placeOrderAt(pos.ShortLeg, "sell", store, shortPrice) + if shortErr != "" { + log.Printf("[Trader] %s: Scale-in short failed: %s — position partially scaled (long only)", pos.Coin, shortErr) // Don't close the long leg — the scale-in long order was placed but the // short wasn't. The position has extra long exposure until the next Tick // decides what to do. This is a partial fill scenario. @@ -850,8 +907,14 @@ func (t *Trader) checkScaleIn(pos *ArbPosition, bgP, hlP, diffPct float64, store tradeUnit := t.cfg.TradeAmountUSD es := pos.EntrySpread - longFee := tradeUnit * takerFees[pos.LongLeg.Exchange] / 100 - shortFee := tradeUnit * takerFees[pos.ShortLeg.Exchange] / 100 + longFee := longFeeActual + if longFee <= 0 { + longFee = tradeUnit * takerFees[pos.LongLeg.Exchange] / 100 + } + shortFee := shortFeeActual + if shortFee <= 0 { + shortFee = tradeUnit * takerFees[pos.ShortLeg.Exchange] / 100 + } longShares := tradeUnit / longPrice shortShares := tradeUnit / shortPrice @@ -860,12 +923,14 @@ func (t *Trader) checkScaleIn(pos *ArbPosition, bgP, hlP, diffPct float64, store Exchange: pos.LongLeg.Exchange, Side: "buy", Price: &longPrice, Size: &longShares, Fee: &longFee, Status: &status, CreatedAt: now, + OrderID: &pos.LongLeg.OrderID, }) shortOID, _ := t.db.SaveOrder(&db.OrderRecord{ TradeID: pos.DBTradeID, Leg: "short", Type: "scale", Exchange: pos.ShortLeg.Exchange, Side: "sell", Price: &shortPrice, Size: &shortShares, Fee: &shortFee, Status: &status, CreatedAt: now, + OrderID: &pos.ShortLeg.OrderID, }) t.db.SaveSystemOrder(&db.SystemOrderRecord{ TradeID: pos.DBTradeID, Type: "scale", Status: "filled", @@ -1084,6 +1149,7 @@ func (t *Trader) checkExit(pos *ArbPosition, bgP, hlP, diffPct float64, notifier Exchange: pos.LongLeg.Exchange, Side: "sell", Price: &pos.LongLeg.ExitPrice, Size: &longExitShares, Fee: &longExitFeeAmt, Status: &status, CreatedAt: now, + OrderID: &pos.LongLeg.OrderID, }) shortExitShares := totalshortShares shortOID, _ := t.db.SaveOrder(&db.OrderRecord{ @@ -1091,6 +1157,7 @@ func (t *Trader) checkExit(pos *ArbPosition, bgP, hlP, diffPct float64, notifier Exchange: pos.ShortLeg.Exchange, Side: "buy", Price: &pos.ShortLeg.ExitPrice, Size: &shortExitShares, Fee: &shortExitFeeAmt, Status: &status, CreatedAt: now, + OrderID: &pos.ShortLeg.OrderID, }) // Save exit system order t.db.SaveSystemOrder(&db.SystemOrderRecord{ @@ -1177,13 +1244,23 @@ func (t *Trader) placeOrder(leg *PositionLeg, side string, store *PriceStore) (s leg.OrderID = oid log.Printf("[ExRes] BG %s %s: size=%s oid=%s", side, leg.Coin+"USDT", szStr, oid) - // Fetch actual fee from exchange - fee, fetchErr := t.bitget.GetTradeFee(leg.Coin+"USDT", oid) + // Fetch actual fee and fill price from exchange + fillPrice, fee, fetchErr := t.bitget.GetTradeFee(leg.Coin+"USDT", oid) if fetchErr != nil { log.Printf("[Fee] BG GetTradeFee warning: %v", fetchErr) fee = 0 } else { - log.Printf("[Fee] BG %s %s: actual fee=$%.6f (filled)", side, leg.Coin+"USDT", fee) + log.Printf("[Fee] BG %s %s: actual fee=$%.6f fillPrice=%.6f (filled)", side, leg.Coin+"USDT", fee, fillPrice) + if fillPrice > 0 { + leg.EntryPrice = fillPrice + } + } + + // Verify position actually exists on BG (IOC can succeed without fills) + time.Sleep(500 * time.Millisecond) + if posSize, _ := t.bitget.CheckPosition(leg.Coin + "USDT"); posSize <= 0 { + log.Printf("[Trader] BG %s %s: IOC order accepted but no position created (zero fill)", side, leg.Coin+"USDT") + return fmt.Sprintf("BG %s zero fill (no position)", side), 0 } return "", fee } else { @@ -1197,7 +1274,12 @@ func (t *Trader) placeOrder(leg *PositionLeg, side string, store *PriceStore) (s leg.OrderID = resp log.Printf("[ExRes] HL %s %s: size=%s", side, leg.Coin, szStr) - // Estimate fee from filled response (HL doesn't return fee in order response) + // Parse actual fill price and estimate fee from HL response + fillPrice, _, parseErr := t.hyperliquid.ParseFillFromResponse(resp) + if parseErr == nil && fillPrice > 0 { + leg.EntryPrice = fillPrice + log.Printf("[Fill] HL %s %s: actual fillPrice=%.6f", side, leg.Coin, fillPrice) + } fee, fetchErr := t.hyperliquid.EstimateFeeFromResponse(resp, takerFees[ExHyperLiquid]) if fetchErr != nil { log.Printf("[Fee] HL EstimateFeeFromResponse warning: %v", fetchErr) @@ -1306,12 +1388,14 @@ func (t *Trader) finalizeClosedPosition(pos *ArbPosition, longPrice, shortPrice, Exchange: pos.LongLeg.Exchange, Side: "sell", Price: &longPrice, Size: &totalLongShares, Fee: &longExitFeeAmt, Status: &status, CreatedAt: now, + OrderID: &pos.LongLeg.OrderID, }) shortOID, _ := t.db.SaveOrder(&db.OrderRecord{ TradeID: pos.DBTradeID, Leg: "short", Type: "exit", Exchange: pos.ShortLeg.Exchange, Side: "buy", Price: &shortPrice, Size: &totalShortShares, Fee: &shortExitFeeAmt, Status: &status, CreatedAt: now, + OrderID: &pos.ShortLeg.OrderID, }) t.db.SaveSystemOrder(&db.SystemOrderRecord{ TradeID: pos.DBTradeID, Type: "exit", Status: "filled", @@ -1398,29 +1482,61 @@ func (t *Trader) closeLeg(leg *PositionLeg) string { } if leg.Exchange == ExBitget { - log.Printf("[Order] BG close %s %s | size=%s", side, leg.Coin+"USDT", leg.Size) + // Query actual position from exchange before closing — handles partial fills + // during IOC entry and scale-ins where leg.Size is stale. + posSize, posSizeStr := t.bitget.CheckPosition(leg.Coin + "USDT") + if posSize <= 0 { + log.Printf("[Trader] BG %s %s: no position to close (already closed)", side, leg.Coin+"USDT") + leg.Closed = true + leg.ExitTime = time.Now() + return "" + } + // Bitget v2 hedge mode: side must match holdSide, not order direction + // close long → side=buy, holdSide=long + // close short → side=sell, holdSide=short holdSide := "long" if leg.Side == Short { holdSide = "short" } - resp, err := t.bitget.PlaceMarketOrder(side, leg.Coin+"USDT", leg.Size, "close", holdSide) + closeSide := "buy" + if leg.Side == Short { + closeSide = "sell" + } + log.Printf("[Order] BG close %s %s | leg.Size=%s actualSize=%s | holdSide=%s", closeSide, leg.Coin+"USDT", leg.Size, posSizeStr, holdSide) + resp, err := t.bitget.PlaceMarketOrder(closeSide, leg.Coin+"USDT", posSizeStr, "close", holdSide) if err != nil { // 22002 = no position on exchange (already closed manually or previously) if strings.Contains(err.Error(), "22002") { - log.Printf("[Trader] BG %s %s: already closed (22002)", side, leg.Coin+"USDT") + log.Printf("[Trader] BG %s %s: already closed (22002)", closeSide, leg.Coin+"USDT") } else { return fmt.Sprintf("%v", err) } } else { - log.Printf("[ExRes] BG close %s %s: size=%s → response=%s", side, leg.Coin+"USDT", leg.Size, resp) + log.Printf("[ExRes] BG close %s %s: size=%s → response=%s", closeSide, leg.Coin+"USDT", posSizeStr, resp) + leg.OrderID = resp + + // Fetch actual exit fill price + fillPrice, _, fetchErr := t.bitget.GetTradeFee(leg.Coin+"USDT", resp) + if fetchErr == nil && fillPrice > 0 { + leg.ExitPrice = fillPrice + log.Printf("[Fill] BG close %s: actual exitPrice=%.6f", leg.Coin+"USDT", fillPrice) + } } } else { log.Printf("[Order] HL close %s %s | size=%s", side, leg.Coin, leg.Size) - resp, err := t.hyperliquid.PlaceMarketOrder(leg.Coin, side, leg.Size) + resp, err := t.hyperliquid.PlaceMarketCloseOrder(leg.Coin, leg.Size) if err != nil { return fmt.Sprintf("%v", err) } log.Printf("[ExRes] HL close %s %s: size=%s → response=%s", side, leg.Coin, leg.Size, resp) + leg.OrderID = resp + + // Parse actual fill price from HL close response + fillPrice, _, parseErr := t.hyperliquid.ParseFillFromResponse(resp) + if parseErr == nil && fillPrice > 0 { + leg.ExitPrice = fillPrice + log.Printf("[Fill] HL close %s: actual exitPrice=%.6f", leg.Coin, fillPrice) + } } leg.Closed = true leg.ExitTime = time.Now() @@ -1516,6 +1632,7 @@ func (t *Trader) retryClose(pos *ArbPosition, bgP, hlP float64, notifier *Notifi Exchange: pos.LongLeg.Exchange, Side: "sell", Price: &pos.LongLeg.ExitPrice, Size: &longExitShares, Fee: &longExitFee, Status: &status, CreatedAt: now, + OrderID: &pos.LongLeg.OrderID, }) } if pos.ShortLeg.Closed { @@ -1526,6 +1643,7 @@ func (t *Trader) retryClose(pos *ArbPosition, bgP, hlP float64, notifier *Notifi Exchange: pos.ShortLeg.Exchange, Side: "buy", Price: &pos.ShortLeg.ExitPrice, Size: &shortExitShares, Fee: &shortExitFee, Status: &status, CreatedAt: now, + OrderID: &pos.ShortLeg.OrderID, }) } // Save exit system order (idempotent-safe since we always overwrite on retry) @@ -1537,7 +1655,13 @@ func (t *Trader) retryClose(pos *ArbPosition, bgP, hlP float64, notifier *Notifi }) // Close trade using previously saved exit metadata - feePct := pos.ExitTotalFees + numBatches := 1 + pos.ScaleLevels + longEntryFeeSum := float64(numBatches) * tradeUnit * takerFees[pos.LongLeg.Exchange] / 100 + shortEntryFeeSum := float64(numBatches) * tradeUnit * takerFees[pos.ShortLeg.Exchange] / 100 + longExitFeeAmt := totalLongShares * pos.LongLeg.ExitPrice * takerFees[pos.LongLeg.Exchange] / 100 + shortExitFeeAmt := totalshortShares * pos.ShortLeg.ExitPrice * takerFees[pos.ShortLeg.Exchange] / 100 + feeEntrySum := longEntryFeeSum + shortEntryFeeSum + feeExitSum := longExitFeeAmt + shortExitFeeAmt t.db.UpdateTradeStatus(pos.DBTradeID, &db.TradeRecord{ Status: "closed", ExitSpread: &pos.ExitDiffPct, @@ -1545,8 +1669,8 @@ func (t *Trader) retryClose(pos *ArbPosition, bgP, hlP float64, notifier *Notifi ShortExit: &pos.ShortLeg.ExitPrice, LongPnl: &pos.ExitLongPnl, ShortPnl: &pos.ExitShortPnl, - FeeEntry: &feePct, - FeeExit: &feePct, + FeeEntry: &feeEntrySum, + FeeExit: &feeExitSum, NetPnl: &pos.ExitNetPnl, AmountUSD: pos.AmountUSD, ScaleCount: pos.ScaleLevels, @@ -1583,31 +1707,70 @@ func (t *Trader) retryClose(pos *ArbPosition, bgP, hlP float64, notifier *Notifi // placeOrderAt places an order at a specified price (used for scale-in, Issue #2). // Unlike placeOrder, this doesn't modify the leg's EntryPrice — it places // an additional order at the current market price for the same trade amount. -func (t *Trader) placeOrderAt(leg *PositionLeg, side string, store *PriceStore, price float64) string { +// Returns (error string, actual fee in USD). +func (t *Trader) placeOrderAt(leg *PositionLeg, side string, store *PriceStore, price float64) (string, float64) { if t.cfg.TestMode { // Mock fill using specified price instead of leg's original entry origPrice := leg.EntryPrice leg.EntryPrice = price err := t.mockFill(leg, side, store) leg.EntryPrice = origPrice // restore original (entry tracking is per-position, not per-order) - return err + return err, 0 } if leg.Exchange == ExBitget { szStr := exchange.GetBitgetSize(leg.Coin+"USDT", t.cfg.TradeAmountUSD, price) log.Printf("[Order] BG scale %s %s | amountUSD=%.2f price=%.6f size=%s", side, leg.Coin+"USDT", t.cfg.TradeAmountUSD, price, szStr) - _, err := t.bitget.PlaceMarketOrder(side, leg.Coin+"USDT", szStr, "open", "") + oid, err := t.bitget.PlaceMarketOrder(side, leg.Coin+"USDT", szStr, "open", "") if err != nil { - return fmt.Sprintf("BG %s error: %v", side, err) + return fmt.Sprintf("BG %s error: %v", side, err), 0 } + leg.OrderID = oid + + // Fetch actual fee and fill price from exchange + fillPrice, fee, fetchErr := t.bitget.GetTradeFee(leg.Coin+"USDT", oid) + if fetchErr != nil { + log.Printf("[Fee] BG scale GetTradeFee warning: %v", fetchErr) + fee = 0 + } else { + log.Printf("[Fee] BG scale %s %s: actual fee=$%.6f fillPrice=%.6f", side, leg.Coin+"USDT", fee, fillPrice) + if fillPrice > 0 { + leg.EntryPrice = fillPrice + } + } + + // Verify position actually exists (IOC can succeed without fills) + time.Sleep(500 * time.Millisecond) + if posSize, _ := t.bitget.CheckPosition(leg.Coin + "USDT"); posSize <= 0 { + log.Printf("[Trader] BG scale %s %s: IOC accepted but no position created (zero fill)", side, leg.Coin+"USDT") + return fmt.Sprintf("BG scale %s zero fill (no position)", side), fee + } + return "", fee } else { szStr := t.hyperliquid.GetSize(leg.Coin, t.cfg.TradeAmountUSD, price) log.Printf("[Order] HL scale %s %s | amountUSD=%.2f price=%.6f size=%s", side, leg.Coin, t.cfg.TradeAmountUSD, price, szStr) - _, err := t.hyperliquid.PlaceMarketOrder(leg.Coin, side, szStr) + oid, err := t.hyperliquid.PlaceMarketOrder(leg.Coin, side, szStr) if err != nil { - return fmt.Sprintf("HL %s error: %v", side, err) + return fmt.Sprintf("HL %s error: %v", side, err), 0 } + leg.OrderID = oid + + // Parse actual fill price from HL response + fillPrice, _, parseErr := t.hyperliquid.ParseFillFromResponse(oid) + if parseErr == nil && fillPrice > 0 { + leg.EntryPrice = fillPrice + log.Printf("[Fill] HL scale %s %s: actual fillPrice=%.6f", side, leg.Coin, fillPrice) + } + + // Estimate fee from HL response + fee, fetchErr := t.hyperliquid.EstimateFeeFromResponse(oid, takerFees[ExHyperLiquid]) + if fetchErr != nil { + log.Printf("[Fee] HL scale EstimateFeeFromResponse warning: %v", fetchErr) + fee = 0 + } else { + log.Printf("[Fee] HL scale %s %s: fee=$%.6f", side, leg.Coin, fee) + } + return "", fee } - return "" } // mockFill simulates order execution. Uses stored bid/ask spread for realistic slippage. @@ -1802,6 +1965,7 @@ func (t *Trader) restoreOpenPositions() { pos.ShortLeg.EntryPrice = weightedAvgPrice(pos.ShortEntryPrices, t.cfg.TradeAmountUSD) } } + pos.DBTradeID = tr.ID t.positions[tr.Coin] = pos // Prevent immediate re-trading of the same coin t.lastTradeTime[tr.Coin] = tr.OpenedAt @@ -1836,6 +2000,25 @@ func (t *Trader) blacklistCoin(pos *ArbPosition, bgP, hlP, diffPct float64, noti pos.ShortLeg.ExitPrice = shortCurrent pos.ExitReasonText = "黑名单强平" + // Pre-compute per-exchange PnL/fees for retryClose + numBatchesBlack := 1 + pos.ScaleLevels + pos.ExitLongPnlUSD = longPnl / 100 * float64(numBatchesBlack) * t.cfg.TradeAmountUSD + pos.ExitShortPnlUSD = shortPnl / 100 * float64(numBatchesBlack) * t.cfg.TradeAmountUSD + { + totalLongSharesBlack := 0.0 + for _, p := range pos.LongEntryPrices { + totalLongSharesBlack += t.cfg.TradeAmountUSD / p + } + totalShortSharesBlack := 0.0 + for _, p := range pos.ShortEntryPrices { + totalShortSharesBlack += t.cfg.TradeAmountUSD / p + } + pos.ExitLongFeeUSD = float64(numBatchesBlack)*t.cfg.TradeAmountUSD*takerFees[pos.LongLeg.Exchange]/100 + + totalLongSharesBlack*longCurrent*takerFees[pos.LongLeg.Exchange]/100 + pos.ExitShortFeeUSD = float64(numBatchesBlack)*t.cfg.TradeAmountUSD*takerFees[pos.ShortLeg.Exchange]/100 + + totalShortSharesBlack*shortCurrent*takerFees[pos.ShortLeg.Exchange]/100 + } + // Convergence label convergedPct := (pos.EntrySpread - diffPct) / pos.EntrySpread * 100 if convergedPct < -10 {