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" {
|
if pos.Status != "open" && pos.Status != "close_failed" {
|
||||||
return fmt.Errorf("position %s is in status %s, cannot close", coin, pos.Status)
|
return fmt.Errorf("position %s is in status %s, cannot close", coin, pos.Status)
|
||||||
}
|
}
|
||||||
t.closeBothLegs(pos)
|
|
||||||
pos.Status = "closed"
|
elapsed := time.Since(pos.StartedAt)
|
||||||
|
closeErr := t.closeBothLegs(pos)
|
||||||
|
if closeErr != "" {
|
||||||
|
pos.Status = "close_failed"
|
||||||
pos.ExitedAt = time.Now()
|
pos.ExitedAt = time.Now()
|
||||||
t.mu.Lock()
|
pos.ErrorLog = closeErr
|
||||||
delete(t.positions, coin)
|
log.Printf("[Trader] ❌ Manual close %s failed: %s", coin, closeErr)
|
||||||
t.mu.Unlock()
|
return fmt.Errorf("close failed: %s", closeErr)
|
||||||
log.Printf("[Trader] Manually closed %s %s", coin, pos.Direction)
|
}
|
||||||
|
|
||||||
|
// 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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -369,13 +379,19 @@ func (t *Trader) CloseAllPositions() int {
|
|||||||
count := 0
|
count := 0
|
||||||
for _, pos := range positions {
|
for _, pos := range positions {
|
||||||
if pos.Status == "open" || pos.Status == "close_failed" {
|
if pos.Status == "open" || pos.Status == "close_failed" {
|
||||||
t.closeBothLegs(pos)
|
elapsed := time.Since(pos.StartedAt)
|
||||||
pos.Status = "closed"
|
closeErr := t.closeBothLegs(pos)
|
||||||
|
if closeErr != "" {
|
||||||
|
pos.Status = "close_failed"
|
||||||
pos.ExitedAt = time.Now()
|
pos.ExitedAt = time.Now()
|
||||||
t.mu.Lock()
|
pos.ErrorLog = closeErr
|
||||||
delete(t.positions, pos.Coin)
|
log.Printf("[Trader] ❌ Force-close %s failed: %s", pos.Coin, closeErr)
|
||||||
t.mu.Unlock()
|
continue
|
||||||
log.Printf("[Trader] Force-closed %s %s", pos.Coin, pos.Direction)
|
}
|
||||||
|
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++
|
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 {
|
func (t *Trader) closeBothLegs(pos *ArbPosition) string {
|
||||||
errs := ""
|
errs := ""
|
||||||
if !pos.LongLeg.Closed {
|
if !pos.LongLeg.Closed {
|
||||||
|
|||||||
Reference in New Issue
Block a user