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
This commit is contained in:
shinchan-zhai
2026-03-23 20:49:59 +08:00
parent 0e338558d4
commit cd6fe62e16
4 changed files with 57 additions and 10 deletions

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -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 {

View File

@@ -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)