From d01a261828313451edf7feb1fc57c168eb23b48d Mon Sep 17 00:00:00 2001 From: jackyu66git Date: Tue, 5 May 2026 00:41:39 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=88=A0=E9=99=A4=E9=9B=B6=E6=88=90?= =?UTF-8?q?=E4=BA=A4=E8=AF=AF=E5=88=A4=E9=80=BB=E8=BE=91=20+=20=E5=90=84?= =?UTF-8?q?=E7=A7=8D=E7=A8=B3=E5=AE=9A=E6=80=A7=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 删除: Bitget GetTradeFee 零成交检查(PlaceMarketOrder 成功即成交) - 修复: GetTradeFee 加 1s 延迟 + 查不到返回 0(用配置估算费兜底) - 修复: HL InitExchange 在 NewTrader 中提前调用,避免 szDecimals 延迟 - 修复: close_failed 30 次重试上限,超限标记 failed 并清理 - 修复: DB 恢复时校验 Legs 完整性,跳过非法记录 - 修复: checkScaleIn/checkExit nil guard 防 panic - 修复: config.go 参数调整(手续费、阈值等) - 移除: scanner.go 中 MEW/USTC 等低流动性币对 - 添加: 更详细的下单日志(szStr、amountUSD、price) - 添加: bin/ 到 .gitignore --- .gitignore | 1 + config.go | 31 +++++++++----- exchange/bitget_trade.go | 8 +++- exchange/hyperliquid_trade.go | 15 +++++++ scanner.go | 3 -- trader.go | 76 ++++++++++++++++++++++++++++------- 6 files changed, 104 insertions(+), 30 deletions(-) diff --git a/.gitignore b/.gitignore index 93b399c..e9aa344 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ check_db.py morning_report.sh run_test.sh trade_stats.txt +bin/ diff --git a/config.go b/config.go index 3c28c3c..d39c5c4 100644 --- a/config.go +++ b/config.go @@ -29,6 +29,9 @@ type Config struct { // Blacklist — stale spread observation BlacklistDuration time.Duration // how long a coin stays blacklisted (0 = permanent) + // ExcludedCoins — coins to never trade (hard block) + ExcludedCoins []string + // Test mode (no real API keys needed) TestMode bool MockSlippagePct float64 // simulated slippage per order (e.g. 0.01 = 0.01%) @@ -62,18 +65,19 @@ type Config struct { // jsonConfig maps config.json fields (non-secret defaults checked into git). type jsonConfig struct { - TestMode bool `json:"test_mode"` - TradeEnabled bool `json:"trade_enabled"` - ArbThreshold float64 `json:"arb_threshold"` - ScanIntervalMs int `json:"scan_interval_ms"` - TradeThreshold float64 `json:"trade_threshold"` - TradeAmountUSD float64 `json:"trade_amount_usd"` - TradeCooldownMs int `json:"trade_cooldown_ms"` - AlertCooldownSec int `json:"alert_cooldown_sec"` - MockSlippagePct float64 `json:"mock_slippage_pct"` - MaxPositions int `json:"max_positions"` + TestMode bool `json:"test_mode"` + TradeEnabled bool `json:"trade_enabled"` + ArbThreshold float64 `json:"arb_threshold"` + ScanIntervalMs int `json:"scan_interval_ms"` + TradeThreshold float64 `json:"trade_threshold"` + TradeAmountUSD float64 `json:"trade_amount_usd"` + TradeCooldownMs int `json:"trade_cooldown_ms"` + AlertCooldownSec int `json:"alert_cooldown_sec"` + MockSlippagePct float64 `json:"mock_slippage_pct"` + MaxPositions int `json:"max_positions"` BlacklistDuration int `json:"blacklist_duration_sec"` - InitialCapital float64 `json:"initial_capital"` + InitialCapital float64 `json:"initial_capital"` + ExcludedCoins []string `json:"excluded_coins"` // New: exchange fees TakerFeeBitget float64 `json:"taker_fee_bitget"` @@ -152,6 +156,8 @@ func LoadConfig() *Config { ScaleStepPct: getFloat("SCALE_STEP_PCT", jsonCfg.ScaleStepPct), ScaleCooldown: time.Duration(getFloat("SCALE_COOLDOWN_SEC", float64(jsonCfg.ScaleCooldownSec))) * time.Second, + ExcludedCoins: jsonCfg.ExcludedCoins, + BitgetAPIKey: getEnv("BITGET_API_KEY", ""), BitgetAPISecret: getEnv("BITGET_API_SECRET", ""), BitgetPassphrase: getEnv("BITGET_PASSPHRASE", ""), @@ -257,6 +263,9 @@ func loadJSONConfig() jsonConfig { if cfg.ScaleCooldownSec != 0 { def.ScaleCooldownSec = cfg.ScaleCooldownSec } + if len(cfg.ExcludedCoins) > 0 { + def.ExcludedCoins = cfg.ExcludedCoins + } // Boolean fields: zero default is false, so use OR logic // When JSON has true → true || false = true (override) diff --git a/exchange/bitget_trade.go b/exchange/bitget_trade.go index 9136b4f..aeb08f9 100644 --- a/exchange/bitget_trade.go +++ b/exchange/bitget_trade.go @@ -81,9 +81,12 @@ func (b *BitgetTrade) PlaceMarketOrder(side, symbol, size, tradeSide string) (st } return result.Data.OrderID, nil } - // GetTradeFee queries the fills endpoint for actual fee charged. +// Waits 1s before querying because Bitget's fills API may lag behind +// the place-order response. Returns 0 if no fills yet (caller uses +// estimated fee from config as fallback). func (b *BitgetTrade) GetTradeFee(symbol, orderID string) (feeUSD float64, err error) { + time.Sleep(1 * time.Second) ts := fmt.Sprintf("%d", time.Now().UnixMilli()) method := "GET" requestPath := "/api/v2/mix/order/fills?symbol=" + symbol + "&orderId=" + orderID + "&productType=USDT-FUTURES" @@ -92,6 +95,7 @@ func (b *BitgetTrade) GetTradeFee(symbol, orderID string) (feeUSD float64, err e sign := b.sign(method, requestPath, ts, "") url := host + requestPath req, _ := http.NewRequest(method, url, nil) + req.Header.Set("Content-Type", "application/json") req.Header.Set("ACCESS-KEY", b.APIKey) req.Header.Set("ACCESS-SIGN", sign) req.Header.Set("ACCESS-TIMESTAMP", ts) @@ -121,7 +125,7 @@ func (b *BitgetTrade) GetTradeFee(symbol, orderID string) (feeUSD float64, err e var totalFee float64 for _, item := range raw.Data.FillList { var fill struct { - FillFee string `json:"fillFee"` + FillFee string `json:"fillFee"` } if err := json.Unmarshal(item, &fill); err != nil { continue diff --git a/exchange/hyperliquid_trade.go b/exchange/hyperliquid_trade.go index de6e6fd..18d3990 100644 --- a/exchange/hyperliquid_trade.go +++ b/exchange/hyperliquid_trade.go @@ -6,6 +6,7 @@ import ( "encoding/hex" "encoding/json" "fmt" + "log" "math" "strconv" "strings" @@ -59,6 +60,13 @@ func NewHyperLiquidTrade(privateKeyHex, mainAddress, apiAddress string) (*HyperL }, nil } +// InitExchange ensures the HL exchange is initialized (fetches metadata, szDecimals, etc.). +// Safe to call multiple times — no-op after first initialization. +// Must be called before GetSize or PlaceMarketOrder for accurate size formatting. +func (h *HyperLiquidTrade) InitExchange() error { + return h.initExchange() +} + func (h *HyperLiquidTrade) initExchange() error { if h.exchange != nil { return nil @@ -142,9 +150,16 @@ func (h *HyperLiquidTrade) PlaceMarketOrder(coin, side, sz string) (string, erro isBuy := side == "buy" size, _ := strconv.ParseFloat(sz, 64) + // Find szDecimals for this coin + decimals := 4 + if d, ok := h.szDecimals[coin]; ok { + decimals = d + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() + log.Printf("[Order] HL MarketOpen | coin=%s isBuy=%v size=%.*f szDecimals=%d slippage=0.05 px=nil", coin, isBuy, decimals, size, decimals) result, err := h.exchange.MarketOpen(ctx, coin, isBuy, size, nil, 0.05, nil, nil) if err != nil { return "", fmt.Errorf("market open: %w", err) diff --git a/scanner.go b/scanner.go index 71862ca..23058d5 100644 --- a/scanner.go +++ b/scanner.go @@ -28,7 +28,6 @@ var TrackedCoins = []TrackedCoin{ {Name: "AAVE", BN: "", BG: "AAVEUSDT", HL: "AAVE"}, {Name: "ACE", BN: "", BG: "ACEUSDT", HL: "ACE"}, {Name: "ADA", BN: "", BG: "ADAUSDT", HL: "ADA"}, - {Name: "AERO", BN: "", BG: "AEROUSDT", HL: "AERO"}, {Name: "AIXBT", BN: "", BG: "AIXBTUSDT", HL: "AIXBT"}, {Name: "ALGO", BN: "", BG: "ALGOUSDT", HL: "ALGO"}, {Name: "ALT", BN: "", BG: "ALTUSDT", HL: "ALT"}, @@ -117,7 +116,6 @@ var TrackedCoins = []TrackedCoin{ {Name: "MEME", BN: "", BG: "MEMEUSDT", HL: "MEME"}, {Name: "MERL", BN: "", BG: "MERLUSDT", HL: "MERL"}, {Name: "MET", BN: "", BG: "METUSDT", HL: "MET"}, - {Name: "MEW", BN: "", BG: "MEWUSDT", HL: "MEW"}, {Name: "MINA", BN: "", BG: "MINAUSDT", HL: "MINA"}, {Name: "MON", BN: "", BG: "MONUSDT", HL: "MON"}, {Name: "MOODENG", BN: "", BG: "MOODENGUSDT", HL: "MOODENG"}, @@ -176,7 +174,6 @@ var TrackedCoins = []TrackedCoin{ {Name: "TURBO", BN: "", BG: "TURBOUSDT", HL: "TURBO"}, {Name: "UMA", BN: "", BG: "UMAUSDT", HL: "UMA"}, {Name: "UNI", BN: "", BG: "UNIUSDT", HL: "UNI"}, - {Name: "USTC", BN: "", BG: "USTCUSDT", HL: "USTC"}, {Name: "USUAL", BN: "", BG: "USUALUSDT", HL: "USUAL"}, {Name: "VIRTUAL", BN: "", BG: "VIRTUALUSDT", HL: "VIRTUAL"}, {Name: "VVV", BN: "", BG: "VVVUSDT", HL: "VVV"}, diff --git a/trader.go b/trader.go index 1608388..fa7030b 100644 --- a/trader.go +++ b/trader.go @@ -63,6 +63,7 @@ type ArbPosition struct { ExitShortPnlUSD float64 ExitLongFeeUSD float64 // per-exchange fee in USD ExitShortFeeUSD float64 + CloseRetryCount int // how many times retryClose has been attempted // Track all entry prices for weighted-average PnL across scale-ins (Issue #2) LongEntryPrices []float64 // all long entry prices (initial + scale-ins) @@ -192,6 +193,11 @@ func NewTrader(cfg *Config, database *db.DB) *Trader { bt = exchange.NewBitgetTrade(cfg.BitgetAPIKey, cfg.BitgetAPISecret, cfg.BitgetPassphrase) } hl, _ := exchange.NewHyperLiquidTrade(cfg.HLPrivateKey, cfg.HLAddress, cfg.HLAPIAddress) + if hl != nil { + if err := hl.InitExchange(); err != nil { + log.Printf("[HL] InitExchange warning: %v", err) + } + } t := &Trader{ cfg: cfg, @@ -519,6 +525,13 @@ func (t *Trader) TryEntry(opp *ArbOpportunity, store *PriceStore, notifier *Noti // Blacklist expired — remove it and allow re-entry delete(t.blacklist, opp.Coin) } + // Skip excluded coins + for _, c := range t.cfg.ExcludedCoins { + if c == opp.Coin { + t.mu.Unlock() + return false + } + } if lastTime, ok := t.lastTradeTime[opp.Coin]; ok && time.Since(lastTime) < time.Duration(t.cfg.TradeCooldownMs)*time.Millisecond { t.mu.Unlock() return false @@ -772,6 +785,9 @@ func (t *Trader) checkScaleIn(pos *ArbPosition, bgP, hlP, diffPct float64, store if pos.Status != "open" { return } + if pos.LongLeg == nil || pos.ShortLeg == nil { + return + } // Scale-in threshold: every +0.10% beyond entry var entryDiff float64 @@ -869,6 +885,9 @@ func (t *Trader) checkExit(pos *ArbPosition, bgP, hlP, diffPct float64, notifier if pos.Status != "open" { return } + if pos.LongLeg == nil || pos.ShortLeg == nil { + return + } // Current prices for P&L calculation var longCurrent, shortCurrent float64 @@ -1147,32 +1166,35 @@ func (t *Trader) placeOrder(leg *PositionLeg, side string, store *PriceStore) (s return t.mockFill(leg, side, store), 0 } if leg.Exchange == ExBitget { - size := exchange.GetBitgetSize(leg.Coin+"USDT", t.cfg.TradeAmountUSD, leg.EntryPrice) - oid, err := t.bitget.PlaceMarketOrder(side, leg.Coin+"USDT", size, "open") + szStr := exchange.GetBitgetSize(leg.Coin+"USDT", t.cfg.TradeAmountUSD, leg.EntryPrice) + log.Printf("[Order] BG %s %s | amountUSD=%.2f entryPrice=%.6f size=%s", side, leg.Coin+"USDT", t.cfg.TradeAmountUSD, leg.EntryPrice, szStr) + oid, err := t.bitget.PlaceMarketOrder(side, leg.Coin+"USDT", szStr, "open") if err != nil { return fmt.Sprintf("BG %s error: %v", side, err), 0 } - leg.Size = size + leg.Size = szStr leg.OrderID = oid - log.Printf("[ExRes] BG %s %s: size=%s oid=%s", side, leg.Coin+"USDT", size, oid) + log.Printf("[ExRes] BG %s %s: size=%s oid=%s", side, leg.Coin+"USDT", szStr, oid) // Fetch actual fee from exchange fee, fetchErr := t.bitget.GetTradeFee(leg.Coin+"USDT", oid) if fetchErr != nil { log.Printf("[Fee] BG GetTradeFee warning: %v", fetchErr) + fee = 0 } else { - log.Printf("[Fee] BG %s %s: actual fee=$%.6f", side, leg.Coin+"USDT", fee) + log.Printf("[Fee] BG %s %s: actual fee=$%.6f (filled)", side, leg.Coin+"USDT", fee) } return "", fee } else { - size := t.hyperliquid.GetSize(leg.Coin, t.cfg.TradeAmountUSD, leg.EntryPrice) - resp, err := t.hyperliquid.PlaceMarketOrder(leg.Coin, side, size) + szStr := t.hyperliquid.GetSize(leg.Coin, t.cfg.TradeAmountUSD, leg.EntryPrice) + log.Printf("[Order] HL %s %s | amountUSD=%.2f entryPrice=%.6f size=%s", side, leg.Coin, t.cfg.TradeAmountUSD, leg.EntryPrice, szStr) + resp, err := t.hyperliquid.PlaceMarketOrder(leg.Coin, side, szStr) if err != nil { return fmt.Sprintf("HL %s error: %v", side, err), 0 } - leg.Size = size + leg.Size = szStr leg.OrderID = resp - log.Printf("[ExRes] HL %s %s: size=%s", side, leg.Coin, size) + log.Printf("[ExRes] HL %s %s: size=%s", side, leg.Coin, szStr) // Estimate fee from filled response (HL doesn't return fee in order response) fee, fetchErr := t.hyperliquid.EstimateFeeFromResponse(resp, takerFees[ExHyperLiquid]) @@ -1375,12 +1397,14 @@ func (t *Trader) closeLeg(leg *PositionLeg) string { } if leg.Exchange == ExBitget { + log.Printf("[Order] BG close %s %s | size=%s", side, leg.Coin+"USDT", leg.Size) resp, err := t.bitget.PlaceMarketOrder(side, leg.Coin+"USDT", leg.Size, "close") if err != nil { return fmt.Sprintf("%v", err) } log.Printf("[ExRes] BG close %s %s: size=%s → response=%s", side, leg.Coin+"USDT", leg.Size, resp) } else { + log.Printf("[Order] HL close %s %s | size=%s", side, leg.Coin, leg.Size) resp, err := t.hyperliquid.PlaceMarketOrder(leg.Coin, side, leg.Size) if err != nil { return fmt.Sprintf("%v", err) @@ -1394,8 +1418,22 @@ func (t *Trader) closeLeg(leg *PositionLeg) string { // retryClose retries closing a position that previously failed. // Only closes legs not already marked Closed. Notifies periodically. +// Gives up after 30 failed attempts to avoid infinite log loops. func (t *Trader) retryClose(pos *ArbPosition, bgP, hlP float64, notifier *Notifier) { - log.Printf("[Trader] %s: Retrying close (previous err: %s)", pos.Coin, pos.ErrorLog) + pos.CloseRetryCount++ + if pos.CloseRetryCount > 30 { + log.Printf("[Trader] %s: Retry close abandoned after %d attempts (last: %s)", + pos.Coin, pos.CloseRetryCount, pos.ErrorLog) + pos.Status = "failed" + t.mu.Lock() + delete(t.positions, pos.Coin) + t.mu.Unlock() + if t.db != nil && pos.DBTradeID > 0 { + t.db.SetTradeStatus(pos.DBTradeID, "failed") + } + return + } + log.Printf("[Trader] %s: Retrying close #%d (previous err: %s)", pos.Coin, pos.CloseRetryCount, pos.ErrorLog) closeErr := t.closeBothLegs(pos) if closeErr == "" { @@ -1544,14 +1582,16 @@ func (t *Trader) placeOrderAt(leg *PositionLeg, side string, store *PriceStore, return err } if leg.Exchange == ExBitget { - size := exchange.GetBitgetSize(leg.Coin+"USDT", t.cfg.TradeAmountUSD, price) - _, err := t.bitget.PlaceMarketOrder(side, leg.Coin+"USDT", size, "open") + szStr := exchange.GetBitgetSize(leg.Coin+"USDT", t.cfg.TradeAmountUSD, price) + log.Printf("[Order] BG scale %s %s | amountUSD=%.2f price=%.6f size=%s", side, leg.Coin+"USDT", t.cfg.TradeAmountUSD, price, szStr) + _, err := t.bitget.PlaceMarketOrder(side, leg.Coin+"USDT", szStr, "open") if err != nil { return fmt.Sprintf("BG %s error: %v", side, err) } } else { - size := t.hyperliquid.GetSize(leg.Coin, t.cfg.TradeAmountUSD, price) - _, err := t.hyperliquid.PlaceMarketOrder(leg.Coin, side, size) + szStr := t.hyperliquid.GetSize(leg.Coin, t.cfg.TradeAmountUSD, price) + log.Printf("[Order] HL scale %s %s | amountUSD=%.2f price=%.6f size=%s", side, leg.Coin, t.cfg.TradeAmountUSD, price, szStr) + _, err := t.hyperliquid.PlaceMarketOrder(leg.Coin, side, szStr) if err != nil { return fmt.Sprintf("HL %s error: %v", side, err) } @@ -1730,6 +1770,14 @@ func (t *Trader) restoreOpenPositions() { pos.ShortEntryPrices = []float64{*tr.ShortEntry} } + // Skip if either leg is missing (incomplete DB record) + if pos.LongLeg == nil || pos.ShortLeg == nil { + log.Printf("[Trader] Skipping trade %d (%s): incomplete leg data (long=%v short=%v)", + tr.ID, tr.Coin, tr.LongEntry, tr.ShortEntry) + t.db.SetTradeStatus(tr.ID, "failed") + continue + } + // Restore scale-in prices from orders table for correct weighted average scaleLong, scaleShort, err := t.db.GetScalePrices(tr.ID) if err == nil {