diff --git a/exchange/hyperliquid_trade.go b/exchange/hyperliquid_trade.go index 171c1c2..55de7ee 100644 --- a/exchange/hyperliquid_trade.go +++ b/exchange/hyperliquid_trade.go @@ -178,21 +178,39 @@ func GetHLSize(coin string, amountUSD, price float64) string { switch coin { case "DOGE": sz = math.Floor(sz) // step=1, szDecimals=0 + if sz < 1 { + sz = 1 + } return fmt.Sprintf("%.0f", sz) case "LINK": sz = math.Floor(sz*10) / 10 // step=0.1, szDecimals=1 + if sz < 0.1 { + sz = 0.1 + } return fmt.Sprintf("%.1f", sz) case "ONDO": sz = math.Floor(sz) // step=1, szDecimals=0 + if sz < 1 { + sz = 1 + } return fmt.Sprintf("%.0f", sz) case "OP": sz = math.Floor(sz*10) / 10 // step=0.1, szDecimals=1 + if sz < 0.1 { + sz = 0.1 + } return fmt.Sprintf("%.1f", sz) case "WIF": sz = math.Floor(sz) // step=1, szDecimals=0 + if sz < 1 { + sz = 1 + } return fmt.Sprintf("%.0f", sz) case "ARB": sz = math.Floor(sz*10) / 10 // step=0.1, szDecimals=1 + if sz < 0.1 { + sz = 0.1 + } return fmt.Sprintf("%.1f", sz) default: return fmt.Sprintf("%.4f", sz) diff --git a/trader.go b/trader.go index e968714..2e302e7 100644 --- a/trader.go +++ b/trader.go @@ -228,6 +228,12 @@ func (t *Trader) Tick(store *PriceStore, notifier *Notifier) { } diffPct := (highP - lowP) / lowP * 100 + // Retry close for positions that failed to close on previous attempt + if pos.Status == "close_failed" { + t.retryClose(pos, bgP, hlP, notifier) + continue + } + // Check scale-in: if spread widened enough, add more t.checkScaleIn(pos, bgP, hlP, diffPct, store) @@ -355,7 +361,16 @@ func (t *Trader) executeEntry(opp *ArbOpportunity, store *PriceStore, notifier * } time.Sleep(300 * time.Millisecond) if err := t.placeOrder(pos.ShortLeg, "sell", store); err != "" { - t.closeLeg(pos.LongLeg) + // Leg1 placed successfully, leg2 failed — try to close leg1 + if closeErr := t.closeLeg(pos.LongLeg); closeErr != "" { + // CRITICAL: leg1 is still open on the exchange! + // Record the orphan so we don't silently lose tracking + pos.ErrorLog = fmt.Sprintf("ORPHAN: leg1 %s %s placed OK, leg2 %s %s failed (%s), leg1 close also failed (%s)", + pos.LongLeg.Exchange, pos.LongLeg.Side, + pos.ShortLeg.Exchange, pos.ShortLeg.Side, + err, closeErr) + log.Printf("[Trader] ⚠️ ORPHAN POSITION on %s: %s", pos.Coin, pos.ErrorLog) + } t.cleanup(pos.Coin) return false } @@ -521,6 +536,20 @@ func (t *Trader) checkExit(pos *ArbPosition, bgP, hlP, diffPct float64, notifier closeErr := t.closeBothLegs(pos) + if closeErr != "" { + // Close failed — keep the position for retry on next Tick + pos.Status = "close_failed" + pos.ErrorLog = closeErr + pos.ExitedAt = time.Now() + log.Printf("[Trader] ❌ %s: Close failed: %s — will retry on next tick", pos.Coin, closeErr) + notifier.Send(fmt.Sprintf( + "[平仓失败] %s/USDT %s\n"+ + " 状态: close_failed\n"+ + " 错误: %s\n"+ + " 下一轮将重试关掉剩余的腿\n", pos.Coin, pos.Direction, closeErr)) + return + } + pos.RealizedPnl = netPnl pos.ExitedAt = time.Now() pos.Status = "closed" @@ -549,7 +578,7 @@ func (t *Trader) checkExit(pos *ArbPosition, bgP, hlP, diffPct float64, notifier // Persist to SQLite if t.db != nil { - go t.persistTrade(pos, diffPct, convergenceLabel, exitReason, netPnl, longPnl, shortPnl, totalFees) + t.persistTrade(pos, diffPct, convergenceLabel, exitReason, netPnl, longPnl, shortPnl, totalFees) } msg := fmt.Sprintf( @@ -570,9 +599,6 @@ func (t *Trader) checkExit(pos *ArbPosition, bgP, hlP, diffPct float64, notifier shortPnl, pos.ShortLeg.Exchange, pos.ShortLeg.EntryPrice, shortCurrent, totalFees, netPnl, exitReason, ) - if closeErr != "" { - msg += fmt.Sprintf(" 平仓异常: %s\n", closeErr) - } notifier.Send(msg) // P3-4: real-time trade event push @@ -658,6 +684,40 @@ func (t *Trader) closeLeg(leg *PositionLeg) string { return "" } +// retryClose retries closing a position that previously failed. +// Only closes legs not already marked Closed. Notifies periodically. +func (t *Trader) retryClose(pos *ArbPosition, bgP, hlP float64, notifier *Notifier) { + log.Printf("[Trader] %s: Retrying close (previous err: %s)", pos.Coin, pos.ErrorLog) + + closeErr := t.closeBothLegs(pos) + if closeErr == "" { + // All legs finally closed — mark as done + pos.Status = "closed" + pos.ExitedAt = time.Now() + t.mu.Lock() + delete(t.positions, pos.Coin) + t.lastTradeTime[pos.Coin] = time.Now() + t.mu.Unlock() + notifier.Send(fmt.Sprintf( + "[平仓重试成功] %s/USDT %s\n"+ + " 之前失败: %s\n"+ + " 已成功关掉所有腿\n", pos.Coin, pos.Direction, pos.ErrorLog)) + return + } + + // Still failing — update log and notify periodically + pos.ErrorLog = closeErr + log.Printf("[Trader] ❌ %s: Retry close still failing: %s", pos.Coin, closeErr) + if time.Since(pos.ExitedAt) > 30*time.Second { + notifier.Send(fmt.Sprintf( + "[平仓仍失败] %s/USDT %s\n"+ + " 已重试 %s, 仍失败: %s\n"+ + " 请手动检查交易所\n", pos.Coin, pos.Direction, + time.Since(pos.ExitedAt).Round(time.Second).String(), closeErr)) + pos.ExitedAt = time.Now() + } +} + // placeOrderAt places an order at a specified price (used for scale-in, Issue #2). // Unlike placeOrder, this doesn't modify the leg's EntryPrice — it places // an additional order at the current market price for the same trade amount.