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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user