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
+215 -32
View File
@@ -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 {