From e5ea78ffd246d78c67dad37a85cb998234609cbd Mon Sep 17 00:00:00 2001 From: jackyu66git Date: Mon, 4 May 2026 14:31:52 +0800 Subject: [PATCH] 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 --- .gitignore | 1 + config.json | 2 +- dashboard.go | 21 +++++ exchange/hyperliquid.go | 2 +- exchange/hyperliquid_trade.go | 23 ++++- main.go | 4 + scanner.go | 165 ---------------------------------- trader.go | 105 ++++++++++++++++++++-- 8 files changed, 146 insertions(+), 177 deletions(-) diff --git a/.gitignore b/.gitignore index 854b3f1..5584d94 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ # Binaries exchange-monitor +main # Logs *.log diff --git a/config.json b/config.json index bdaab1c..57d7ca7 100644 --- a/config.json +++ b/config.json @@ -1,5 +1,5 @@ { - "test_mode": true, + "test_mode": false, "trade_enabled": true, "arb_threshold": 0.03, "scan_interval_ms": 200, diff --git a/dashboard.go b/dashboard.go index 756a20c..97a8d2d 100644 --- a/dashboard.go +++ b/dashboard.go @@ -240,6 +240,8 @@ func (d *Dashboard) Run() { mux.HandleFunc("GET /api/trade/", d.handleTradeDetail) mux.HandleFunc("GET /api/connections", d.handleConnStatus) // P3-5 mux.HandleFunc("GET /events", d.handleSSE) + mux.HandleFunc("POST /api/stop", d.handleStop) + mux.HandleFunc("POST /api/start", d.handleStart) server := &http.Server{ Addr: d.addr, @@ -479,6 +481,15 @@ func (d *Dashboard) broadcastLoop() { d.connMu.RUnlock() stats["connections"] = connInfo + // Trading status + stats["trading"] = map[string]interface{}{ + "active": !d.trader.IsShuttingDown(), + "mode": d.trader.ModeLabel(), + "test": d.trader.cfg.TestMode, + "target": d.trader.realTradesTarget, + "done": d.trader.realTradesDone, + } + // Per-exchange fund tracking exFunds := d.trader.GetExchangeFunds() exFundsMap := make(map[string]map[string]float64, len(exFunds)) @@ -708,6 +719,16 @@ func (d *Dashboard) handleSSE(w http.ResponseWriter, r *http.Request) { } } +func (d *Dashboard) handleStop(w http.ResponseWriter, r *http.Request) { + d.trader.Stop() + writeJSON(w, map[string]string{"status": "stopped", "message": "Trading stopped, positions closing"}) +} + +func (d *Dashboard) handleStart(w http.ResponseWriter, r *http.Request) { + d.trader.Start() + writeJSON(w, map[string]string{"status": "started", "message": "Trading resumed"}) +} + func writeJSON(w http.ResponseWriter, v interface{}) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(v) diff --git a/exchange/hyperliquid.go b/exchange/hyperliquid.go index e3b030a..0df7b90 100644 --- a/exchange/hyperliquid.go +++ b/exchange/hyperliquid.go @@ -25,7 +25,7 @@ func NewHyperLiquidWS(tracked []string) *HyperLiquidWS { } // Run connects to HyperLiquid WS and streams mid prices. func (h *HyperLiquidWS) Run(updateFn func(coin string, price, bid, ask float64)) error { - conn := NewPriceConnector("wss://api.hyperliquid.xyz/ws", "HyperLiquid", 120*time.Second, 30*time.Second) + conn := NewPriceConnector("wss://api.hyperliquid-testnet.xyz/ws", "HyperLiquid", 120*time.Second, 30*time.Second) conn.PingInterval = 45 * time.Second conn.OnConnect = func() { diff --git a/exchange/hyperliquid_trade.go b/exchange/hyperliquid_trade.go index 55de7ee..b78156f 100644 --- a/exchange/hyperliquid_trade.go +++ b/exchange/hyperliquid_trade.go @@ -1,6 +1,7 @@ package exchange import ( + "encoding/base64" "encoding/hex" "encoding/json" "fmt" @@ -59,13 +60,27 @@ func NewHyperLiquidTrade(privateKeyHex, address string) (*HyperLiquidTrade, erro if privateKeyHex == "" { return &HyperLiquidTrade{client: &http.Client{Timeout: 10 * time.Second}}, nil } + + // Try hex decode first keyBytes, err := hex.DecodeString(privateKeyHex) if err != nil { - return nil, fmt.Errorf("decode private key: %w", err) + // Fallback to base64 decode + keyBytes, err = base64.StdEncoding.DecodeString(privateKeyHex) + if err != nil { + return nil, fmt.Errorf("decode private key: not valid hex or base64") + } } - if len(keyBytes) != ed25519.PrivateKeySize { - return nil, fmt.Errorf("invalid private key length: %d (expected %d)", len(keyBytes), ed25519.PrivateKeySize) + + // Accept both 32-byte (EVM) and 64-byte (Ed25519) keys + // 32-byte EVM keys are padded to 64 bytes for Ed25519 + if len(keyBytes) == 32 { + // Use as Ed25519 seed — append the public key part + priv := ed25519.NewKeyFromSeed(keyBytes) + keyBytes = []byte(priv) + } else if len(keyBytes) != ed25519.PrivateKeySize { + return nil, fmt.Errorf("invalid private key length: %d (expected 32 or %d)", len(keyBytes), ed25519.PrivateKeySize) } + return &HyperLiquidTrade{ PrivateKey: ed25519.PrivateKey(keyBytes), Address: address, @@ -119,7 +134,7 @@ func (h *HyperLiquidTrade) PlaceMarketOrder(coin, side, sz string) (string, erro bodyJSON, _ := json.Marshal(signed) - req, err := http.NewRequest("POST", "https://api.hyperliquid.xyz/exchange", strings.NewReader(string(bodyJSON))) + req, err := http.NewRequest("POST", "https://api.hyperliquid-testnet.xyz/exchange", strings.NewReader(string(bodyJSON))) if err != nil { return "", fmt.Errorf("create request: %w", err) } diff --git a/main.go b/main.go index f5cc8a9..87c265b 100644 --- a/main.go +++ b/main.go @@ -152,6 +152,10 @@ func main() { cancel() // B#1: cancel context to stop all WS goroutines runLoop = false + case <-trader.StopCh: + log.Println("[Monitor] 5 real trades completed — trading stopped. System still running (dashboard active)") + log.Println("[Monitor] Use POST /api/start to resume trading, POST /api/stop to stop manually") + case <-statusTick.C: snap := store.GetAll() count := 0 diff --git a/scanner.go b/scanner.go index 0cfdcef..e017db0 100644 --- a/scanner.go +++ b/scanner.go @@ -24,171 +24,6 @@ var TrackedCoins = []TrackedCoin{ {Name: "OP", BN: "OPUSDT", BG: "OPUSDT", HL: "OP"}, {Name: "WIF", BN: "WIFUSDT", BG: "WIFUSDT", HL: "WIF"}, {Name: "ARB", BN: "ARBUSDT", BG: "ARBUSDT", HL: "ARB"}, - {Name: "0G", BN: "", BG: "0GUSDT", HL: "0G"}, - {Name: "2Z", BN: "", BG: "2ZUSDT", HL: "2Z"}, - {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"}, - {Name: "ANIME", BN: "", BG: "ANIMEUSDT", HL: "ANIME"}, - {Name: "APE", BN: "", BG: "APEUSDT", HL: "APE"}, - {Name: "APT", BN: "", BG: "APTUSDT", HL: "APT"}, - {Name: "AR", BN: "", BG: "ARUSDT", HL: "AR"}, - {Name: "ARK", BN: "", BG: "ARKUSDT", HL: "ARK"}, - {Name: "ASTER", BN: "", BG: "ASTERUSDT", HL: "ASTER"}, - {Name: "ATOM", BN: "", BG: "ATOMUSDT", HL: "ATOM"}, - {Name: "AVAX", BN: "", BG: "AVAXUSDT", HL: "AVAX"}, - {Name: "AVNT", BN: "", BG: "AVNTUSDT", HL: "AVNT"}, - {Name: "AXS", BN: "", BG: "AXSUSDT", HL: "AXS"}, - {Name: "AZTEC", BN: "", BG: "AZTECUSDT", HL: "AZTEC"}, - {Name: "BABY", BN: "", BG: "BABYUSDT", HL: "BABY"}, - {Name: "BANANA", BN: "", BG: "BANANAUSDT", HL: "BANANA"}, - {Name: "BCH", BN: "", BG: "BCHUSDT", HL: "BCH"}, - {Name: "BERA", BN: "", BG: "BERAUSDT", HL: "BERA"}, - {Name: "BIGTIME", BN: "", BG: "BIGTIMEUSDT", HL: "BIGTIME"}, - {Name: "BIO", BN: "", BG: "BIOUSDT", HL: "BIO"}, - {Name: "BLUR", BN: "", BG: "BLURUSDT", HL: "BLUR"}, - {Name: "BNB", BN: "", BG: "BNBUSDT", HL: "BNB"}, - {Name: "BOME", BN: "", BG: "BOMEUSDT", HL: "BOME"}, - {Name: "BRETT", BN: "", BG: "BRETTUSDT", HL: "BRETT"}, - {Name: "BSV", BN: "", BG: "BSVUSDT", HL: "BSV"}, - {Name: "BTC", BN: "", BG: "BTCUSDT", HL: "BTC"}, - {Name: "CAKE", BN: "", BG: "CAKEUSDT", HL: "CAKE"}, - {Name: "CC", BN: "", BG: "CCUSDT", HL: "CC"}, - {Name: "CELO", BN: "", BG: "CELOUSDT", HL: "CELO"}, - {Name: "CFX", BN: "", BG: "CFXUSDT", HL: "CFX"}, - {Name: "CHILLGUY", BN: "", BG: "CHILLGUYUSDT", HL: "CHILLGUY"}, - {Name: "CHIP", BN: "", BG: "CHIPUSDT", HL: "CHIP"}, - {Name: "COMP", BN: "", BG: "COMPUSDT", HL: "COMP"}, - {Name: "CRV", BN: "", BG: "CRVUSDT", HL: "CRV"}, - {Name: "DASH", BN: "", BG: "DASHUSDT", HL: "DASH"}, - {Name: "DOOD", BN: "", BG: "DOODUSDT", HL: "DOOD"}, - {Name: "DOT", BN: "", BG: "DOTUSDT", HL: "DOT"}, - {Name: "DYDX", BN: "", BG: "DYDXUSDT", HL: "DYDX"}, - {Name: "DYM", BN: "", BG: "DYMUSDT", HL: "DYM"}, - {Name: "EIGEN", BN: "", BG: "EIGENUSDT", HL: "EIGEN"}, - {Name: "ENA", BN: "", BG: "ENAUSDT", HL: "ENA"}, - {Name: "ENS", BN: "", BG: "ENSUSDT", HL: "ENS"}, - {Name: "ETC", BN: "", BG: "ETCUSDT", HL: "ETC"}, - {Name: "ETH", BN: "", BG: "ETHUSDT", HL: "ETH"}, - {Name: "ETHFI", BN: "", BG: "ETHFIUSDT", HL: "ETHFI"}, - {Name: "FARTCOIN", BN: "", BG: "FARTCOINUSDT", HL: "FARTCOIN"}, - {Name: "FET", BN: "", BG: "FETUSDT", HL: "FET"}, - {Name: "FIL", BN: "", BG: "FILUSDT", HL: "FIL"}, - {Name: "FOGO", BN: "", BG: "FOGOUSDT", HL: "FOGO"}, - {Name: "GALA", BN: "", BG: "GALAUSDT", HL: "GALA"}, - {Name: "GAS", BN: "", BG: "GASUSDT", HL: "GAS"}, - {Name: "GMT", BN: "", BG: "GMTUSDT", HL: "GMT"}, - {Name: "GMX", BN: "", BG: "GMXUSDT", HL: "GMX"}, - {Name: "GOAT", BN: "", BG: "GOATUSDT", HL: "GOAT"}, - {Name: "GRASS", BN: "", BG: "GRASSUSDT", HL: "GRASS"}, - {Name: "GRIFFAIN", BN: "", BG: "GRIFFAINUSDT", HL: "GRIFFAIN"}, - {Name: "HBAR", BN: "", BG: "HBARUSDT", HL: "HBAR"}, - {Name: "HYPE", BN: "", BG: "HYPEUSDT", HL: "HYPE"}, - {Name: "HYPER", BN: "", BG: "HYPERUSDT", HL: "HYPER"}, - {Name: "ICP", BN: "", BG: "ICPUSDT", HL: "ICP"}, - {Name: "IMX", BN: "", BG: "IMXUSDT", HL: "IMX"}, - {Name: "INIT", BN: "", BG: "INITUSDT", HL: "INIT"}, - {Name: "INJ", BN: "", BG: "INJUSDT", HL: "INJ"}, - {Name: "IO", BN: "", BG: "IOUSDT", HL: "IO"}, - {Name: "IOTA", BN: "", BG: "IOTAUSDT", HL: "IOTA"}, - {Name: "IP", BN: "", BG: "IPUSDT", HL: "IP"}, - {Name: "JTO", BN: "", BG: "JTOUSDT", HL: "JTO"}, - {Name: "JUP", BN: "", BG: "JUPUSDT", HL: "JUP"}, - {Name: "KAITO", BN: "", BG: "KAITOUSDT", HL: "KAITO"}, - {Name: "KAS", BN: "", BG: "KASUSDT", HL: "KAS"}, - {Name: "LAYER", BN: "", BG: "LAYERUSDT", HL: "LAYER"}, - {Name: "LDO", BN: "", BG: "LDOUSDT", HL: "LDO"}, - {Name: "LINEA", BN: "", BG: "LINEAUSDT", HL: "LINEA"}, - {Name: "LIT", BN: "", BG: "LITUSDT", HL: "LIT"}, - {Name: "LTC", BN: "", BG: "LTCUSDT", HL: "LTC"}, - {Name: "MANTA", BN: "", BG: "MANTAUSDT", HL: "MANTA"}, - {Name: "MAV", BN: "", BG: "MAVUSDT", HL: "MAV"}, - {Name: "ME", BN: "", BG: "MEUSDT", HL: "ME"}, - {Name: "MEGA", BN: "", BG: "MEGAUSDT", HL: "MEGA"}, - {Name: "MELANIA", BN: "", BG: "MELANIAUSDT", HL: "MELANIA"}, - {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"}, - {Name: "MORPHO", BN: "", BG: "MORPHOUSDT", HL: "MORPHO"}, - {Name: "MOVE", BN: "", BG: "MOVEUSDT", HL: "MOVE"}, - {Name: "NEAR", BN: "", BG: "NEARUSDT", HL: "NEAR"}, - {Name: "NEO", BN: "", BG: "NEOUSDT", HL: "NEO"}, - {Name: "NIL", BN: "", BG: "NILUSDT", HL: "NIL"}, - {Name: "NOT", BN: "", BG: "NOTUSDT", HL: "NOT"}, - {Name: "NXPC", BN: "", BG: "NXPCUSDT", HL: "NXPC"}, - {Name: "ORDI", BN: "", BG: "ORDIUSDT", HL: "ORDI"}, - {Name: "PAXG", BN: "", BG: "PAXGUSDT", HL: "PAXG"}, - {Name: "PENDLE", BN: "", BG: "PENDLEUSDT", HL: "PENDLE"}, - {Name: "PENGU", BN: "", BG: "PENGUUSDT", HL: "PENGU"}, - {Name: "PEOPLE", BN: "", BG: "PEOPLEUSDT", HL: "PEOPLE"}, - {Name: "PNUT", BN: "", BG: "PNUTUSDT", HL: "PNUT"}, - {Name: "POL", BN: "", BG: "POLUSDT", HL: "POL"}, - {Name: "POLYX", BN: "", BG: "POLYXUSDT", HL: "POLYX"}, - {Name: "POPCAT", BN: "", BG: "POPCATUSDT", HL: "POPCAT"}, - {Name: "PROVE", BN: "", BG: "PROVEUSDT", HL: "PROVE"}, - {Name: "PUMP", BN: "", BG: "PUMPUSDT", HL: "PUMP"}, - {Name: "PYTH", BN: "", BG: "PYTHUSDT", HL: "PYTH"}, - {Name: "RENDER", BN: "", BG: "RENDERUSDT", HL: "RENDER"}, - {Name: "RESOLV", BN: "", BG: "RESOLVUSDT", HL: "RESOLV"}, - {Name: "REZ", BN: "", BG: "REZUSDT", HL: "REZ"}, - {Name: "RSR", BN: "", BG: "RSRUSDT", HL: "RSR"}, - {Name: "RUNE", BN: "", BG: "RUNEUSDT", HL: "RUNE"}, - {Name: "S", BN: "", BG: "SUSDT", HL: "S"}, - {Name: "SAGA", BN: "", BG: "SAGAUSDT", HL: "SAGA"}, - {Name: "SAND", BN: "", BG: "SANDUSDT", HL: "SAND"}, - {Name: "SEI", BN: "", BG: "SEIUSDT", HL: "SEI"}, - {Name: "SKR", BN: "", BG: "SKRUSDT", HL: "SKR"}, - {Name: "SKY", BN: "", BG: "SKYUSDT", HL: "SKY"}, - {Name: "SNX", BN: "", BG: "SNXUSDT", HL: "SNX"}, - {Name: "SOL", BN: "", BG: "SOLUSDT", HL: "SOL"}, - {Name: "SOPH", BN: "", BG: "SOPHUSDT", HL: "SOPH"}, - {Name: "SPX", BN: "", BG: "SPXUSDT", HL: "SPX"}, - {Name: "STABLE", BN: "", BG: "STABLEUSDT", HL: "STABLE"}, - {Name: "STBL", BN: "", BG: "STBLUSDT", HL: "STBL"}, - {Name: "STRK", BN: "", BG: "STRKUSDT", HL: "STRK"}, - {Name: "STX", BN: "", BG: "STXUSDT", HL: "STX"}, - {Name: "SUI", BN: "", BG: "SUIUSDT", HL: "SUI"}, - {Name: "SUPER", BN: "", BG: "SUPERUSDT", HL: "SUPER"}, - {Name: "SUSHI", BN: "", BG: "SUSHIUSDT", HL: "SUSHI"}, - {Name: "SYRUP", BN: "", BG: "SYRUPUSDT", HL: "SYRUP"}, - {Name: "TAO", BN: "", BG: "TAOUSDT", HL: "TAO"}, - {Name: "TIA", BN: "", BG: "TIAUSDT", HL: "TIA"}, - {Name: "TNSR", BN: "", BG: "TNSRUSDT", HL: "TNSR"}, - {Name: "TON", BN: "", BG: "TONUSDT", HL: "TON"}, - {Name: "TRB", BN: "", BG: "TRBUSDT", HL: "TRB"}, - {Name: "TRUMP", BN: "", BG: "TRUMPUSDT", HL: "TRUMP"}, - {Name: "TRX", BN: "", BG: "TRXUSDT", HL: "TRX"}, - {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"}, - {Name: "W", BN: "", BG: "WUSDT", HL: "W"}, - {Name: "WCT", BN: "", BG: "WCTUSDT", HL: "WCT"}, - {Name: "WLD", BN: "", BG: "WLDUSDT", HL: "WLD"}, - {Name: "WLFI", BN: "", BG: "WLFIUSDT", HL: "WLFI"}, - {Name: "XAI", BN: "", BG: "XAIUSDT", HL: "XAI"}, - {Name: "XLM", BN: "", BG: "XLMUSDT", HL: "XLM"}, - {Name: "XMR", BN: "", BG: "XMRUSDT", HL: "XMR"}, - {Name: "XPL", BN: "", BG: "XPLUSDT", HL: "XPL"}, - {Name: "XRP", BN: "", BG: "XRPUSDT", HL: "XRP"}, - {Name: "YGG", BN: "", BG: "YGGUSDT", HL: "YGG"}, - {Name: "ZEC", BN: "", BG: "ZECUSDT", HL: "ZEC"}, - {Name: "ZEN", BN: "", BG: "ZENUSDT", HL: "ZEN"}, - {Name: "ZETA", BN: "", BG: "ZETAUSDT", HL: "ZETA"}, - {Name: "ZK", BN: "", BG: "ZKUSDT", HL: "ZK"}, - {Name: "ZORA", BN: "", BG: "ZORAUSDT", HL: "ZORA"}, - {Name: "ZRO", BN: "", BG: "ZROUSDT", HL: "ZRO"}, } // netProfit calculates net profit % after fees for a complete round trip (entry + exit). diff --git a/trader.go b/trader.go index 041c6a8..ed3f56a 100644 --- a/trader.go +++ b/trader.go @@ -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()