fix: 删除零成交误判逻辑 + 各种稳定性修复

- 删除: 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
This commit is contained in:
jackyu66git
2026-05-05 00:41:39 +08:00
parent 866f9906b7
commit d01a261828
6 changed files with 104 additions and 30 deletions
+1
View File
@@ -10,3 +10,4 @@ check_db.py
morning_report.sh
run_test.sh
trade_stats.txt
bin/
+20 -11
View File
@@ -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)
+6 -2
View File
@@ -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
+15
View File
@@ -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)
-3
View File
@@ -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"},
+62 -14
View File
@@ -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 {