fix: 手动平仓持久化PnL到DB

ClosePosition/CloseAllPositions 之前只调 closeBothLegs 关交易所仓位
后直接 delete(t.positions),没有写DB/记PnL/入 closedTrades,
重启后交易数据丢失。

- 新增 finalizeClosedPosition() 共享方法:
  统一处理 PnL计算、exchangeFunds更新、DB持久化(exit orders +
  UpdateTradeStatus)、closedTrades追加、SSE推事件
- ClosePosition/CloseAllPositions 改用该方法
- 手动平仓用入场均价的出场价格,PnL=0,
  原因标记为'手动平仓'/'全部平仓'
- 同时增加 closeBothLegs 失败的处理(之前直接忽略)
This commit is contained in:
jackyu66git
2026-05-04 22:32:42 +08:00
parent 9163d7fd30
commit 292669fb0d
+189 -14
View File
@@ -348,13 +348,23 @@ func (t *Trader) ClosePosition(coin string) error {
if pos.Status != "open" && pos.Status != "close_failed" {
return fmt.Errorf("position %s is in status %s, cannot close", coin, pos.Status)
}
t.closeBothLegs(pos)
pos.Status = "closed"
pos.ExitedAt = time.Now()
t.mu.Lock()
delete(t.positions, coin)
t.mu.Unlock()
log.Printf("[Trader] Manually closed %s %s", coin, pos.Direction)
elapsed := time.Since(pos.StartedAt)
closeErr := t.closeBothLegs(pos)
if closeErr != "" {
pos.Status = "close_failed"
pos.ExitedAt = time.Now()
pos.ErrorLog = closeErr
log.Printf("[Trader] ❌ Manual close %s failed: %s", coin, closeErr)
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)
t.finalizeClosedPosition(pos, longAvg, shortAvg, 0, 0, 0, 0, 0, longAvg, shortAvg, "手动", "手动平仓", elapsed)
log.Printf("[Trader] Manually closed %s %s — persisted to DB", coin, pos.Direction)
return nil
}
@@ -369,13 +379,19 @@ func (t *Trader) CloseAllPositions() int {
count := 0
for _, pos := range positions {
if pos.Status == "open" || pos.Status == "close_failed" {
t.closeBothLegs(pos)
pos.Status = "closed"
pos.ExitedAt = time.Now()
t.mu.Lock()
delete(t.positions, pos.Coin)
t.mu.Unlock()
log.Printf("[Trader] Force-closed %s %s", pos.Coin, pos.Direction)
elapsed := time.Since(pos.StartedAt)
closeErr := t.closeBothLegs(pos)
if closeErr != "" {
pos.Status = "close_failed"
pos.ExitedAt = time.Now()
pos.ErrorLog = closeErr
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)
log.Printf("[Trader] Force-closed %s %s — persisted to DB", pos.Coin, pos.Direction)
count++
}
}
@@ -1163,6 +1179,165 @@ func (t *Trader) placeOrder(leg *PositionLeg, side string, store *PriceStore) (s
}
}
// finalizeClosedPosition persists a closed position: computes PnL (if not given),
// records to closedTrades, updates exchangeFunds, persists exit orders + trade to DB.
// longPct/shortPct/totalFeesPct are % values; pass the price snapshot used at close trigger.
// longPrice/shortPrice are the exit prices for each leg. Use entry prices if unknown.
func (t *Trader) finalizeClosedPosition(pos *ArbPosition, longPrice, shortPrice, diffPct, netPnlPct, longPnlPct, shortPnlPct, totalFeesPct, longCurrent, shortCurrent float64, convergence, exitReason string, elapsed time.Duration) {
pos.ExitedAt = time.Now()
pos.Status = "closed"
pos.RealizedPnl = netPnlPct
// Per-leg PnL and fees in USD
numBatches := 1 + pos.ScaleLevels
legCapital := t.cfg.TradeAmountUSD
longPnlUSD := longPnlPct / 100 * float64(numBatches) * legCapital
shortPnlUSD := shortPnlPct / 100 * float64(numBatches) * legCapital
totalLongShares := 0.0
for _, p := range pos.LongEntryPrices {
totalLongShares += legCapital / p
}
totalShortShares := 0.0
for _, p := range pos.ShortEntryPrices {
totalShortShares += legCapital / p
}
longEntryFeeSum := float64(numBatches) * legCapital * takerFees[pos.LongLeg.Exchange] / 100
shortEntryFeeSum := float64(numBatches) * legCapital * takerFees[pos.ShortLeg.Exchange] / 100
longExitFeeAmt := totalLongShares * longPrice * takerFees[pos.LongLeg.Exchange] / 100
shortExitFeeAmt := totalShortShares * shortPrice * takerFees[pos.ShortLeg.Exchange] / 100
longFeeUSD := longEntryFeeSum + longExitFeeAmt
shortFeeUSD := shortEntryFeeSum + shortExitFeeAmt
pos.LongLeg.ExitPrice = longPrice
pos.ShortLeg.ExitPrice = shortPrice
// Update exchange funds
t.mu.Lock()
if ef, ok := t.exchangeFunds[pos.LongLeg.Exchange]; ok {
ef.Balance -= longFeeUSD
ef.Balance += longPnlUSD
ef.TotalFee += longFeeUSD
ef.TotalPnl += longPnlUSD
}
if ef, ok := t.exchangeFunds[pos.ShortLeg.Exchange]; ok {
ef.Balance -= shortFeeUSD
ef.Balance += shortPnlUSD
ef.TotalFee += shortFeeUSD
ef.TotalPnl += shortPnlUSD
}
t.mu.Unlock()
// Build trade record
record := TradeRecord{
Coin: pos.Coin,
Direction: pos.Direction,
EntrySpread: pos.EntrySpread,
ExitSpread: diffPct,
PnlPct: netPnlPct,
PnlUSD: 2 * pos.AmountUSD * netPnlPct / 100,
Convergence: convergence,
Reason: exitReason,
Duration: elapsed.Round(time.Second).String(),
OpenedAt: pos.StartedAt,
ClosedAt: pos.ExitedAt,
ScaleLevels: pos.ScaleLevels,
AmountUSD: pos.AmountUSD,
PnlLongUSD: longPnlUSD,
PnlShortUSD: shortPnlUSD,
FeeLongUSD: longFeeUSD,
FeeShortUSD: shortFeeUSD,
}
t.mu.Lock()
delete(t.positions, pos.Coin)
t.lastTradeTime[pos.Coin] = time.Now()
t.closedTrades = append(t.closedTrades, record)
t.realTradesDone++
t.mu.Unlock()
// Auto-stop after target real trades
if t.realTradesTarget > 0 && t.realTradesDone >= t.realTradesTarget {
log.Printf("[Trader] ✅ %d real trades completed — shutting down...", t.realTradesDone)
t.shuttingDown = true
select {
case t.StopCh <- struct{}{}:
default:
}
}
// Persist to DB
if t.db != nil && pos.DBTradeID > 0 {
now := time.Now()
status := "filled"
longOID, _ := t.db.SaveOrder(&db.OrderRecord{
TradeID: pos.DBTradeID, Leg: "long", Type: "exit",
Exchange: pos.LongLeg.Exchange, Side: "sell",
Price: &longPrice, Size: &totalLongShares,
Fee: &longExitFeeAmt, Status: &status, CreatedAt: now,
})
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,
})
t.db.SaveSystemOrder(&db.SystemOrderRecord{
TradeID: pos.DBTradeID, Type: "exit", Status: "filled",
Spread: &diffPct,
LongPrice: &longPrice, ShortPrice: &shortPrice,
LongOrderID: &longOID, ShortOrderID: &shortOID,
CreatedAt: now,
})
feeEntrySum := longEntryFeeSum + shortEntryFeeSum
feeExitSum := longExitFeeAmt + shortExitFeeAmt
t.db.UpdateTradeStatus(pos.DBTradeID, &db.TradeRecord{
Status: "closed",
ExitSpread: &diffPct,
LongExit: &longPrice,
ShortExit: &shortPrice,
LongPnl: &longPnlPct,
ShortPnl: &shortPnlPct,
FeeEntry: &feeEntrySum,
FeeExit: &feeExitSum,
NetPnl: &netPnlPct,
AmountUSD: pos.AmountUSD,
ScaleCount: pos.ScaleLevels,
ExitReason: &exitReason,
Convergence: &convergence,
ClosedAt: &now,
PnlLongUSD: &longPnlUSD,
PnlShortUSD: &shortPnlUSD,
FeeLongUSD: &longFeeUSD,
FeeShortUSD: &shortFeeUSD,
})
}
// SSE trade event
if t.OnTradeEvent != nil {
t.OnTradeEvent("trade_close", map[string]interface{}{
"coin": pos.Coin,
"direction": pos.Direction,
"entry_spread": pos.EntrySpread,
"exit_spread": diffPct,
"pnl_pct": netPnlPct,
"pnl_usd": record.PnlUSD,
"convergence": convergence,
"reason": exitReason,
"duration": record.Duration,
"scale_levels": pos.ScaleLevels,
"amount_usd": pos.AmountUSD,
"long_pnl_usd": record.PnlLongUSD,
"short_pnl_usd": record.PnlShortUSD,
"long_fee_usd": record.FeeLongUSD,
"short_fee_usd": record.FeeShortUSD,
})
}
}
func (t *Trader) closeBothLegs(pos *ArbPosition) string {
errs := ""
if !pos.LongLeg.Closed {