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.