diff --git a/exchange/bitget_trade.go b/exchange/bitget_trade.go index 4e57939..5fd3349 100644 --- a/exchange/bitget_trade.go +++ b/exchange/bitget_trade.go @@ -87,6 +87,57 @@ func (b *BitgetTrade) PlaceMarketOrder(side, symbol, size, tradeSide string) (st return result.Data.OrderID, nil } +// GetTradeFee queries the fills endpoint for actual fee charged. +func (b *BitgetTrade) GetTradeFee(symbol, orderID string) (feeUSD float64, err error) { + ts := fmt.Sprintf("%d", time.Now().UnixMilli()) + method := "GET" + requestPath := "/api/v2/mix/order/fills?symbol=" + symbol + "&orderId=" + orderID + 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) + if b.paperMode { + req.Header.Set("paptrading", "1") + } + + 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 struct { + Code string `json:"code"` + Msg string `json:"msg"` + Data []json.RawMessage `json:"data"` + } + if err := json.Unmarshal(respBody, &raw); err != nil { + return 0, fmt.Errorf("parse: %s", string(respBody)) + } + if raw.Code != "00000" { + return 0, fmt.Errorf("bitget error: %s - %s", raw.Code, raw.Msg) + } + + var totalFee float64 + for _, item := range raw.Data { + 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 +} + func (b *BitgetTrade) sign(method, requestPath, timestamp, body string) string { raw := timestamp + method + requestPath + body mac := hmac.New(sha256.New, []byte(b.APISecret)) @@ -128,7 +179,6 @@ func (b *BitgetTrade) GetBalance() (float64, error) { return 0, fmt.Errorf("bitget error: %s - %s", code, msg) } - // Parse data as array of accounts dataRaw, ok := raw["data"] if !ok || dataRaw == nil { return 0, fmt.Errorf("no data in response") diff --git a/exchange/hyperliquid_trade.go b/exchange/hyperliquid_trade.go index f0e4253..1ba9f30 100644 --- a/exchange/hyperliquid_trade.go +++ b/exchange/hyperliquid_trade.go @@ -83,6 +83,7 @@ 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") @@ -120,6 +121,33 @@ func (h *HyperLiquidTrade) PlaceMarketOrder(coin, side, sz string) (string, erro return string(respJSON), nil } +// GetTradeFee parses the MarketOpen JSON response to extract filled size and +// estimates the actual fee from the exchange taker rate. +func (h *HyperLiquidTrade) GetTradeFee(orderResponseJSON string, takerFeePct float64) (feeUSD float64, err error) { + var resp struct { + Statuses []struct { + Filled *struct { + TotalSz string `json:"totalSz"` + AvgPx string `json:"avgPx"` + } `json:"filled,omitempty"` + Error *string `json:"error,omitempty"` + } `json:"statuses"` + } + if err := json.Unmarshal([]byte(orderResponseJSON), &resp); err != nil { + return 0, fmt.Errorf("parse order response: %w", err) + } + for _, st := range resp.Statuses { + if st.Filled != nil { + sz, _ := strconv.ParseFloat(st.Filled.TotalSz, 64) + px, _ := strconv.ParseFloat(st.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") diff --git a/trader.go b/trader.go index 95827ef..b810964 100644 --- a/trader.go +++ b/trader.go @@ -554,14 +554,18 @@ func (t *Trader) executeEntry(opp *ArbOpportunity, store *PriceStore, notifier * t.mu.Unlock() // Execute both legs - if err := t.placeOrder(pos.LongLeg, "buy", store); err != "" { - log.Printf("[Trader] %s: long leg placeOrder failed: %s", opp.Coin, err) + var longFeeUSD, shortFeeUSD float64 + var errMsg string + errMsg, longFeeUSD = t.placeOrder(pos.LongLeg, "buy", store) + if errMsg != "" { + log.Printf("[Trader] %s: long leg placeOrder failed: %s", opp.Coin, errMsg) t.cleanup(pos.Coin) return false } time.Sleep(t.cfg.LegDelay) - if err := t.placeOrder(pos.ShortLeg, "sell", store); err != "" { - log.Printf("[Trader] %s: short leg placeOrder failed: %s", opp.Coin, err) + errMsg, shortFeeUSD = t.placeOrder(pos.ShortLeg, "sell", store) + if errMsg != "" { + log.Printf("[Trader] %s: short leg placeOrder failed: %s", opp.Coin, errMsg) // Leg1 placed successfully, leg2 failed — try to close leg1 pos.Status = "failed" if closeErr := t.closeLeg(pos.LongLeg); closeErr != "" { @@ -570,7 +574,7 @@ func (t *Trader) executeEntry(opp *ArbOpportunity, store *PriceStore, notifier * pos.ErrorLog = fmt.Sprintf("ORPHAN: leg1 %s %s placed OK, leg2 %s %s failed (%s), leg1 close also failed (%s)", pos.LongLeg.Exchange, pos.LongLeg.Side, pos.ShortLeg.Exchange, pos.ShortLeg.Side, - err, closeErr) + errMsg, closeErr) log.Printf("[Trader] ⚠️ ORPHAN POSITION on %s: %s", pos.Coin, pos.ErrorLog) } t.cleanup(pos.Coin) @@ -602,8 +606,13 @@ func (t *Trader) executeEntry(opp *ArbOpportunity, store *PriceStore, notifier * if tradeID, err := t.db.SaveTrade(dbTrade); err == nil { pos.DBTradeID = tradeID - longFee := tradeUnit * takerFees[pos.LongLeg.Exchange] / 100 - shortFee := tradeUnit * takerFees[pos.ShortLeg.Exchange] / 100 + // Use actual fee from exchange (fetched in placeOrder), fall back to estimate + 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 @@ -611,13 +620,13 @@ func (t *Trader) executeEntry(opp *ArbOpportunity, store *PriceStore, notifier * TradeID: tradeID, Leg: "long", Type: "entry", Exchange: pos.LongLeg.Exchange, Side: "buy", Price: &pos.LongLeg.EntryPrice, Size: &longShares, - Fee: &longFee, Status: &status, CreatedAt: now, + Fee: &longFeeUSD, Status: &status, CreatedAt: now, }) 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: &shortFee, Status: &status, CreatedAt: now, + Fee: &shortFeeUSD, Status: &status, CreatedAt: now, }) t.db.SaveSystemOrder(&db.SystemOrderRecord{ TradeID: tradeID, Type: "entry", Status: "filled", @@ -838,14 +847,14 @@ func (t *Trader) checkExit(pos *ArbPosition, bgP, hlP, diffPct float64, notifier for _, p := range pos.LongEntryPrices { totalLongSharesRetry += t.cfg.TradeAmountUSD / p } - totalShortSharesRetry := 0.0 + totalshortSharesRetry := 0.0 for _, p := range pos.ShortEntryPrices { - totalShortSharesRetry += t.cfg.TradeAmountUSD / p + totalshortSharesRetry += t.cfg.TradeAmountUSD / p } pos.ExitLongFeeUSD = float64(numBatchesRetry)*t.cfg.TradeAmountUSD*takerFees[pos.LongLeg.Exchange]/100 + totalLongSharesRetry*longCurrent*takerFees[pos.LongLeg.Exchange]/100 pos.ExitShortFeeUSD = float64(numBatchesRetry)*t.cfg.TradeAmountUSD*takerFees[pos.ShortLeg.Exchange]/100 + - totalShortSharesRetry*shortCurrent*takerFees[pos.ShortLeg.Exchange]/100 + totalshortSharesRetry*shortCurrent*takerFees[pos.ShortLeg.Exchange]/100 } closeErr := t.closeBothLegs(pos) @@ -878,15 +887,15 @@ func (t *Trader) checkExit(pos *ArbPosition, bgP, hlP, diffPct float64, notifier for _, p := range pos.LongEntryPrices { totalLongShares += legCapital / p } - totalShortShares := 0.0 + totalshortShares := 0.0 for _, p := range pos.ShortEntryPrices { - totalShortShares += legCapital / p + totalshortShares += legCapital / p } longEntryFeeSum := float64(numBatches) * legCapital * takerFees[pos.LongLeg.Exchange] / 100 shortEntryFeeSum := float64(numBatches) * legCapital * 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 + shortExitFeeAmt := totalshortShares * pos.ShortLeg.ExitPrice * takerFees[pos.ShortLeg.Exchange] / 100 longFeeUSD := longEntryFeeSum + longExitFeeAmt shortFeeUSD := shortEntryFeeSum + shortExitFeeAmt @@ -957,7 +966,7 @@ func (t *Trader) checkExit(pos *ArbPosition, bgP, hlP, diffPct float64, notifier Price: &pos.LongLeg.ExitPrice, Size: &longExitShares, Fee: &longExitFeeAmt, Status: &status, CreatedAt: now, }) - shortExitShares := totalShortShares + shortExitShares := totalshortShares shortOID, _ := t.db.SaveOrder(&db.OrderRecord{ TradeID: pos.DBTradeID, Leg: "short", Type: "exit", Exchange: pos.ShortLeg.Exchange, Side: "buy", @@ -1034,30 +1043,47 @@ func (t *Trader) checkExit(pos *ArbPosition, bgP, hlP, diffPct float64, notifier } } -func (t *Trader) placeOrder(leg *PositionLeg, side string, store *PriceStore) string { +func (t *Trader) placeOrder(leg *PositionLeg, side string, store *PriceStore) (string, float64) { if t.cfg.TestMode { - return t.mockFill(leg, side, store) + return t.mockFill(leg, side, store), 0 } if leg.Exchange == ExBitget { size := exchange.GetBitgetSize(leg.Coin+"USDT", t.cfg.TradeAmountUSD, leg.EntryPrice) oid, err := t.bitget.PlaceMarketOrder(side, leg.Coin+"USDT", size, "open") if err != nil { - return fmt.Sprintf("BG %s error: %v", side, err) + return fmt.Sprintf("BG %s error: %v", side, err), 0 } leg.Size = size leg.OrderID = oid - log.Printf("[ExRes] BG %s %s: size=%s → response=%s", side, leg.Coin+"USDT", size, oid) + log.Printf("[ExRes] BG %s %s: size=%s oid=%s", side, leg.Coin+"USDT", size, oid) + + // Fetch actual fee from exchange + fee, fetchErr := t.bitget.GetTradeFee(leg.Coin+"USDT", oid) + if fetchErr != nil { + log.Printf("[Fee] BG GetTradeFee warning: %v", fetchErr) + } else { + log.Printf("[Fee] BG %s %s: actual fee=$%.6f", side, leg.Coin+"USDT", fee) + } + return "", fee } else { size := exchange.GetHLSize(leg.Coin, t.cfg.TradeAmountUSD, leg.EntryPrice) resp, err := t.hyperliquid.PlaceMarketOrder(leg.Coin, side, size) if err != nil { - return fmt.Sprintf("HL %s error: %v", side, err) + return fmt.Sprintf("HL %s error: %v", side, err), 0 } leg.Size = size leg.OrderID = resp - log.Printf("[ExRes] HL %s %s: size=%s → response=%s", side, leg.Coin, size, resp) + log.Printf("[ExRes] HL %s %s: size=%s", side, leg.Coin, size) + + // Estimate fee from filled response (HL doesn't return fee in order response) + fee, fetchErr := t.hyperliquid.GetTradeFee(resp, takerFees[ExHyperLiquid]) + if fetchErr != nil { + log.Printf("[Fee] HL GetTradeFee warning: %v", fetchErr) + } else { + log.Printf("[Fee] HL %s %s: actual fee=$%.6f", side, leg.Coin, fee) + } + return "", fee } - return "" } func (t *Trader) closeBothLegs(pos *ArbPosition) string { @@ -1169,9 +1195,9 @@ func (t *Trader) retryClose(pos *ArbPosition, bgP, hlP float64, notifier *Notifi for _, p := range pos.LongEntryPrices { totalLongShares += tradeUnit / p } - totalShortShares := 0.0 + totalshortShares := 0.0 for _, p := range pos.ShortEntryPrices { - totalShortShares += tradeUnit / p + totalshortShares += tradeUnit / p } // Save exit orders for legs that were just now closed @@ -1186,8 +1212,8 @@ func (t *Trader) retryClose(pos *ArbPosition, bgP, hlP float64, notifier *Notifi }) } if pos.ShortLeg.Closed { - shortExitFee := totalShortShares * pos.ShortLeg.ExitPrice * takerFees[pos.ShortLeg.Exchange] / 100 - shortExitShares := totalShortShares + shortExitFee := totalshortShares * pos.ShortLeg.ExitPrice * takerFees[pos.ShortLeg.Exchange] / 100 + shortExitShares := totalshortShares _, _ = t.db.SaveOrder(&db.OrderRecord{ TradeID: pos.DBTradeID, Leg: "short", Type: "exit", Exchange: pos.ShortLeg.Exchange, Side: "buy",