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:
+8
-6
@@ -290,8 +290,8 @@ func (d *Dashboard) broadcastLoop() {
|
|||||||
}
|
}
|
||||||
d.hub.Broadcast("prices", prices)
|
d.hub.Broadcast("prices", prices)
|
||||||
|
|
||||||
// 2. Open positions with live PnL (P3-3)
|
// 2. Open positions with live PnL (P3-3) — use safe copy for concurrent read
|
||||||
positions := d.trader.GetOpenPositions()
|
positions := d.trader.GetPositionsCopy()
|
||||||
posList := make([]map[string]interface{}, 0, len(positions))
|
posList := make([]map[string]interface{}, 0, len(positions))
|
||||||
for _, pos := range positions {
|
for _, pos := range positions {
|
||||||
posEntry := map[string]interface{}{
|
posEntry := map[string]interface{}{
|
||||||
@@ -304,7 +304,7 @@ func (d *Dashboard) broadcastLoop() {
|
|||||||
"started_at": pos.StartedAt.Format("15:04:05"),
|
"started_at": pos.StartedAt.Format("15:04:05"),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate live PnL from current prices
|
// Calculate live PnL from current prices — use weighted average for scale-ins
|
||||||
if exMap := snap[pos.Coin]; exMap != nil {
|
if exMap := snap[pos.Coin]; exMap != nil {
|
||||||
bgP := exMap[ExBitget]
|
bgP := exMap[ExBitget]
|
||||||
hlP := exMap[ExHyperLiquid]
|
hlP := exMap[ExHyperLiquid]
|
||||||
@@ -315,8 +315,10 @@ func (d *Dashboard) broadcastLoop() {
|
|||||||
} else {
|
} else {
|
||||||
longCurrent, shortCurrent = hlP, bgP
|
longCurrent, shortCurrent = hlP, bgP
|
||||||
}
|
}
|
||||||
longPnl := (longCurrent - pos.LongLeg.EntryPrice) / pos.LongLeg.EntryPrice * 100
|
longAvg := weightedAvgPrice(pos.LongEntryPrices, pos.AmountUSD/float64(max(1, len(pos.LongEntryPrices))))
|
||||||
shortPnl := (pos.ShortLeg.EntryPrice - shortCurrent) / pos.ShortLeg.EntryPrice * 100
|
shortAvg := weightedAvgPrice(pos.ShortEntryPrices, pos.AmountUSD/float64(max(1, len(pos.ShortEntryPrices))))
|
||||||
|
longPnl := (longCurrent - longAvg) / longAvg * 100
|
||||||
|
shortPnl := (shortAvg - shortCurrent) / shortAvg * 100
|
||||||
totalFees := 2 * (makerFees[ExBitget] + makerFees[ExHyperLiquid])
|
totalFees := 2 * (makerFees[ExBitget] + makerFees[ExHyperLiquid])
|
||||||
netPnl := longPnl + shortPnl - totalFees
|
netPnl := longPnl + shortPnl - totalFees
|
||||||
|
|
||||||
@@ -426,7 +428,7 @@ func (d *Dashboard) handleIndex(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
func (d *Dashboard) handleStatus(w http.ResponseWriter, r *http.Request) {
|
func (d *Dashboard) handleStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
snap := d.store.GetAll()
|
snap := d.store.GetAll()
|
||||||
positions := d.trader.GetOpenPositions()
|
positions := d.trader.GetPositionsCopy()
|
||||||
converged, diverged, flat, total := d.trader.GetClosedStats()
|
converged, diverged, flat, total := d.trader.GetClosedStats()
|
||||||
|
|
||||||
resp := map[string]interface{}{
|
resp := map[string]interface{}{
|
||||||
|
|||||||
+3
-40
@@ -1,42 +1,5 @@
|
|||||||
package exchange
|
package exchange
|
||||||
|
|
||||||
import "github.com/gorilla/websocket"
|
// Package-level helpers for the exchange package.
|
||||||
|
// connector.go imports gorilla/websocket, so this file needs no imports
|
||||||
// These are needed for compilation of the exchange package.
|
// for that dependency. CalcNetProfit was removed (see netProfit in scanner.go).
|
||||||
// PriceConnector is defined in connector.go.
|
|
||||||
var _ = websocket.ErrCloseSent // keep gorilla/websocket import
|
|
||||||
|
|
||||||
// CalcNetProfit calculates net profit % for a complete round trip (entry + exit) between two exchanges.
|
|
||||||
// buyPrice: price on the buy exchange
|
|
||||||
// sellPrice: price on the sell exchange
|
|
||||||
// buyFee: fee rate on buy exchange (e.g. 0.03 for 0.03%)
|
|
||||||
// sellFee: fee rate on sell exchange
|
|
||||||
// buyFee2: buy fee on the other exchange
|
|
||||||
// sellFee2: sell fee on the other exchange
|
|
||||||
// Returns net profit in percentage.
|
|
||||||
func CalcNetProfit(price1, price2, fee1Buy, fee1Sell, fee2Buy, fee2Sell float64) float64 {
|
|
||||||
// price1 = Bitget, price2 = HyperLiquid
|
|
||||||
// Try: buy cheap (min), sell expensive (max)
|
|
||||||
buyPrice := price1
|
|
||||||
sellPrice := price2
|
|
||||||
buyFee := fee1Buy
|
|
||||||
sellFee := fee2Sell
|
|
||||||
|
|
||||||
if price2 < price1 {
|
|
||||||
buyPrice = price2
|
|
||||||
sellPrice = price1
|
|
||||||
buyFee = fee2Buy
|
|
||||||
sellFee = fee1Sell
|
|
||||||
}
|
|
||||||
|
|
||||||
// Entry: buy at buyPrice (pay buyFee), sell short at sellPrice (pay sellFee)
|
|
||||||
if buyPrice <= 0 || sellPrice <= 0 {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
cost := buyPrice * (1 + buyFee/100)
|
|
||||||
revenue := sellPrice * (1 - sellFee/100)
|
|
||||||
|
|
||||||
// Exit: sell long (pay sellFee), buy back short (pay buyFee)
|
|
||||||
// Total fees = 2 * (buyFee + sellFee), first round already in formula above
|
|
||||||
return (revenue/cost-1)*100 - (buyFee + sellFee)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"math/big"
|
"math/big"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"crypto/ed25519"
|
"crypto/ed25519"
|
||||||
@@ -50,6 +51,7 @@ type HyperLiquidTrade struct {
|
|||||||
Address string
|
Address string
|
||||||
client *http.Client
|
client *http.Client
|
||||||
lastNonce int64
|
lastNonce int64
|
||||||
|
nonceMu sync.Mutex // protect lastNonce++ (Issue #4)
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewHyperLiquidTrade(privateKeyHex, address string) (*HyperLiquidTrade, error) {
|
func NewHyperLiquidTrade(privateKeyHex, address string) (*HyperLiquidTrade, error) {
|
||||||
@@ -96,9 +98,11 @@ func (h *HyperLiquidTrade) PlaceMarketOrder(coin, side, sz string) (string, erro
|
|||||||
BrokerCode: 0,
|
BrokerCode: 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate nonce
|
// Generate nonce (thread-safe)
|
||||||
|
h.nonceMu.Lock()
|
||||||
h.lastNonce++
|
h.lastNonce++
|
||||||
nonce := time.Now().UnixMilli()*1_000_000 + h.lastNonce%1_000_000
|
nonce := time.Now().UnixMilli()*1_000_000 + h.lastNonce%1_000_000
|
||||||
|
h.nonceMu.Unlock()
|
||||||
|
|
||||||
// Sign the action
|
// Sign the action
|
||||||
sig, err := h.signAction(action, nonce)
|
sig, err := h.signAction(action, nonce)
|
||||||
|
|||||||
@@ -187,10 +187,10 @@ func main() {
|
|||||||
tickMs, t1.Sub(t0).Milliseconds(), t2.Sub(t1).Milliseconds(), t3.Sub(t2).Milliseconds())
|
tickMs, t1.Sub(t0).Milliseconds(), t2.Sub(t1).Milliseconds(), t3.Sub(t2).Milliseconds())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hourly trade summary
|
// Hourly trade summary — use hour-based tracking (wider window than second-granularity)
|
||||||
hour := now.Hour()
|
hour := now.Hour()
|
||||||
if now.Minute() == 0 && now.Second() < 5 && hour != lastHour {
|
if hour != lastHour && now.Minute() < 1 {
|
||||||
positions := trader.GetOpenPositions()
|
positions := trader.GetPositionsCopy()
|
||||||
notifier.SendTradeSummary(positions, now.Format("2006-01-02 15:04"))
|
notifier.SendTradeSummary(positions, now.Format("2006-01-02 15:04"))
|
||||||
lastHour = hour
|
lastHour = hour
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -73,7 +73,7 @@ func (n *Notifier) SendAlert(opp *ArbOpportunity) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SendTradeSummary sends a summary of open positions at each hour.
|
// SendTradeSummary sends a summary of open positions at each hour.
|
||||||
func (n *Notifier) SendTradeSummary(positions []*ArbPosition, timeStr string) {
|
func (n *Notifier) SendTradeSummary(positions []ArbPosition, timeStr string) {
|
||||||
if n.BotToken == "" || n.ChatID == "" {
|
if n.BotToken == "" || n.ChatID == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,6 +50,43 @@ type ArbPosition struct {
|
|||||||
Status string // "open", "closed"
|
Status string // "open", "closed"
|
||||||
RealizedPnl float64
|
RealizedPnl float64
|
||||||
ErrorLog string
|
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.
|
// 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,
|
Coin: opp.Coin, Exchange: ExHyperLiquid, Side: Short,
|
||||||
EntryPrice: hlP, EntryTime: time.Now(),
|
EntryPrice: hlP, EntryTime: time.Now(),
|
||||||
}
|
}
|
||||||
|
pos.LongEntryPrices = []float64{bgP}
|
||||||
|
pos.ShortEntryPrices = []float64{hlP}
|
||||||
} else {
|
} else {
|
||||||
pos.Direction = "HL->BG"
|
pos.Direction = "HL->BG"
|
||||||
pos.EntrySpread = (bgP - hlP) / hlP * 100 // positive when bgP > hlP
|
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,
|
Coin: opp.Coin, Exchange: ExBitget, Side: Short,
|
||||||
EntryPrice: bgP, EntryTime: time.Now(),
|
EntryPrice: bgP, EntryTime: time.Now(),
|
||||||
}
|
}
|
||||||
|
pos.LongEntryPrices = []float64{hlP}
|
||||||
|
pos.ShortEntryPrices = []float64{bgP}
|
||||||
}
|
}
|
||||||
|
|
||||||
t.mu.Lock()
|
t.mu.Lock()
|
||||||
@@ -357,6 +398,8 @@ func (t *Trader) checkScaleIn(pos *ArbPosition, bgP, hlP, diffPct float64, store
|
|||||||
pos.ScaleLevels++
|
pos.ScaleLevels++
|
||||||
pos.LastScaleAt = time.Now()
|
pos.LastScaleAt = time.Now()
|
||||||
pos.AmountUSD += t.cfg.TradeAmountUSD
|
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",
|
log.Printf("[Trader] %s: Scale-in #%d executed | spread=%.4f%% (entry=%.4f%%) | total=$%.0f",
|
||||||
pos.Coin, pos.ScaleLevels, diffPct, entryDiff, pos.AmountUSD)
|
pos.Coin, pos.ScaleLevels, diffPct, entryDiff, pos.AmountUSD)
|
||||||
@@ -389,7 +432,8 @@ func (t *Trader) checkExit(pos *ArbPosition, bgP, hlP, diffPct float64, notifier
|
|||||||
return
|
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
|
var longCurrent, shortCurrent float64
|
||||||
if pos.LongLeg.Exchange == ExBitget {
|
if pos.LongLeg.Exchange == ExBitget {
|
||||||
longCurrent, shortCurrent = bgP, hlP
|
longCurrent, shortCurrent = bgP, hlP
|
||||||
@@ -397,8 +441,12 @@ func (t *Trader) checkExit(pos *ArbPosition, bgP, hlP, diffPct float64, notifier
|
|||||||
longCurrent, shortCurrent = hlP, bgP
|
longCurrent, shortCurrent = hlP, bgP
|
||||||
}
|
}
|
||||||
|
|
||||||
longPnl := (longCurrent - pos.LongLeg.EntryPrice) / pos.LongLeg.EntryPrice * 100
|
// Weighted average entry prices across all scale levels
|
||||||
shortPnl := (pos.ShortLeg.EntryPrice - shortCurrent) / pos.ShortLeg.EntryPrice * 100
|
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]) // 开仓 + 平仓手续费
|
totalFees := 2 * (makerFees[ExBitget] + makerFees[ExHyperLiquid]) // 开仓 + 平仓手续费
|
||||||
netPnl := longPnl + shortPnl - totalFees
|
netPnl := longPnl + shortPnl - totalFees
|
||||||
|
|
||||||
@@ -627,6 +675,27 @@ func (t *Trader) GetOpenPositions() []*ArbPosition {
|
|||||||
return r
|
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.
|
// GetClosedStats returns convergence stats from all closed trades.
|
||||||
func (t *Trader) GetClosedStats() (converged, diverged, flat, total int) {
|
func (t *Trader) GetClosedStats() (converged, diverged, flat, total int) {
|
||||||
t.mu.Lock()
|
t.mu.Lock()
|
||||||
@@ -717,12 +786,14 @@ func (t *Trader) restoreOpenPositions() {
|
|||||||
Coin: tr.Coin, Exchange: tr.LongExchange, Side: Long,
|
Coin: tr.Coin, Exchange: tr.LongExchange, Side: Long,
|
||||||
EntryPrice: *tr.LongEntry, EntryTime: tr.OpenedAt,
|
EntryPrice: *tr.LongEntry, EntryTime: tr.OpenedAt,
|
||||||
}
|
}
|
||||||
|
pos.LongEntryPrices = []float64{*tr.LongEntry}
|
||||||
}
|
}
|
||||||
if tr.ShortEntry != nil {
|
if tr.ShortEntry != nil {
|
||||||
pos.ShortLeg = &PositionLeg{
|
pos.ShortLeg = &PositionLeg{
|
||||||
Coin: tr.Coin, Exchange: tr.ShortExchange, Side: Short,
|
Coin: tr.Coin, Exchange: tr.ShortExchange, Side: Short,
|
||||||
EntryPrice: *tr.ShortEntry, EntryTime: tr.OpenedAt,
|
EntryPrice: *tr.ShortEntry, EntryTime: tr.OpenedAt,
|
||||||
}
|
}
|
||||||
|
pos.ShortEntryPrices = []float64{*tr.ShortEntry}
|
||||||
}
|
}
|
||||||
t.positions[tr.Coin] = pos
|
t.positions[tr.Coin] = pos
|
||||||
// Prevent immediate re-trading of the same coin
|
// Prevent immediate re-trading of the same coin
|
||||||
|
|||||||
Reference in New Issue
Block a user