mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-15 16:56:56 +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"}`
|
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
|
// Create pending trade — requires user confirmation
|
||||||
trade := &TradeAction{
|
trade := &TradeAction{
|
||||||
ID: fmt.Sprintf("trade_%d", time.Now().UnixNano()),
|
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()
|
a.pending.CleanExpired()
|
||||||
|
|
||||||
// Return confirmation info to LLM so it can present it to the user
|
// 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",
|
"status": "pending_confirmation",
|
||||||
"trade_id": trade.ID,
|
"trade_id": trade.ID,
|
||||||
"action": trade.Action,
|
"action": trade.Action,
|
||||||
@@ -248,7 +271,11 @@ func (a *Agent) toolExecuteTrade(_ context.Context, userID int64, lang, argsJSON
|
|||||||
"leverage": trade.Leverage,
|
"leverage": trade.Leverage,
|
||||||
"message": fmt.Sprintf("Trade created. User must confirm with: 确认 %s (or: confirm %s)", trade.ID, trade.ID),
|
"message": fmt.Sprintf("Trade created. User must confirm with: 确认 %s (or: confirm %s)", trade.ID, trade.ID),
|
||||||
"expires": "5 minutes",
|
"expires": "5 minutes",
|
||||||
})
|
}
|
||||||
|
if marketWarning != "" {
|
||||||
|
resultMap["market_warning"] = marketWarning
|
||||||
|
}
|
||||||
|
result, _ := json.Marshal(resultMap)
|
||||||
return string(result)
|
return string(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -356,10 +383,24 @@ func (a *Agent) toolGetMarketPrice(argsJSON string) string {
|
|||||||
}
|
}
|
||||||
price, err := underlying.GetMarketPrice(sym)
|
price, err := underlying.GetMarketPrice(sym)
|
||||||
if err == nil && price > 0 {
|
if err == nil && price > 0 {
|
||||||
result, _ := json.Marshal(map[string]any{
|
priceResult := map[string]any{
|
||||||
"symbol": sym,
|
"symbol": sym,
|
||||||
"price": price,
|
"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)
|
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)
|
// Use Hyperliquid API for xyz dex assets (use 5m since 3m may not be available)
|
||||||
klines3m, err = getKlinesFromHyperliquid(symbol, "5m", 100)
|
klines3m, err = getKlinesFromHyperliquid(symbol, "5m", 100)
|
||||||
if err != nil {
|
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 {
|
} else {
|
||||||
// Use CoinAnk for regular crypto assets with exchange-specific data
|
// Use CoinAnk for regular crypto assets with exchange-specific data
|
||||||
klines3m, err = getKlinesFromCoinAnk(symbol, "3m", exchange, 100)
|
klines3m, err = getKlinesFromCoinAnk(symbol, "3m", exchange, 100)
|
||||||
if err != nil {
|
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 {
|
if useHyperliquidAPI {
|
||||||
klines4h, err = getKlinesFromHyperliquid(symbol, "4h", 100)
|
klines4h, err = getKlinesFromHyperliquid(symbol, "4h", 100)
|
||||||
if err != nil {
|
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 {
|
} else {
|
||||||
klines4h, err = getKlinesFromCoinAnk(symbol, "4h", exchange, 100)
|
klines4h, err = getKlinesFromCoinAnk(symbol, "4h", exchange, 100)
|
||||||
if err != nil {
|
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)
|
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
|
// Close position
|
||||||
order, err := at.trader.CloseLong(decision.Symbol, 0) // 0 = close all
|
order, err := at.trader.CloseLong(decision.Symbol, 0) // 0 = close all
|
||||||
if err != nil {
|
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)
|
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
|
// 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
|
// Critical: orphaned SL/TP orders could trigger after position is closed and create unintended positions
|
||||||
if err := at.trader.CancelStopOrders(decision.Symbol); err != nil {
|
if err := at.trader.CancelStopOrders(decision.Symbol); err != nil {
|
||||||
|
|||||||
@@ -276,7 +276,7 @@ func (t *HyperliquidTrader) GetMarketPrice(symbol string) (float64, error) {
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
return priceFloat, 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)
|
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 {
|
if err == nil {
|
||||||
return priceFloat, 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)
|
return 0, fmt.Errorf("xyz dex price not found for %s (lookup key: %s)", coin, lookupKey)
|
||||||
|
|||||||
Reference in New Issue
Block a user