diff --git a/trader.go b/trader.go index 3202d4a..86c8913 100644 --- a/trader.go +++ b/trader.go @@ -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 {