diff --git a/.gitignore b/.gitignore index caae807..3a55808 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,6 @@ hl_helper/node_modules/ +.env +exchange-monitor +exchange-monitor.log +*.log +data/ diff --git a/config.json b/config.json index 57d7ca7..b44f0ee 100644 --- a/config.json +++ b/config.json @@ -3,8 +3,8 @@ "trade_enabled": true, "arb_threshold": 0.03, "scan_interval_ms": 200, - "trade_threshold": 0.20, - "trade_amount_usd": 5, + "trade_threshold": 0.10, + "trade_amount_usd": 10, "trade_cooldown_ms": 30000, "alert_cooldown_sec": 300, "mock_slippage_pct": 0.05, diff --git a/dashboard.go b/dashboard.go index 97a8d2d..ad93321 100644 --- a/dashboard.go +++ b/dashboard.go @@ -579,10 +579,22 @@ func (d *Dashboard) handleStatus(w http.ResponseWriter, r *http.Request) { positions := d.trader.ReadSnapshot() converged, diverged, flat, total := d.trader.GetClosedStats() + // Format exchange funds (snake_case, like SSE) + exFunds := d.trader.GetExchangeFunds() + exFundsMap := make(map[string]map[string]float64, len(exFunds)) + for ex, ef := range exFunds { + exFundsMap[ex] = map[string]float64{ + "balance": math.Round(ef.Balance*100) / 100, + "total_fee": math.Round(ef.TotalFee*100) / 100, + "total_pnl": math.Round(ef.TotalPnl*100) / 100, + } + } + resp := map[string]interface{}{ - "prices": snap, - "positions": len(positions), - "stats": map[string]int{"total": total, "converged": converged, "diverged": diverged, "flat": flat}, + "prices": snap, + "positions": len(positions), + "stats": map[string]int{"total": total, "converged": converged, "diverged": diverged, "flat": flat}, + "exchange_funds": exFundsMap, } writeJSON(w, resp) } diff --git a/exchange/bitget_trade.go b/exchange/bitget_trade.go index 260e7f9..26a8baf 100644 --- a/exchange/bitget_trade.go +++ b/exchange/bitget_trade.go @@ -14,7 +14,6 @@ import ( "time" ) -// BitgetTrade handles order placement on Bitget (live or paper). type BitgetTrade struct { APIKey string APISecret string @@ -23,8 +22,6 @@ type BitgetTrade struct { paperMode bool } -// NewBitgetTrade creates a BitgetTrade. Paper mode is auto-detected -// from the API key prefix: "bg_" → Bitget paper trading. func NewBitgetTrade(apiKey, apiSecret, passphrase string) *BitgetTrade { return &BitgetTrade{ APIKey: apiKey, @@ -35,49 +32,28 @@ func NewBitgetTrade(apiKey, apiSecret, passphrase string) *BitgetTrade { } } -// PlaceMarketOrder places a market order on Bitget. -// side: "buy" or "sell" -// symbol: "BTCUSDT" (always without _UMCBL suffix — appended internally for live) -// size: contract size in coin units (e.g. 0.001 for BTC) -// tradeSide: "open" or "close" — only used in paper mode func (b *BitgetTrade) PlaceMarketOrder(side, symbol, size, tradeSide string) (string, error) { ts := fmt.Sprintf("%d", time.Now().UnixMilli()) method := "POST" - var requestPath string - var host string - if b.paperMode { - requestPath = "/api/v2/mix/order/place-order" - host = "https://api.bitget.com" - } else { - requestPath = "/api/v2/mix/order/place" - host = "https://api.bitget.com" - } + requestPath := "/api/v2/mix/order/place-order" + host := "https://api.bitget.com" body := map[string]interface{}{ "marginCoin": "USDT", + "symbol": symbol, "side": side, "orderType": "market", - "timeInForce": "IOC", // immediate-or-cancel for market orders + "timeInForce": "IOC", + "marginMode": "crossed", + "tradeSide": tradeSide, + "size": size, } - - if b.paperMode { - body["symbol"] = symbol - body["marginMode"] = "crossed" - body["tradeSide"] = tradeSide - } else { - body["symbol"] = symbol + "_UMCBL" - } - bodyJSON, _ := json.Marshal(body) sign := b.sign(method, requestPath, ts, string(bodyJSON)) - url := host + requestPath - req, err := http.NewRequest(method, url, strings.NewReader(string(bodyJSON))) - if err != nil { - return "", fmt.Errorf("create request: %w", err) - } + req, _ := http.NewRequest(method, url, strings.NewReader(string(bodyJSON))) req.Header.Set("Content-Type", "application/json") req.Header.Set("ACCESS-KEY", b.APIKey) req.Header.Set("ACCESS-SIGN", sign) @@ -92,7 +68,6 @@ func (b *BitgetTrade) PlaceMarketOrder(side, symbol, size, tradeSide string) (st return "", fmt.Errorf("http request: %w", err) } defer resp.Body.Close() - respBody, _ := io.ReadAll(resp.Body) var result struct { @@ -103,7 +78,7 @@ func (b *BitgetTrade) PlaceMarketOrder(side, symbol, size, tradeSide string) (st } `json:"data"` } if err := json.Unmarshal(respBody, &result); err != nil { - return "", fmt.Errorf("parse response: %s", string(respBody)) + return "", fmt.Errorf("parse: %s", string(respBody)) } if result.Code != "00000" { return "", fmt.Errorf("bitget error: %s - %s", result.Code, result.Msg) @@ -118,27 +93,15 @@ func (b *BitgetTrade) sign(method, requestPath, timestamp, body string) string { return base64.StdEncoding.EncodeToString(mac.Sum(nil)) } -// GetBalance queries available balance. func (b *BitgetTrade) GetBalance() (float64, error) { ts := fmt.Sprintf("%d", time.Now().UnixMilli()) method := "GET" - - var host, requestPath string - if b.paperMode { - host = "https://api.bitget.com" - requestPath = "/api/v2/mix/account/accounts?productType=USDT-FUTURES" - } else { - host = "https://api.bitget.com" - requestPath = "/api/v2/mix/account/accounts?productType=UMCBL" - } + host := "https://api.bitget.com" + requestPath := "/api/v2/mix/account/accounts?productType=USDT-FUTURES" sign := b.sign(method, requestPath, ts, "") - url := host + requestPath - req, err := http.NewRequest(method, url, nil) - if err != nil { - return 0, fmt.Errorf("create request: %w", err) - } + req, _ := http.NewRequest(method, url, nil) req.Header.Set("ACCESS-KEY", b.APIKey) req.Header.Set("ACCESS-SIGN", sign) req.Header.Set("ACCESS-TIMESTAMP", ts) @@ -149,82 +112,68 @@ func (b *BitgetTrade) GetBalance() (float64, error) { resp, err := b.client.Do(req) if err != nil { - return 0, fmt.Errorf("http request: %w", err) + return 0, fmt.Errorf("http: %w", err) } defer resp.Body.Close() - respBody, _ := io.ReadAll(resp.Body) - var result struct { - Code string `json:"code"` - Msg string `json:"msg"` - Data []struct { - MarginCoin string `json:"marginCoin"` - Available string `json:"available"` - } `json:"data"` - } - if err := json.Unmarshal(respBody, &result); err != nil { + var raw map[string]interface{} + if err := json.Unmarshal(respBody, &raw); err != nil { return 0, fmt.Errorf("parse: %s", string(respBody)) } - if result.Code != "00000" { - return 0, fmt.Errorf("bitget error: %s - %s", result.Code, result.Msg) + code, _ := raw["code"].(string) + if code != "00000" && code != "" { + msg, _ := raw["msg"].(string) + return 0, fmt.Errorf("bitget error: %s - %s", code, msg) } - for _, acct := range result.Data { - if acct.MarginCoin == "USDT" { - bal, _ := strconv.ParseFloat(acct.Available, 64) + + // Parse data as array of accounts + dataRaw, ok := raw["data"] + if !ok || dataRaw == nil { + return 0, fmt.Errorf("no data in response") + } + dataArr, ok := dataRaw.([]interface{}) + if !ok { + return 0, fmt.Errorf("unexpected data format") + } + for _, item := range dataArr { + acct, ok := item.(map[string]interface{}) + if !ok { + continue + } + if acct["marginCoin"] == "USDT" { + bal, _ := strconv.ParseFloat(fmt.Sprint(acct["available"]), 64) return bal, nil } } - return 0, fmt.Errorf("no USDT margin account found") + return 0, fmt.Errorf("no USDT account found") } -// GetBitgetSize calculates the contract size for a given USD amount. -// Returns size as a decimal string complying with Bitget's USDT-FUTURES precision. -// Enforces the exchange's minimum: minTradeNum contracts AND $5 min notional. -// Uses math.Floor to round DOWN to the nearest valid step (B#5: prevent rounding up). func GetBitgetSize(symbol string, amountUSD, price float64) string { if amountUSD < 5 { - amountUSD = 5 // Bitget minimum notional + amountUSD = 5 } - sz := amountUSD / price // raw coin count - + sz := amountUSD / price switch symbol { case "DOGEUSDT": - if sz < 1 { - sz = 1 - } - sz = math.Floor(sz) // step=1 - return fmt.Sprintf("%.0f", sz) // minTradeNum=1, sizeMultiplier=1 - case "LINKUSDT": - if sz < 1 { - sz = 1 - } - sz = math.Floor(sz) // step=1 - return fmt.Sprintf("%.0f", sz) // minTradeNum=1, sizeMultiplier=1 + if sz < 1 { sz = 1 } + return fmt.Sprintf("%.0f", math.Floor(sz)) case "ONDOUSDT": - sz = math.Floor(sz*10) / 10 // step=0.1 - if sz < 0.1 { - sz = 0.1 - } - return fmt.Sprintf("%.1f", sz) // minTradeNum=0.1, sizeMultiplier=0.1 + sz = math.Floor(sz*10)/10 + if sz < 0.1 { sz = 0.1 } + return fmt.Sprintf("%.1f", sz) case "OPUSDT": - sz = math.Floor(sz*10) / 10 // step=0.1 - if sz < 0.1 { - sz = 0.1 - } - return fmt.Sprintf("%.1f", sz) // minTradeNum=0.1, sizeMultiplier=0.1 + sz = math.Floor(sz*10)/10 + if sz < 0.1 { sz = 0.1 } + return fmt.Sprintf("%.1f", sz) case "WIFUSDT": - sz = math.Floor(sz*10) / 10 // step=0.1 - if sz < 0.1 { - sz = 0.1 - } - return fmt.Sprintf("%.1f", sz) // minTradeNum=0.1, sizeMultiplier=0.1 + sz = math.Floor(sz*10)/10 + if sz < 0.1 { sz = 0.1 } + return fmt.Sprintf("%.1f", sz) case "ARBUSDT": - sz = math.Floor(sz*100) / 100 // step=0.01 - if sz < 0.01 { - sz = 0.01 - } - return fmt.Sprintf("%.2f", sz) // minTradeNum=0.01, sizeMultiplier=0.01 + sz = math.Floor(sz*100)/100 + if sz < 0.01 { sz = 0.01 } + return fmt.Sprintf("%.2f", sz) default: return fmt.Sprintf("%.4f", sz) } diff --git a/exchange/hyperliquid_trade.go b/exchange/hyperliquid_trade.go index dc5579e..905cb75 100644 --- a/exchange/hyperliquid_trade.go +++ b/exchange/hyperliquid_trade.go @@ -16,7 +16,6 @@ import ( hl "github.com/sonirico/go-hyperliquid" ) -// HyperLiquidTrade handles order placement on HyperLiquid using the SDK. type HyperLiquidTrade struct { exchange *hl.Exchange info *hl.Info @@ -40,24 +39,20 @@ func NewHyperLiquidTrade(privateKeyHex, mainAddress, apiAddress string) (*HyperL privKey, err := crypto.ToECDSA(keyBytes) if err != nil { - return nil, fmt.Errorf("convert to ECDSA: %w", err) + return nil, fmt.Errorf("to ECDSA: %w", err) } - // Initialize SDK Info (auto-fetches meta + spotMeta) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() info := hl.NewInfo(ctx, hl.TestnetAPIURL, true, nil, nil, nil) - t := &HyperLiquidTrade{ + return &HyperLiquidTrade{ privateKey: privKey, mainAddress: mainAddress, info: info, configured: true, - } - - // Initialize exchange lazily on first order - return t, nil + }, nil } func (h *HyperLiquidTrade) initExchange() error { @@ -65,7 +60,7 @@ func (h *HyperLiquidTrade) initExchange() error { return nil } if !h.configured { - return fmt.Errorf("HyperLiquid not configured") + return fmt.Errorf("HL not configured") } ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) @@ -73,25 +68,14 @@ func (h *HyperLiquidTrade) initExchange() error { meta, err := h.info.Meta(ctx) if err != nil { - return fmt.Errorf("fetch meta: %w", err) + return fmt.Errorf("meta: %w", err) } - spotMeta, err := h.info.SpotMeta(ctx) if err != nil { - return fmt.Errorf("fetch spot meta: %w", err) + return fmt.Errorf("spot meta: %w", err) } - h.exchange = hl.NewExchange( - ctx, - h.privateKey, - hl.TestnetAPIURL, - meta, - "", - h.mainAddress, - spotMeta, - nil, - ) - + h.exchange = hl.NewExchange(ctx, h.privateKey, hl.TestnetAPIURL, meta, "", h.mainAddress, spotMeta, nil) return nil } @@ -99,38 +83,30 @@ func (h *HyperLiquidTrade) IsConfigured() bool { return h.configured } -// PlaceMarketOrder places a market (IOC) order on HyperLiquid. func (h *HyperLiquidTrade) PlaceMarketOrder(coin, side, sz string) (string, error) { if !h.configured { - return "", fmt.Errorf("HyperLiquid not configured") + return "", fmt.Errorf("HL not configured") } - if err := h.initExchange(); err != nil { - return "", fmt.Errorf("init exchange: %w", err) + return "", fmt.Errorf("init: %w", err) } isBuy := side == "buy" - size, err := strconv.ParseFloat(sz, 64) - if err != nil { - return "", fmt.Errorf("parse size %s: %w", sz, err) - } + size, _ := strconv.ParseFloat(sz, 64) - // Get current price for slippage ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() mids, err := h.info.AllMids(ctx) if err != nil { - return "", fmt.Errorf("fetch mids: %w", err) + return "", fmt.Errorf("mids: %w", err) } - priceStr, ok := mids[coin] if !ok { return "", fmt.Errorf("coin %s not found", coin) } midPx, _ := strconv.ParseFloat(priceStr, 64) - // Aggressive IOC: buy above market, sell below limitPx := midPx * 2.0 if !isBuy { limitPx = midPx * 0.5 @@ -140,51 +116,56 @@ func (h *HyperLiquidTrade) PlaceMarketOrder(coin, side, sz string) (string, erro if err != nil { return "", fmt.Errorf("market open: %w", err) } - - // Marshal response respJSON, _ := json.Marshal(result) return string(respJSON), nil } -// GetHLSize calculates size for a given USD amount on HyperLiquid. +func (h *HyperLiquidTrade) GetBalance() (float64, error) { + if !h.configured { + return 0, fmt.Errorf("HL not configured") + } + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + // HL testnet USDC is on spot, not perp. Use SpotUserState. + state, err := h.info.SpotUserState(ctx, h.mainAddress) + if err != nil { + return 0, fmt.Errorf("spot user state: %w", err) + } + + for _, b := range state.Balances { + if b.Coin == "USDC" { + total, _ := strconv.ParseFloat(b.Total, 64) + hold, _ := strconv.ParseFloat(b.Hold, 64) + return total - hold, nil + } + } + return 0, fmt.Errorf("USDC balance not found in spot state") +} + func GetHLSize(coin string, amountUSD, price float64) string { sz := amountUSD / price switch coin { case "DOGE": sz = math.Floor(sz) - if sz < 1 { - sz = 1 - } + if sz < 1 { sz = 1 } return fmt.Sprintf("%.0f", sz) - case "LINK": - sz = math.Floor(sz*10) / 10 - if sz < 0.1 { - sz = 0.1 - } - return fmt.Sprintf("%.1f", sz) case "ONDO": sz = math.Floor(sz) - if sz < 1 { - sz = 1 - } + if sz < 1 { sz = 1 } return fmt.Sprintf("%.0f", sz) case "OP": - sz = math.Floor(sz*10) / 10 - if sz < 0.1 { - sz = 0.1 - } + sz = math.Floor(sz*10)/10 + if sz < 0.1 { sz = 0.1 } return fmt.Sprintf("%.1f", sz) case "WIF": sz = math.Floor(sz) - if sz < 1 { - sz = 1 - } + if sz < 1 { sz = 1 } return fmt.Sprintf("%.0f", sz) case "ARB": - sz = math.Floor(sz*10) / 10 - if sz < 0.1 { - sz = 0.1 - } + sz = math.Floor(sz*10)/10 + if sz < 0.1 { sz = 0.1 } return fmt.Sprintf("%.1f", sz) default: return fmt.Sprintf("%.4f", sz) diff --git a/scanner.go b/scanner.go index e017db0..6662843 100644 --- a/scanner.go +++ b/scanner.go @@ -19,7 +19,7 @@ var takerFees = map[string]float64{ // TickerCoins defines all coins we monitor. var TrackedCoins = []TrackedCoin{ {Name: "DOGE", BN: "DOGEUSDT", BG: "DOGEUSDT", HL: "DOGE"}, - {Name: "LINK", BN: "LINKUSDT", BG: "LINKUSDT", HL: "LINK"}, + // LINK removed — not listed on HL testnet {Name: "ONDO", BN: "ONDOUSDT", BG: "ONDOUSDT", HL: "ONDO"}, {Name: "OP", BN: "OPUSDT", BG: "OPUSDT", HL: "OP"}, {Name: "WIF", BN: "WIFUSDT", BG: "WIFUSDT", HL: "WIF"}, diff --git a/trader.go b/trader.go index 0d0a1af..992cd73 100644 --- a/trader.go +++ b/trader.go @@ -248,9 +248,34 @@ func NewTrader(cfg *Config, database *db.DB) *Trader { } } + // Fetch real balances from exchanges + t.fetchBalances() + return t } +func (t *Trader) fetchBalances() { + // Bitget + if t.bitget != nil { + if bal, err := t.bitget.GetBalance(); err == nil { + t.exchangeFunds[ExBitget] = &ExchangeFund{Balance: bal} + log.Printf("[Funds] Bitget balance: $%.2f", bal) + } else { + log.Printf("[Funds] Bitget balance fetch failed: %v (using default)", err) + } + } + + // HyperLiquid + if t.hyperliquid != nil && t.hyperliquid.IsConfigured() { + if bal, err := t.hyperliquid.GetBalance(); err == nil { + t.exchangeFunds[ExHyperLiquid] = &ExchangeFund{Balance: bal} + log.Printf("[Funds] HyperLiquid balance: $%.2f", bal) + } else { + log.Printf("[Funds] HyperLiquid balance fetch failed: %v (using default)", err) + } + } +} + func (t *Trader) IsConfigured() bool { switch { case t.cfg.TestMode: