mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-06 20:41:14 +08:00
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user