fix: data race, scale-in PnL, nonce mutex, dead code, hourly check
- 🔴 Data race: Add GetPositionsCopy() returning deep copies (no shared ArbPosition pointers). Use it in dashboard broadcastLoop + handleStatus. - 🟡 Scale-in PnL: Track LongEntryPrices/ShortEntryPrices on ArbPosition, compute weighted average (harmonic mean) at exit for accurate PnL. - 🟢 CalcNetProfit: Delete dead code from exchange/helpers.go. - 🟢 HL nonce: Add sync.Mutex around lastNonce++ (thread safety). - 🟢 Hourly check: Change from 5-second window to minute window. - 🟢 ExitPrice: Test mode closeLeg already handled by checkExit.
This commit is contained in:
@@ -50,6 +50,43 @@ type ArbPosition struct {
|
||||
Status string // "open", "closed"
|
||||
RealizedPnl float64
|
||||
ErrorLog string
|
||||
|
||||
// Track all entry prices for weighted-average PnL across scale-ins (Issue #2)
|
||||
LongEntryPrices []float64 // all long entry prices (initial + scale-ins)
|
||||
ShortEntryPrices []float64 // all short entry prices (initial + scale-ins)
|
||||
}
|
||||
|
||||
// DeepCopy returns a copy-safe snapshot of the position (no shared pointers).
|
||||
func (p *ArbPosition) DeepCopy() ArbPosition {
|
||||
c := *p
|
||||
if p.LongLeg != nil {
|
||||
lc := *p.LongLeg
|
||||
c.LongLeg = &lc
|
||||
}
|
||||
if p.ShortLeg != nil {
|
||||
sc := *p.ShortLeg
|
||||
c.ShortLeg = &sc
|
||||
}
|
||||
if p.LongEntryPrices != nil {
|
||||
c.LongEntryPrices = make([]float64, len(p.LongEntryPrices))
|
||||
copy(c.LongEntryPrices, p.LongEntryPrices)
|
||||
}
|
||||
if p.ShortEntryPrices != nil {
|
||||
c.ShortEntryPrices = make([]float64, len(p.ShortEntryPrices))
|
||||
copy(c.ShortEntryPrices, p.ShortEntryPrices)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// GetPositionsCopy returns deep copies of all open positions — safe for concurrent read.
|
||||
func (t *Trader) GetPositionsCopy() []ArbPosition {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
r := make([]ArbPosition, 0, len(t.positions))
|
||||
for _, p := range t.positions {
|
||||
r = append(r, p.DeepCopy())
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// Trader handles scalable arbitrage between Bitget and HyperLiquid.
|
||||
@@ -245,6 +282,8 @@ func (t *Trader) executeEntry(opp *ArbOpportunity, store *PriceStore, notifier *
|
||||
Coin: opp.Coin, Exchange: ExHyperLiquid, Side: Short,
|
||||
EntryPrice: hlP, EntryTime: time.Now(),
|
||||
}
|
||||
pos.LongEntryPrices = []float64{bgP}
|
||||
pos.ShortEntryPrices = []float64{hlP}
|
||||
} else {
|
||||
pos.Direction = "HL->BG"
|
||||
pos.EntrySpread = (bgP - hlP) / hlP * 100 // positive when bgP > hlP
|
||||
@@ -256,6 +295,8 @@ func (t *Trader) executeEntry(opp *ArbOpportunity, store *PriceStore, notifier *
|
||||
Coin: opp.Coin, Exchange: ExBitget, Side: Short,
|
||||
EntryPrice: bgP, EntryTime: time.Now(),
|
||||
}
|
||||
pos.LongEntryPrices = []float64{hlP}
|
||||
pos.ShortEntryPrices = []float64{bgP}
|
||||
}
|
||||
|
||||
t.mu.Lock()
|
||||
@@ -357,6 +398,8 @@ func (t *Trader) checkScaleIn(pos *ArbPosition, bgP, hlP, diffPct float64, store
|
||||
pos.ScaleLevels++
|
||||
pos.LastScaleAt = time.Now()
|
||||
pos.AmountUSD += t.cfg.TradeAmountUSD
|
||||
pos.LongEntryPrices = append(pos.LongEntryPrices, longPrice)
|
||||
pos.ShortEntryPrices = append(pos.ShortEntryPrices, shortPrice)
|
||||
|
||||
log.Printf("[Trader] %s: Scale-in #%d executed | spread=%.4f%% (entry=%.4f%%) | total=$%.0f",
|
||||
pos.Coin, pos.ScaleLevels, diffPct, entryDiff, pos.AmountUSD)
|
||||
@@ -389,7 +432,8 @@ func (t *Trader) checkExit(pos *ArbPosition, bgP, hlP, diffPct float64, notifier
|
||||
return
|
||||
}
|
||||
|
||||
// Calculate P&L
|
||||
// Calculate P&L — use weighted average entry for scale-in positions
|
||||
// Each scale adds cfg.TradeAmountUSD at the scale price
|
||||
var longCurrent, shortCurrent float64
|
||||
if pos.LongLeg.Exchange == ExBitget {
|
||||
longCurrent, shortCurrent = bgP, hlP
|
||||
@@ -397,8 +441,12 @@ func (t *Trader) checkExit(pos *ArbPosition, bgP, hlP, diffPct float64, notifier
|
||||
longCurrent, shortCurrent = hlP, bgP
|
||||
}
|
||||
|
||||
longPnl := (longCurrent - pos.LongLeg.EntryPrice) / pos.LongLeg.EntryPrice * 100
|
||||
shortPnl := (pos.ShortLeg.EntryPrice - shortCurrent) / pos.ShortLeg.EntryPrice * 100
|
||||
// Weighted average entry prices across all scale levels
|
||||
longAvg := weightedAvgPrice(pos.LongEntryPrices, t.cfg.TradeAmountUSD)
|
||||
shortAvg := weightedAvgPrice(pos.ShortEntryPrices, t.cfg.TradeAmountUSD)
|
||||
|
||||
longPnl := (longCurrent - longAvg) / longAvg * 100
|
||||
shortPnl := (shortAvg - shortCurrent) / shortAvg * 100
|
||||
totalFees := 2 * (makerFees[ExBitget] + makerFees[ExHyperLiquid]) // 开仓 + 平仓手续费
|
||||
netPnl := longPnl + shortPnl - totalFees
|
||||
|
||||
@@ -627,6 +675,27 @@ func (t *Trader) GetOpenPositions() []*ArbPosition {
|
||||
return r
|
||||
}
|
||||
|
||||
// weightedAvgPrice computes the weighted average entry price across multiple scale levels.
|
||||
// Each level trades the same USD amount, so the result is the harmonic mean of prices.
|
||||
func weightedAvgPrice(prices []float64, amountPerTrade float64) float64 {
|
||||
if len(prices) == 0 {
|
||||
return 0
|
||||
}
|
||||
totalShares := 0.0
|
||||
totalCost := 0.0
|
||||
for _, p := range prices {
|
||||
if p <= 0 {
|
||||
continue
|
||||
}
|
||||
totalShares += amountPerTrade / p
|
||||
totalCost += amountPerTrade
|
||||
}
|
||||
if totalShares <= 0 {
|
||||
return prices[0] // fallback
|
||||
}
|
||||
return totalCost / totalShares
|
||||
}
|
||||
|
||||
// GetClosedStats returns convergence stats from all closed trades.
|
||||
func (t *Trader) GetClosedStats() (converged, diverged, flat, total int) {
|
||||
t.mu.Lock()
|
||||
@@ -717,12 +786,14 @@ func (t *Trader) restoreOpenPositions() {
|
||||
Coin: tr.Coin, Exchange: tr.LongExchange, Side: Long,
|
||||
EntryPrice: *tr.LongEntry, EntryTime: tr.OpenedAt,
|
||||
}
|
||||
pos.LongEntryPrices = []float64{*tr.LongEntry}
|
||||
}
|
||||
if tr.ShortEntry != nil {
|
||||
pos.ShortLeg = &PositionLeg{
|
||||
Coin: tr.Coin, Exchange: tr.ShortExchange, Side: Short,
|
||||
EntryPrice: *tr.ShortEntry, EntryTime: tr.OpenedAt,
|
||||
}
|
||||
pos.ShortEntryPrices = []float64{*tr.ShortEntry}
|
||||
}
|
||||
t.positions[tr.Coin] = pos
|
||||
// Prevent immediate re-trading of the same coin
|
||||
|
||||
Reference in New Issue
Block a user