mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-07 04:50:57 +08:00
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:
@@ -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{
|
||||
|
||||
@@ -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 = "📉"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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{},
|
||||
|
||||
Reference in New Issue
Block a user