From cd6fe62e16f6025a68eab2214f2ec76c09f02f88 Mon Sep 17 00:00:00 2001 From: shinchan-zhai Date: Mon, 23 Mar 2026 20:49:59 +0800 Subject: [PATCH] feat(agent): add market hours check for stock trades - execute_trade: warn user when US market is closed (order queued for next open) - get_market_price: include market_open/market_status for stock quotes - Uses Alpaca's /v2/clock endpoint via IsMarketOpen() interface - Prevents surprise queued orders during off-hours --- agent/tools.go | 49 +++++++++++++++++++++++++--- market/data.go | 8 ++--- trader/auto_trader_orders.go | 6 ++++ trader/hyperliquid/trader_account.go | 4 +-- 4 files changed, 57 insertions(+), 10 deletions(-) diff --git a/agent/tools.go b/agent/tools.go index 863c7d4a..1081415f 100644 --- a/agent/tools.go +++ b/agent/tools.go @@ -224,6 +224,29 @@ func (a *Agent) toolExecuteTrade(_ context.Context, userID int64, lang, argsJSON return `{"error": "quantity must be > 0 for opening positions"}` } + // For stock symbols, check market hours and warn if closed + var marketWarning string + if isStockSymbol(sym) && a.traderManager != nil { + for _, t := range a.traderManager.GetAllTraders() { + if t.GetExchange() == "alpaca" { + ut := t.GetUnderlyingTrader() + if ut == nil { + continue + } + type marketChecker interface { + IsMarketOpen() (bool, string, error) + } + if mc, ok := ut.(marketChecker); ok { + isOpen, status, err := mc.IsMarketOpen() + if err == nil && !isOpen { + marketWarning = fmt.Sprintf("⚠️ US market is currently %s. Order will be queued for next market open.", status) + } + } + break + } + } + } + // Create pending trade — requires user confirmation trade := &TradeAction{ ID: fmt.Sprintf("trade_%d", time.Now().UnixNano()), @@ -239,7 +262,7 @@ func (a *Agent) toolExecuteTrade(_ context.Context, userID int64, lang, argsJSON a.pending.CleanExpired() // Return confirmation info to LLM so it can present it to the user - result, _ := json.Marshal(map[string]any{ + resultMap := map[string]any{ "status": "pending_confirmation", "trade_id": trade.ID, "action": trade.Action, @@ -248,7 +271,11 @@ func (a *Agent) toolExecuteTrade(_ context.Context, userID int64, lang, argsJSON "leverage": trade.Leverage, "message": fmt.Sprintf("Trade created. User must confirm with: 确认 %s (or: confirm %s)", trade.ID, trade.ID), "expires": "5 minutes", - }) + } + if marketWarning != "" { + resultMap["market_warning"] = marketWarning + } + result, _ := json.Marshal(resultMap) return string(result) } @@ -356,10 +383,24 @@ func (a *Agent) toolGetMarketPrice(argsJSON string) string { } price, err := underlying.GetMarketPrice(sym) if err == nil && price > 0 { - result, _ := json.Marshal(map[string]any{ + priceResult := map[string]any{ "symbol": sym, "price": price, - }) + } + // For stocks, include market status + if wantStock && isAlpaca { + type marketChecker interface { + IsMarketOpen() (bool, string, error) + } + if mc, ok := underlying.(marketChecker); ok { + isOpen, status, mErr := mc.IsMarketOpen() + if mErr == nil { + priceResult["market_open"] = isOpen + priceResult["market_status"] = status + } + } + } + result, _ := json.Marshal(priceResult) return string(result) } } diff --git a/market/data.go b/market/data.go index 9230b8ac..3c59224b 100644 --- a/market/data.go +++ b/market/data.go @@ -48,13 +48,13 @@ func GetWithExchange(symbol, exchange string) (*Data, error) { // Use Hyperliquid API for xyz dex assets (use 5m since 3m may not be available) klines3m, err = getKlinesFromHyperliquid(symbol, "5m", 100) if err != nil { - return nil, fmt.Errorf("Failed to get 5-minute K-line from Hyperliquid: %v", err) + return nil, fmt.Errorf("failed to get 5-minute K-line from Hyperliquid: %w", err) } } else { // Use CoinAnk for regular crypto assets with exchange-specific data klines3m, err = getKlinesFromCoinAnk(symbol, "3m", exchange, 100) if err != nil { - return nil, fmt.Errorf("Failed to get 3-minute K-line from CoinAnk (%s): %v", exchange, err) + return nil, fmt.Errorf("failed to get 3-minute K-line from CoinAnk (%s): %w", exchange, err) } } @@ -68,12 +68,12 @@ func GetWithExchange(symbol, exchange string) (*Data, error) { if useHyperliquidAPI { klines4h, err = getKlinesFromHyperliquid(symbol, "4h", 100) if err != nil { - return nil, fmt.Errorf("Failed to get 4-hour K-line from Hyperliquid: %v", err) + return nil, fmt.Errorf("failed to get 4-hour K-line from Hyperliquid: %w", err) } } else { klines4h, err = getKlinesFromCoinAnk(symbol, "4h", exchange, 100) if err != nil { - return nil, fmt.Errorf("Failed to get 4-hour K-line from CoinAnk (%s): %v", exchange, err) + return nil, fmt.Errorf("failed to get 4-hour K-line from CoinAnk (%s): %w", exchange, err) } } diff --git a/trader/auto_trader_orders.go b/trader/auto_trader_orders.go index 19e850c6..5b6f8da7 100644 --- a/trader/auto_trader_orders.go +++ b/trader/auto_trader_orders.go @@ -322,6 +322,9 @@ func (at *AutoTrader) executeCloseLongWithRecord(decision *kernel.Decision, acti logger.Infof(" 🗑️ Cancelled stop/TP orders for %s", decision.Symbol) } + // Record quantity in action record (was missing — close decisions showed 0 quantity in history) + actionRecord.Quantity = quantity + // Close position order, err := at.trader.CloseLong(decision.Symbol, 0) // 0 = close all if err != nil { @@ -387,6 +390,9 @@ func (at *AutoTrader) executeCloseShortWithRecord(decision *kernel.Decision, act logger.Infof(" 📊 Using exchange position data: qty=%.8f, entry=%.2f", quantity, entryPrice) } + // Record quantity in action record (was missing — close decisions showed 0 quantity in history) + actionRecord.Quantity = quantity + // Cancel existing stop-loss and take-profit orders BEFORE closing position // Critical: orphaned SL/TP orders could trigger after position is closed and create unintended positions if err := at.trader.CancelStopOrders(decision.Symbol); err != nil { diff --git a/trader/hyperliquid/trader_account.go b/trader/hyperliquid/trader_account.go index 7dee04d6..e9183afe 100644 --- a/trader/hyperliquid/trader_account.go +++ b/trader/hyperliquid/trader_account.go @@ -276,7 +276,7 @@ func (t *HyperliquidTrader) GetMarketPrice(symbol string) (float64, error) { if err == nil { return priceFloat, nil } - return 0, fmt.Errorf("price format error: %v", err) + return 0, fmt.Errorf("price format error: %w", err) } return 0, fmt.Errorf("price not found for %s", symbol) @@ -335,7 +335,7 @@ func (t *HyperliquidTrader) getXyzMarketPrice(coin string) (float64, error) { if err == nil { return priceFloat, nil } - return 0, fmt.Errorf("price format error: %v", err) + return 0, fmt.Errorf("price format error: %w", err) } return 0, fmt.Errorf("xyz dex price not found for %s (lookup key: %s)", coin, lookupKey)