mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-17 17:34:39 +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{
|
positions = append(positions, map[string]any{
|
||||||
"trader": tid,
|
"trader": tid,
|
||||||
|
"exchange": t.GetExchange(),
|
||||||
"symbol": p["symbol"],
|
"symbol": p["symbol"],
|
||||||
"side": p["side"],
|
"side": p["side"],
|
||||||
"size": size,
|
"size": size,
|
||||||
@@ -339,11 +340,20 @@ func (a *Agent) toolGetMarketPrice(argsJSON string) string {
|
|||||||
return `{"error": "no trader manager configured"}`
|
return `{"error": "no trader manager configured"}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
wantStock := isStockSymbol(sym)
|
||||||
for _, t := range a.traderManager.GetAllTraders() {
|
for _, t := range a.traderManager.GetAllTraders() {
|
||||||
underlying := t.GetUnderlyingTrader()
|
underlying := t.GetUnderlyingTrader()
|
||||||
if underlying == nil {
|
if underlying == nil {
|
||||||
continue
|
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)
|
price, err := underlying.GetMarketPrice(sym)
|
||||||
if err == nil && price > 0 {
|
if err == nil && price > 0 {
|
||||||
result, _ := json.Marshal(map[string]any{
|
result, _ := json.Marshal(map[string]any{
|
||||||
|
|||||||
@@ -112,7 +112,8 @@ func parseTradeCommand(text string) *TradeAction {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
symbol = words[1]
|
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"
|
symbol += "USDT"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -226,7 +227,10 @@ func formatTradeConfirmation(trade *TradeAction, lang string) string {
|
|||||||
"close_short": "平空 (Close Short)",
|
"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]
|
actionName := actionNames[trade.Action]
|
||||||
if actionName == "" {
|
if actionName == "" {
|
||||||
actionName = trade.Action
|
actionName = trade.Action
|
||||||
@@ -308,7 +312,10 @@ func (a *Agent) handleTradeConfirmation(ctx context.Context, userID int64, text,
|
|||||||
}
|
}
|
||||||
|
|
||||||
trade.Status = "executed"
|
trade.Status = "executed"
|
||||||
symbol := strings.TrimSuffix(trade.Symbol, "USDT")
|
symbol := trade.Symbol
|
||||||
|
if strings.HasSuffix(symbol, "USDT") {
|
||||||
|
symbol = strings.TrimSuffix(symbol, "USDT")
|
||||||
|
}
|
||||||
actionEmoji := "📈"
|
actionEmoji := "📈"
|
||||||
if strings.Contains(trade.Action, "short") {
|
if strings.Contains(trade.Action, "short") {
|
||||||
actionEmoji = "📉"
|
actionEmoji = "📉"
|
||||||
|
|||||||
@@ -183,8 +183,12 @@ func (t *AlpacaTrader) GetBalance() (map[string]interface{}, error) {
|
|||||||
buyingPower := parseFloatStr(acct.BuyingPower)
|
buyingPower := parseFloatStr(acct.BuyingPower)
|
||||||
|
|
||||||
result := map[string]interface{}{
|
result := map[string]interface{}{
|
||||||
"total_equity": equity,
|
// Standard fields expected by auto_trader (camelCase)
|
||||||
"available_balance": cash,
|
"totalEquity": equity,
|
||||||
|
"totalWalletBalance": cash,
|
||||||
|
"availableBalance": cash,
|
||||||
|
"totalUnrealizedProfit": equity - cash,
|
||||||
|
// Alpaca-specific fields
|
||||||
"buying_power": buyingPower,
|
"buying_power": buyingPower,
|
||||||
"currency": acct.Currency,
|
"currency": acct.Currency,
|
||||||
"status": acct.Status,
|
"status": acct.Status,
|
||||||
@@ -230,15 +234,17 @@ func (t *AlpacaTrader) GetPositions() ([]map[string]interface{}, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
result = append(result, map[string]interface{}{
|
result = append(result, map[string]interface{}{
|
||||||
"symbol": p.Symbol,
|
"symbol": p.Symbol,
|
||||||
"side": side,
|
"side": side,
|
||||||
"size": qty,
|
"size": qty,
|
||||||
"entryPrice": parseFloatStr(p.AvgEntryPrice),
|
"positionAmt": qty, // Standard field expected by auto_trader
|
||||||
"markPrice": parseFloatStr(p.CurrentPrice),
|
"entryPrice": parseFloatStr(p.AvgEntryPrice),
|
||||||
"unrealizedPnl": parseFloatStr(p.UnrealizedPL),
|
"markPrice": parseFloatStr(p.CurrentPrice),
|
||||||
"marketValue": parseFloatStr(p.MarketValue),
|
"unrealizedPnl": parseFloatStr(p.UnrealizedPL),
|
||||||
"leverage": 1,
|
"unRealizedProfit": parseFloatStr(p.UnrealizedPL), // Standard field
|
||||||
"exchange": "alpaca",
|
"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")
|
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 {
|
func (t *AlpacaTrader) CancelAllOrders(symbol string) error {
|
||||||
_, err := t.doDelete("/v2/orders")
|
if symbol == "" {
|
||||||
return err
|
_, 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
|
// 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
|
}, 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) {
|
func (t *AlpacaTrader) GetClosedPnL(startTime time.Time, limit int) ([]ClosedPnLRecord, error) {
|
||||||
// Alpaca tracks activities, not PnL directly. Return empty for now.
|
path := fmt.Sprintf("/v2/orders?status=closed&direction=desc&limit=%d&after=%s",
|
||||||
return nil, nil
|
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
|
// GetOpenOrders returns open orders
|
||||||
@@ -515,6 +579,29 @@ func (t *AlpacaTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) {
|
|||||||
return result, nil
|
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 ---
|
// --- Helper: cancel orders by type ---
|
||||||
|
|
||||||
func (t *AlpacaTrader) cancelOrdersByType(symbol, orderType string) error {
|
func (t *AlpacaTrader) cancelOrdersByType(symbol, orderType string) error {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"nofx/kernel"
|
"nofx/kernel"
|
||||||
"nofx/logger"
|
"nofx/logger"
|
||||||
"nofx/store"
|
"nofx/store"
|
||||||
|
"nofx/trader/alpaca"
|
||||||
"nofx/wallet"
|
"nofx/wallet"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -33,6 +34,19 @@ func (at *AutoTrader) runCycle() error {
|
|||||||
at.checkClaw402Balance()
|
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
|
// Create decision record
|
||||||
record := &store.DecisionRecord{
|
record := &store.DecisionRecord{
|
||||||
ExecutionLog: []string{},
|
ExecutionLog: []string{},
|
||||||
|
|||||||
Reference in New Issue
Block a user