feat: 所有参数移至config.json, 重构退出逻辑, 清理遗留接口

- 将所有硬编码参数迁移到 config.json (手续费率、止盈/止损阈值、
  超时、腿间隔、加仓步进等)
- 退出条件: 净利 >= take_profit_pct 止盈, 价差 <= 0 平仓
- 删除 Binance/dYdX 遗留代码
- 更新 README 文档
- Dashboard: 双交易所价格表、黑名单UI、按币名排序持仓
- Bitget WS: 文本ping保活
- 数据库: 重置, 无历史仓位
This commit is contained in:
jackyu66git
2026-05-04 01:46:17 +08:00
parent 2ed6ffc747
commit 21a3f9a962
10 changed files with 450 additions and 206 deletions
+118 -39
View File
@@ -129,6 +129,7 @@ type Trader struct {
positions map[string]*ArbPosition // coin -> position
entering map[string]bool // coin -> being entered (async goroutine)
lastTradeTime map[string]time.Time
blacklist map[string]time.Time // coin -> when blacklisted (stale spread)
closedTrades []TradeRecord // history of closed trades (current session)
// Historical stats loaded from DB on startup — combined with session stats in GetClosedStats
@@ -148,6 +149,7 @@ type TradeRecord struct {
EntrySpread float64
ExitSpread float64
PnlPct float64
PnlUSD float64 // absolute PnL in USD
Convergence string // "收敛", "发散", "持平"
Reason string // exit reason
Duration string
@@ -172,6 +174,7 @@ func NewTrader(cfg *Config, database *db.DB) *Trader {
positions: make(map[string]*ArbPosition),
entering: make(map[string]bool),
lastTradeTime: make(map[string]time.Time),
blacklist: make(map[string]time.Time),
}
// Restore open positions from DB on restart
@@ -236,6 +239,7 @@ func (t *Trader) Tick(store *PriceStore, notifier *Notifier) {
lowP, highP = hlP, bgP
}
diffPct := (highP - lowP) / lowP * 100
elapsed := time.Since(pos.StartedAt)
// Retry close for positions that failed to close on previous attempt
if pos.Status == "close_failed" {
@@ -248,6 +252,12 @@ func (t *Trader) Tick(store *PriceStore, notifier *Notifier) {
// Check exit: if spread converged, take profit
t.checkExit(pos, bgP, hlP, diffPct, notifier)
// Blacklist: if position still open after 10 minutes without converging,
// the spread is likely stale data. Add coin to blacklist and force close.
if pos.Status == "open" && elapsed > 10*time.Minute {
t.blacklistCoin(pos, notifier)
}
}
}
@@ -275,7 +285,19 @@ func (t *Trader) TryEntry(opp *ArbOpportunity, store *PriceStore, notifier *Noti
t.mu.Unlock()
return false
}
if lastTime, ok := t.lastTradeTime[opp.Coin]; ok && time.Since(lastTime) < 30*time.Second {
if t.cfg.MaxPositions > 0 && len(t.positions)+len(t.entering) >= t.cfg.MaxPositions {
t.mu.Unlock()
return false
}
if blTime, bl := t.blacklist[opp.Coin]; bl {
if t.cfg.BlacklistDuration <= 0 || time.Since(blTime) < t.cfg.BlacklistDuration {
t.mu.Unlock()
return false
}
// Blacklist expired — remove it and allow re-entry
delete(t.blacklist, opp.Coin)
}
if lastTime, ok := t.lastTradeTime[opp.Coin]; ok && time.Since(lastTime) < time.Duration(t.cfg.TradeCooldownMs)*time.Millisecond {
t.mu.Unlock()
return false
}
@@ -313,10 +335,11 @@ func (t *Trader) executeEntry(opp *ArbOpportunity, store *PriceStore, notifier *
currBg := exMap[ExBitget]
currHl := exMap[ExHyperLiquid]
if currBg > 0 && currHl > 0 {
if opp.BuyEx == ExBitget && currHl <= currBg*0.999 {
reversalMul := 1 - t.cfg.ReversalTolerancePct/100
if opp.BuyEx == ExBitget && currHl <= currBg*reversalMul {
return false // reversed beyond small tolerance
}
if opp.BuyEx == ExHyperLiquid && currBg <= currHl*0.999 {
if opp.BuyEx == ExHyperLiquid && currBg <= currHl*reversalMul {
return false
}
}
@@ -368,7 +391,7 @@ func (t *Trader) executeEntry(opp *ArbOpportunity, store *PriceStore, notifier *
t.cleanup(pos.Coin)
return false
}
time.Sleep(300 * time.Millisecond)
time.Sleep(t.cfg.LegDelay)
if err := t.placeOrder(pos.ShortLeg, "sell", store); err != "" {
// Leg1 placed successfully, leg2 failed — try to close leg1
if closeErr := t.closeLeg(pos.LongLeg); closeErr != "" {
@@ -434,14 +457,14 @@ func (t *Trader) checkScaleIn(pos *ArbPosition, bgP, hlP, diffPct float64, store
}
}
scaleStep := 0.10 // add every 0.10% wider
scaleStep := t.cfg.ScaleStepPct // add every X% wider
nextLevel := float64(pos.ScaleLevels+1) * scaleStep
if diffPct < entryDiff+nextLevel {
return
}
// Cooldown: at least 5 seconds between scales
if time.Since(pos.LastScaleAt) < 5*time.Second {
// Cooldown: use configured interval between scales
if time.Since(pos.LastScaleAt) < t.cfg.ScaleCooldown {
return
}
@@ -457,7 +480,7 @@ func (t *Trader) checkScaleIn(pos *ArbPosition, bgP, hlP, diffPct float64, store
log.Printf("[Trader] %s: Scale-in long failed: %s", pos.Coin, err)
return
}
time.Sleep(300 * time.Millisecond)
time.Sleep(t.cfg.LegDelay)
if err := t.placeOrderAt(pos.ShortLeg, "sell", store, shortPrice); err != "" {
log.Printf("[Trader] %s: Scale-in short failed: %s — position partially scaled (long only)", pos.Coin, err)
// Don't close the long leg — the scale-in long order was placed but the
@@ -476,41 +499,14 @@ func (t *Trader) checkScaleIn(pos *ArbPosition, bgP, hlP, diffPct float64, store
pos.Coin, pos.ScaleLevels, diffPct, entryDiff, pos.AmountUSD)
}
// checkExit closes position when spread converges.
// checkExit closes position when net profit >= 0.20% (take profit)
// or spread reversed past -0.02% (stop loss) or timeout.
func (t *Trader) checkExit(pos *ArbPosition, bgP, hlP, diffPct float64, notifier *Notifier) {
if pos.Status != "open" {
return
}
// Exit when spread converges to near zero (<= 0.02%)
// Or if held too long (30 min timeout)
elapsed := time.Since(pos.StartedAt)
shouldExit := false
exitReason := ""
if diffPct <= 0.02 {
shouldExit = true
exitReason = "价差收敛,止盈平仓"
}
// Stop-loss: spread reversed (went negative)
if diffPct < -0.02 {
shouldExit = true
exitReason = "价差反转,止盈平仓"
}
if elapsed > 30*time.Minute {
shouldExit = true
exitReason = "超时平仓"
}
if !shouldExit {
return
}
// Calculate P&L — use weighted average entry for scale-in positions
// Each scale adds cfg.TradeAmountUSD at the scale price
// Current prices for P&L calculation
var longCurrent, shortCurrent float64
if pos.LongLeg.Exchange == ExBitget {
longCurrent, shortCurrent = bgP, hlP
@@ -527,6 +523,33 @@ func (t *Trader) checkExit(pos *ArbPosition, bgP, hlP, diffPct float64, notifier
totalFees := 2 * (takerFees[ExBitget] + takerFees[ExHyperLiquid]) // 开仓 + 平仓手续费
netPnl := longPnl + shortPnl - totalFees
elapsed := time.Since(pos.StartedAt)
shouldExit := false
exitReason := ""
// Take profit: net profit >= configured threshold
if netPnl >= t.cfg.TakeProfitPct {
shouldExit = true
exitReason = "利润止盈"
}
// Exit when spread converges to zero or reverses (prices same or flipped)
if diffPct <= 0 {
shouldExit = true
exitReason = "价差收敛止盈"
}
// Timeout: configured max hold time
if elapsed > t.cfg.PositionTimeout {
shouldExit = true
exitReason = "超时平仓"
}
if !shouldExit {
return
}
// Convergence analysis
convergedPct := (pos.EntrySpread - diffPct) / pos.EntrySpread * 100
convergenceLabel := "价差收敛"
@@ -579,6 +602,7 @@ func (t *Trader) checkExit(pos *ArbPosition, bgP, hlP, diffPct float64, notifier
EntrySpread: pos.EntrySpread,
ExitSpread: diffPct,
PnlPct: netPnl,
PnlUSD: pos.AmountUSD * netPnl / 100,
Convergence: convergenceLabel,
Reason: exitReason,
Duration: elapsed.Round(time.Second).String(),
@@ -927,6 +951,10 @@ func (t *Trader) restoreOpenPositions() {
return
}
for i := range openTrades {
if t.cfg.MaxPositions > 0 && len(t.positions) >= t.cfg.MaxPositions {
log.Printf("[Trader] Skipping restored position %s (max_positions=%d reached)", openTrades[i].Coin, t.cfg.MaxPositions)
break
}
tr := &openTrades[i]
// Recreate position structure from DB record
pos := &ArbPosition{
@@ -958,6 +986,57 @@ func (t *Trader) restoreOpenPositions() {
t.lastTradeTime[tr.Coin] = tr.OpenedAt
}
if len(openTrades) > 0 {
log.Printf("[Trader] Restored %d open positions from DB", len(openTrades))
log.Printf("[Trader] Restored %d open positions from DB", len(t.positions))
}
}
// blacklistCoin adds a coin to the blacklist and force-closes its position.
func (t *Trader) blacklistCoin(pos *ArbPosition, notifier *Notifier) {
t.mu.Lock()
t.blacklist[pos.Coin] = time.Now()
t.mu.Unlock()
log.Printf("[Trader] ⛔ %s: Blacklisted — position open %.0fm without convergence", pos.Coin, time.Since(pos.StartedAt).Minutes())
notifier.Send(fmt.Sprintf(
"<b>[黑名单]</b> %s/USDT\n"+
" 开仓 %.0f 分钟未收敛\n"+
" 已加入黑名单观察\n",
pos.Coin, time.Since(pos.StartedAt).Minutes()))
// Force-close the position immediately
pos.Status = "close_failed" // triggers retryClose on next tick
}
// GetBlacklist returns a copy of the current blacklist (coin -> blacklisted at).
func (t *Trader) GetBlacklist() map[string]time.Time {
t.mu.Lock()
defer t.mu.Unlock()
r := make(map[string]time.Time, len(t.blacklist))
for k, v := range t.blacklist {
r[k] = v
}
return r
}
// IsBlacklisted checks if a coin is currently blacklisted (within duration).
func (t *Trader) IsBlacklisted(coin string) bool {
t.mu.Lock()
defer t.mu.Unlock()
blTime, exists := t.blacklist[coin]
if !exists {
return false
}
if t.cfg.BlacklistDuration > 0 && time.Since(blTime) >= t.cfg.BlacklistDuration {
delete(t.blacklist, coin)
return false
}
return true
}
// RemoveBlacklist removes a coin from the blacklist manually.
func (t *Trader) RemoveBlacklist(coin string) {
t.mu.Lock()
defer t.mu.Unlock()
delete(t.blacklist, coin)
log.Printf("[Trader] ✅ %s: Removed from blacklist", coin)
}