feat: real trading mode, auto-stop after 5 trades, HL testnet support

- config.json: test_mode=false, ready for sim/testnet trading
- trader.go: auto-stop after 5 real trades, exchange response logging,
  Stop()/Start() API, shuttingDown flag for graceful stop
- dashboard.go: POST /api/stop + POST /api/start endpoints,
  trading status in SSE stats
- exchange/hyperliquid.go: switch HL WS to testnet endpoint
- exchange/hyperliquid_trade.go: switch REST to testnet endpoint,
  support base64 + 32-byte EVM private keys
- main.go: listen on trader.StopCh (graceful, no process exit)
- scanner.go: trim TrackedCoins to only 6 core coins (DOGE/LINK/ONDO/OP/WIF/ARB)
- .gitignore: ignore main binary
This commit is contained in:
jackyu66git
2026-05-04 14:31:52 +08:00
parent 5fa3e1bac8
commit e5ea78ffd2
8 changed files with 146 additions and 177 deletions
+99 -6
View File
@@ -150,6 +150,12 @@ type Trader struct {
// Decoupled snapshot for display — snapMu never contended by trading path
snapMu sync.RWMutex
positionsSnapshot []ArbPosition
// Auto-stop after N real trades
StopCh chan struct{}
realTradesTarget int
realTradesDone int
shuttingDown bool
}
// TradeRecord stores a finalized trade for stats tracking.
@@ -196,6 +202,8 @@ func NewTrader(cfg *Config, database *db.DB) *Trader {
entering: make(map[string]bool),
lastTradeTime: make(map[string]time.Time),
blacklist: make(map[string]time.Time),
StopCh: make(chan struct{}, 1),
realTradesTarget: 5,
exchangeFunds: map[string]*ExchangeFund{
ExBitget: {Balance: cfg.InitialCapital / 2},
ExHyperLiquid: {Balance: cfg.InitialCapital / 2},
@@ -260,6 +268,50 @@ func (t *Trader) ModeLabel() string {
return "LIVE"
}
// IsShuttingDown returns whether trading is stopped.
func (t *Trader) IsShuttingDown() bool {
t.mu.Lock()
defer t.mu.Unlock()
return t.shuttingDown
}
// Stop sets shuttingDown flag and force-closes all open positions.
func (t *Trader) Stop() {
t.mu.Lock()
t.shuttingDown = true
t.mu.Unlock()
log.Println("[Trader] ⏹ Trading STOPPED — no new entries, closing positions...")
// Force-close all open positions immediately
t.mu.Lock()
positions := make([]*ArbPosition, 0, len(t.positions))
for _, pos := range t.positions {
positions = append(positions, pos)
}
t.mu.Unlock()
for _, pos := range positions {
if pos.Status == "open" || pos.Status == "close_failed" {
t.closeBothLegs(pos)
pos.Status = "closed"
pos.ExitedAt = time.Now()
t.mu.Lock()
delete(t.positions, pos.Coin)
t.mu.Unlock()
log.Printf("[Trader] ⏹ Force-closed %s %s (manual stop)", pos.Coin, pos.Direction)
}
}
log.Println("[Trader] ✅ All positions closed, trading stopped. POST /api/start to resume.")
}
// Start clears shuttingDown flag and resumes trading.
func (t *Trader) Start() {
t.mu.Lock()
t.shuttingDown = false
t.mu.Unlock()
log.Println("[Trader] ▶ Trading RESUMED")
}
// Tick is called every scanner cycle — checks scaling and exit.
func (t *Trader) Tick(store *PriceStore, notifier *Notifier) {
if !t.IsConfigured() {
@@ -272,6 +324,26 @@ func (t *Trader) Tick(store *PriceStore, notifier *Notifier) {
for _, pos := range t.positions {
positions = append(positions, pos)
}
// Force-close remaining positions when shutting down
if t.shuttingDown && len(positions) > 0 {
t.mu.Unlock()
for _, pos := range positions {
if pos.Status == "open" || pos.Status == "close_failed" {
t.closeBothLegs(pos)
pos.Status = "closed"
pos.ExitedAt = time.Now()
delete(t.positions, pos.Coin)
log.Printf("[Trader] ⏹ Force-closed %s %s (shutdown)", pos.Coin, pos.Direction)
}
}
// All force-closed — signal stop
select {
case t.StopCh <- struct{}{}:
default:
}
return
}
t.mu.Unlock()
for _, pos := range positions {
@@ -331,6 +403,10 @@ func (t *Trader) TryEntry(opp *ArbOpportunity, store *PriceStore, notifier *Noti
}
t.mu.Lock()
if t.shuttingDown {
t.mu.Unlock()
return false
}
if _, exists := t.positions[opp.Coin]; exists {
t.mu.Unlock()
return false
@@ -828,8 +904,19 @@ func (t *Trader) checkExit(pos *ArbPosition, bgP, hlP, diffPct float64, notifier
delete(t.positions, pos.Coin)
t.lastTradeTime[pos.Coin] = time.Now()
t.closedTrades = append(t.closedTrades, record)
t.realTradesDone++
t.mu.Unlock()
// Auto-stop: after 5 real trades, signal shutdown
if t.realTradesTarget > 0 && t.realTradesDone >= t.realTradesTarget {
log.Printf("[Trader] ✅ %d real trades completed — shutting down...", t.realTradesDone)
t.shuttingDown = true
select {
case t.StopCh <- struct{}{}:
default:
}
}
// Persist exit orders + close trade in DB
if t.db != nil && pos.DBTradeID > 0 {
now := time.Now()
@@ -932,6 +1019,7 @@ func (t *Trader) placeOrder(leg *PositionLeg, side string, store *PriceStore) st
}
leg.Size = size
leg.OrderID = oid
log.Printf("[ExRes] BG %s %s: size=%s → response=%s", side, leg.Coin+"USDT", size, oid)
} else {
size := exchange.GetHLSize(leg.Coin, t.cfg.TradeAmountUSD, leg.EntryPrice)
resp, err := t.hyperliquid.PlaceMarketOrder(leg.Coin, side, size)
@@ -940,6 +1028,7 @@ func (t *Trader) placeOrder(leg *PositionLeg, side string, store *PriceStore) st
}
leg.Size = size
leg.OrderID = resp
log.Printf("[ExRes] HL %s %s: size=%s → response=%s", side, leg.Coin, size, resp)
}
return ""
}
@@ -974,14 +1063,18 @@ func (t *Trader) closeLeg(leg *PositionLeg) string {
return ""
}
var err error
if leg.Exchange == ExBitget {
_, err = t.bitget.PlaceMarketOrder(side, leg.Coin+"USDT", leg.Size)
resp, err := t.bitget.PlaceMarketOrder(side, leg.Coin+"USDT", leg.Size)
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 {
_, err = t.hyperliquid.PlaceMarketOrder(leg.Coin, side, leg.Size)
}
if err != nil {
return fmt.Sprintf("%v", err)
resp, err := t.hyperliquid.PlaceMarketOrder(leg.Coin, side, leg.Size)
if err != nil {
return fmt.Sprintf("%v", err)
}
log.Printf("[ExRes] HL close %s %s: size=%s → response=%s", side, leg.Coin, leg.Size, resp)
}
leg.Closed = true
leg.ExitTime = time.Now()