fix: Alpaca integration bugs — field mapping, market hours, stock symbol handling

- 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
This commit is contained in:
shinchan-zhai
2026-03-23 19:05:25 +08:00
parent d0aebe9f18
commit bbaa109223
4 changed files with 138 additions and 20 deletions

View File

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

View File

@@ -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 = "📉"

View File

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

View File

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