From bbaa1092232bacd21831e4760961962274eb652b Mon Sep 17 00:00:00 2001 From: shinchan-zhai Date: Mon, 23 Mar 2026 19:05:25 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20Alpaca=20integration=20bugs=20=E2=80=94?= =?UTF-8?q?=20field=20mapping,=20market=20hours,=20stock=20symbol=20handli?= =?UTF-8?q?ng?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix critical bug: Alpaca GetBalance returns wrong field names (total_equity vs totalEquity) — auto_trader was getting 0 equity, breaking all decisions - Fix critical bug: Alpaca GetPositions missing positionAmt/unRealizedProfit fields — positions invisible to trading AI - Fix CancelAllOrders: was nuking ALL orders globally, now filters by symbol - Implement GetClosedPnL: was returning nil, now returns filled sell orders - Add IsMarketOpen: checks Alpaca clock endpoint for market hours - Add market hours check in trading loop: skip cycles when US market closed (saves LLM tokens and prevents failed orders) - Fix parseTradeCommand: 'BUY AAPL 10' no longer becomes 'AAPLUSDT' - Fix toolGetMarketPrice: route stock symbols to Alpaca, crypto to others - Add exchange field to toolGetPositions output for multi-exchange clarity --- agent/tools.go | 10 +++ agent/trade.go | 13 +++- trader/alpaca/trader.go | 121 +++++++++++++++++++++++++++++++------ trader/auto_trader_loop.go | 14 +++++ 4 files changed, 138 insertions(+), 20 deletions(-) diff --git a/agent/tools.go b/agent/tools.go index 4cfab0c2..dcdecd4d 100644 --- a/agent/tools.go +++ b/agent/tools.go @@ -274,6 +274,7 @@ func (a *Agent) toolGetPositions() string { } positions = append(positions, map[string]any{ "trader": tid, + "exchange": t.GetExchange(), "symbol": p["symbol"], "side": p["side"], "size": size, @@ -339,11 +340,20 @@ func (a *Agent) toolGetMarketPrice(argsJSON string) string { return `{"error": "no trader manager configured"}` } + wantStock := isStockSymbol(sym) for _, t := range a.traderManager.GetAllTraders() { underlying := t.GetUnderlyingTrader() if underlying == nil { continue } + // Route to correct exchange type (stock vs crypto) + isAlpaca := t.GetExchange() == "alpaca" + if wantStock && !isAlpaca { + continue + } + if !wantStock && isAlpaca { + continue + } price, err := underlying.GetMarketPrice(sym) if err == nil && price > 0 { result, _ := json.Marshal(map[string]any{ diff --git a/agent/trade.go b/agent/trade.go index c5eb45f4..6a987a7d 100644 --- a/agent/trade.go +++ b/agent/trade.go @@ -112,7 +112,8 @@ func parseTradeCommand(text string) *TradeAction { return nil } symbol = words[1] - if !strings.HasSuffix(symbol, "USDT") { + // Only append USDT for crypto symbols, not stock tickers + if !isStockSymbol(symbol) && !strings.HasSuffix(symbol, "USDT") { symbol += "USDT" } @@ -226,7 +227,10 @@ func formatTradeConfirmation(trade *TradeAction, lang string) string { "close_short": "平空 (Close Short)", } - symbol := strings.TrimSuffix(trade.Symbol, "USDT") + symbol := trade.Symbol + if strings.HasSuffix(symbol, "USDT") { + symbol = strings.TrimSuffix(symbol, "USDT") + } actionName := actionNames[trade.Action] if actionName == "" { actionName = trade.Action @@ -308,7 +312,10 @@ func (a *Agent) handleTradeConfirmation(ctx context.Context, userID int64, text, } trade.Status = "executed" - symbol := strings.TrimSuffix(trade.Symbol, "USDT") + symbol := trade.Symbol + if strings.HasSuffix(symbol, "USDT") { + symbol = strings.TrimSuffix(symbol, "USDT") + } actionEmoji := "📈" if strings.Contains(trade.Action, "short") { actionEmoji = "📉" diff --git a/trader/alpaca/trader.go b/trader/alpaca/trader.go index 892f0b07..e4ce58c4 100644 --- a/trader/alpaca/trader.go +++ b/trader/alpaca/trader.go @@ -183,8 +183,12 @@ func (t *AlpacaTrader) GetBalance() (map[string]interface{}, error) { buyingPower := parseFloatStr(acct.BuyingPower) result := map[string]interface{}{ - "total_equity": equity, - "available_balance": cash, + // Standard fields expected by auto_trader (camelCase) + "totalEquity": equity, + "totalWalletBalance": cash, + "availableBalance": cash, + "totalUnrealizedProfit": equity - cash, + // Alpaca-specific fields "buying_power": buyingPower, "currency": acct.Currency, "status": acct.Status, @@ -230,15 +234,17 @@ func (t *AlpacaTrader) GetPositions() ([]map[string]interface{}, error) { } result = append(result, map[string]interface{}{ - "symbol": p.Symbol, - "side": side, - "size": qty, - "entryPrice": parseFloatStr(p.AvgEntryPrice), - "markPrice": parseFloatStr(p.CurrentPrice), - "unrealizedPnl": parseFloatStr(p.UnrealizedPL), - "marketValue": parseFloatStr(p.MarketValue), - "leverage": 1, - "exchange": "alpaca", + "symbol": p.Symbol, + "side": side, + "size": qty, + "positionAmt": qty, // Standard field expected by auto_trader + "entryPrice": parseFloatStr(p.AvgEntryPrice), + "markPrice": parseFloatStr(p.CurrentPrice), + "unrealizedPnl": parseFloatStr(p.UnrealizedPL), + "unRealizedProfit": parseFloatStr(p.UnrealizedPL), // Standard field + "marketValue": parseFloatStr(p.MarketValue), + "leverage": float64(1), + "exchange": "alpaca", }) } @@ -433,10 +439,24 @@ func (t *AlpacaTrader) CancelTakeProfitOrders(symbol string) error { return t.cancelOrdersByType(symbol, "limit") } -// CancelAllOrders cancels all pending orders for a symbol +// CancelAllOrders cancels all pending orders for a symbol. +// If symbol is empty, cancels ALL orders across all symbols. func (t *AlpacaTrader) CancelAllOrders(symbol string) error { - _, err := t.doDelete("/v2/orders") - return err + if symbol == "" { + _, err := t.doDelete("/v2/orders") + return err + } + // Filter by symbol: get open orders for this symbol, then cancel each + orders, err := t.GetOpenOrders(symbol) + if err != nil { + return fmt.Errorf("get open orders for %s: %w", symbol, err) + } + for _, o := range orders { + if _, err := t.doDelete("/v2/orders/" + o.OrderID); err != nil { + logger.Warnf("[Alpaca] cancel order %s: %v", o.OrderID, err) + } + } + return nil } // CancelStopOrders cancels both stop and limit orders for a symbol @@ -472,10 +492,54 @@ func (t *AlpacaTrader) GetOrderStatus(symbol string, orderID string) (map[string }, nil } -// GetClosedPnL returns closed position records (Alpaca doesn't have a direct endpoint for this) +// GetClosedPnL returns closed position records from Alpaca's closed orders. +// Alpaca doesn't track PnL directly, so we reconstruct from filled sell orders. func (t *AlpacaTrader) GetClosedPnL(startTime time.Time, limit int) ([]ClosedPnLRecord, error) { - // Alpaca tracks activities, not PnL directly. Return empty for now. - return nil, nil + path := fmt.Sprintf("/v2/orders?status=closed&direction=desc&limit=%d&after=%s", + limit, startTime.Format(time.RFC3339)) + + data, err := t.doGet(path) + if err != nil { + return nil, fmt.Errorf("get closed orders: %w", err) + } + + var orders []AlpacaOrder + if err := json.Unmarshal(data, &orders); err != nil { + return nil, fmt.Errorf("parse closed orders: %w", err) + } + + var records []ClosedPnLRecord + for _, o := range orders { + // Only include filled sell orders (closing a long position) + if o.Status != "filled" || o.Side != "sell" { + continue + } + + filledQty := parseFloatStr(o.FilledQty) + filledPrice := parseFloatStr(o.FilledAvgPrice) + if filledQty <= 0 || filledPrice <= 0 { + continue + } + + closeTime, _ := time.Parse(time.RFC3339Nano, o.FilledAt) + if closeTime.IsZero() { + closeTime, _ = time.Parse(time.RFC3339Nano, o.UpdatedAt) + } + + records = append(records, ClosedPnLRecord{ + Symbol: o.Symbol, + Side: "long", // Sell orders close long positions + ExitPrice: filledPrice, + Quantity: filledQty, + ExitTime: closeTime, + OrderID: o.ID, + CloseType: "manual", + Fee: 0, // Alpaca is commission-free for most stocks + Leverage: 1, + }) + } + + return records, nil } // GetOpenOrders returns open orders @@ -515,6 +579,29 @@ func (t *AlpacaTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) { return result, nil } +// IsMarketOpen checks Alpaca's clock endpoint to determine if the market is open. +func (t *AlpacaTrader) IsMarketOpen() (bool, string, error) { + data, err := t.doGet("/v2/clock") + if err != nil { + return false, "", fmt.Errorf("get clock: %w", err) + } + + var clock struct { + IsOpen bool `json:"is_open"` + NextOpen string `json:"next_open"` + NextClose string `json:"next_close"` + } + if err := json.Unmarshal(data, &clock); err != nil { + return false, "", fmt.Errorf("parse clock: %w", err) + } + + status := "open" + if !clock.IsOpen { + status = fmt.Sprintf("closed (opens %s)", clock.NextOpen) + } + return clock.IsOpen, status, nil +} + // --- Helper: cancel orders by type --- func (t *AlpacaTrader) cancelOrdersByType(symbol, orderType string) error { diff --git a/trader/auto_trader_loop.go b/trader/auto_trader_loop.go index 2190ffc4..4a949654 100644 --- a/trader/auto_trader_loop.go +++ b/trader/auto_trader_loop.go @@ -6,6 +6,7 @@ import ( "nofx/kernel" "nofx/logger" "nofx/store" + "nofx/trader/alpaca" "nofx/wallet" "strings" "time" @@ -33,6 +34,19 @@ func (at *AutoTrader) runCycle() error { at.checkClaw402Balance() } + // Check market hours for stock exchanges (Alpaca) + if at.exchange == "alpaca" { + if alpacaTrader, ok := at.trader.(*alpaca.AlpacaTrader); ok { + isOpen, status, err := alpacaTrader.IsMarketOpen() + if err != nil { + logger.Warnf("⚠️ [%s] Failed to check market clock: %v", at.name, err) + } else if !isOpen { + logger.Infof("🔒 [%s] US stock market %s — skipping trading cycle #%d", at.name, status, at.callCount) + return nil + } + } + } + // Create decision record record := &store.DecisionRecord{ ExecutionLog: []string{},