diff --git a/agent/agent.go b/agent/agent.go index 6a0e20bf..c55b7c8d 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -475,10 +475,10 @@ func (a *Agent) buildSystemPrompt(lang string) string { ## 工具使用 你可以调用以下工具来执行操作: - **search_stock** — 搜索股票(支持中文名、英文名、代码)。当用户提到你不认识的股票时,先用这个工具搜索。 -- **execute_trade** — 下单交易(做多/做空/平多/平空)。调用后会创建待确认订单,用户需回复"确认 trade_xxx"才会真正执行。 -- **get_positions** — 查看当前所有持仓 +- **execute_trade** — 下单交易(加密货币或美股)。美股:open_long=买入,close_long=卖出。调用后创建待确认订单,用户需回复"确认 trade_xxx"。 +- **get_positions** — 查看当前所有持仓(加密货币 + 股票) - **get_balance** — 查看账户余额 -- **get_market_price** — 获取交易所实时价格 +- **get_market_price** — 获取实时价格(加密货币或股票代码) ### 交易安全规则 - 用户明确要求交易时才调用 execute_trade @@ -536,10 +536,10 @@ func (a *Agent) buildSystemPrompt(lang string) string { ## Tools You can call these tools to take action: - **search_stock** — Search for stocks by name, ticker, or code. Covers A-share, HK, and US markets. Use when the user mentions an unknown stock. -- **execute_trade** — Place a trade order (open_long/open_short/close_long/close_short). Creates a pending order that requires user confirmation. -- **get_positions** — View all current open positions +- **execute_trade** — Place a trade order (crypto or US stocks). For stocks: open_long=buy, close_long=sell. Creates a pending order that requires user confirmation. +- **get_positions** — View all current open positions (crypto + stocks) - **get_balance** — View account balance and equity -- **get_market_price** — Get real-time price from the exchange +- **get_market_price** — Get real-time price from the exchange (crypto or stock symbol) ### Trade Safety Rules - Only call execute_trade when user explicitly requests a trade diff --git a/agent/tools.go b/agent/tools.go index 3640b885..4cfab0c2 100644 --- a/agent/tools.go +++ b/agent/tools.go @@ -40,7 +40,7 @@ func buildAgentTools() []mcp.Tool { Type: "function", Function: mcp.FunctionDef{ Name: "execute_trade", - Description: "Execute a trade order. Use this when the user explicitly asks to open/close a position. This will create a pending trade that requires user confirmation before execution.", + Description: "Execute a trade order (crypto or US stocks). Use this when the user explicitly asks to open/close a position. For stocks (e.g. AAPL, TSLA), use open_long to buy and close_long to sell. This creates a pending trade that requires user confirmation.", Parameters: map[string]any{ "type": "object", "properties": map[string]any{ @@ -51,7 +51,7 @@ func buildAgentTools() []mcp.Tool { }, "symbol": map[string]any{ "type": "string", - "description": "Trading symbol, e.g. BTCUSDT, ETHUSDT. Always append USDT if not present.", + "description": "Trading symbol. For crypto: BTCUSDT, ETHUSDT. For US stocks: AAPL, TSLA, NVDA (no suffix needed).", }, "quantity": map[string]any{ "type": "number", @@ -86,13 +86,13 @@ func buildAgentTools() []mcp.Tool { Type: "function", Function: mcp.FunctionDef{ Name: "get_market_price", - Description: "Get the current market price for a symbol from the trader's exchange.", + Description: "Get the current market price for a crypto or stock symbol.", Parameters: map[string]any{ "type": "object", "properties": map[string]any{ "symbol": map[string]any{ "type": "string", - "description": "Trading symbol, e.g. BTCUSDT", + "description": "Trading symbol, e.g. BTCUSDT for crypto, AAPL for stocks", }, }, "required": []string{"symbol"}, @@ -205,7 +205,8 @@ func (a *Agent) toolExecuteTrade(_ context.Context, userID int64, lang, argsJSON // Normalize symbol sym := strings.ToUpper(args.Symbol) - if !strings.HasSuffix(sym, "USDT") { + // Only append USDT for crypto symbols; stock tickers (e.g. AAPL, TSLA) stay as-is + if !isStockSymbol(sym) && !strings.HasSuffix(sym, "USDT") { sym += "USDT" } @@ -330,7 +331,7 @@ func (a *Agent) toolGetMarketPrice(argsJSON string) string { } sym := strings.ToUpper(args.Symbol) - if !strings.HasSuffix(sym, "USDT") { + if !isStockSymbol(sym) && !strings.HasSuffix(sym, "USDT") { sym += "USDT" } @@ -459,3 +460,31 @@ func (a *Agent) toolGetTradeHistory(argsJSON string) string { }) return string(result) } + +// isStockSymbol heuristically determines if a symbol is a stock ticker (not crypto). +// Stock tickers are 1-5 uppercase letters without numeric suffixes like "USDT". +// Known crypto suffixes: USDT, BTC, ETH, BNB, USDC, BUSD. +func isStockSymbol(sym string) bool { + sym = strings.ToUpper(sym) + // If it already has a crypto quote suffix, it's crypto + cryptoSuffixes := []string{"USDT", "BUSD", "USDC", "BTC", "ETH", "BNB"} + for _, suffix := range cryptoSuffixes { + if strings.HasSuffix(sym, suffix) && len(sym) > len(suffix) { + return false + } + } + // Pure uppercase letters, 1-5 chars = likely a stock ticker + if len(sym) >= 1 && len(sym) <= 5 { + allLetters := true + for _, c := range sym { + if c < 'A' || c > 'Z' { + allLetters = false + break + } + } + if allLetters { + return true + } + } + return false +} diff --git a/agent/trade.go b/agent/trade.go index 85f1d9ec..c5eb45f4 100644 --- a/agent/trade.go +++ b/agent/trade.go @@ -153,6 +153,9 @@ func (a *Agent) executeTrade(ctx context.Context, trade *TradeAction) error { return fmt.Errorf("no traders configured") } + // Determine if this is a stock trade to route to the right exchange + wantStock := isStockSymbol(trade.Symbol) + // Find a running trader's underlying exchange interface var underlyingTrader interface { OpenLong(symbol string, quantity float64, leverage int) (map[string]interface{}, error) @@ -169,12 +172,24 @@ func (a *Agent) executeTrade(ctx context.Context, trade *TradeAction) error { if ut == nil { continue } + // Route stock symbols to alpaca traders, crypto to others + exchange := t.GetExchange() + isAlpaca := exchange == "alpaca" + if wantStock && !isAlpaca { + continue // Skip non-stock traders for stock symbols + } + if !wantStock && isAlpaca { + continue // Skip stock traders for crypto symbols + } underlyingTrader = ut break } } if underlyingTrader == nil { + if wantStock { + return fmt.Errorf("no running stock trader (Alpaca) found — configure one to trade stocks") + } return fmt.Errorf("no running trader supports trade execution") } diff --git a/api/handler_trader.go b/api/handler_trader.go index 8dfbb07f..7f98d213 100644 --- a/api/handler_trader.go +++ b/api/handler_trader.go @@ -10,6 +10,7 @@ import ( "nofx/safe" "nofx/store" "nofx/trader" + alpacatrader "nofx/trader/alpaca" "nofx/trader/aster" "nofx/trader/binance" "nofx/trader/bitget" @@ -217,6 +218,12 @@ func (s *Server) handleCreateTrader(c *gin.Context) { } else { createErr = fmt.Errorf("Lighter requires wallet address and API Key private key") } + case "alpaca": + tempTrader = alpacatrader.NewAlpacaTrader( + string(exchangeCfg.APIKey), + string(exchangeCfg.SecretKey), + exchangeCfg.Testnet, + ) default: logger.Infof("⚠️ Unsupported exchange type: %s, using user input for initial balance", exchangeCfg.ExchangeType) } diff --git a/manager/trader_manager.go b/manager/trader_manager.go index bed8f0a6..204b47b6 100644 --- a/manager/trader_manager.go +++ b/manager/trader_manager.go @@ -704,6 +704,10 @@ func (tm *TraderManager) addTraderFromStore(traderCfg *store.Trader, aiModelCfg case "indodax": traderConfig.IndodaxAPIKey = string(exchangeCfg.APIKey) traderConfig.IndodaxSecretKey = string(exchangeCfg.SecretKey) + case "alpaca": + traderConfig.AlpacaAPIKey = string(exchangeCfg.APIKey) + traderConfig.AlpacaAPISecret = string(exchangeCfg.SecretKey) + traderConfig.AlpacaPaper = exchangeCfg.Testnet // Reuse Testnet field for paper/live toggle } // Set API keys based on AI model (convert EncryptedString to string) diff --git a/trader/alpaca/trader.go b/trader/alpaca/trader.go new file mode 100644 index 00000000..892f0b07 --- /dev/null +++ b/trader/alpaca/trader.go @@ -0,0 +1,558 @@ +package alpaca + +import ( + "encoding/json" + "fmt" + "net/http" + "nofx/logger" + "nofx/safe" + "strconv" + "strings" + "sync" + "time" +) + +// Alpaca API endpoints +const ( + alpacaPaperBaseURL = "https://paper-api.alpaca.markets" + alpacaLiveBaseURL = "https://api.alpaca.markets" + alpacaDataBaseURL = "https://data.alpaca.markets" +) + +// AlpacaTrader implements types.Trader for Alpaca US stock trading. +// Maps the crypto-oriented Trader interface to stock operations: +// - OpenLong → Buy shares (leverage ignored, always 1) +// - CloseLong → Sell shares +// - OpenShort/CloseShort → Not supported (requires margin account) +// - GetPositions, GetBalance, GetMarketPrice → Direct mapping +type AlpacaTrader struct { + apiKey string + apiSecret string + baseURL string // paper or live + client *http.Client + + // Cache + cachedBalance map[string]interface{} + cachedPositions []map[string]interface{} + balanceCacheTime time.Time + positionCacheTime time.Time + cacheDuration time.Duration + cacheMutex sync.RWMutex +} + +// NewAlpacaTrader creates a new Alpaca trader for paper trading +func NewAlpacaTrader(apiKey, apiSecret string, paper bool) *AlpacaTrader { + baseURL := alpacaLiveBaseURL + if paper { + baseURL = alpacaPaperBaseURL + } + return &AlpacaTrader{ + apiKey: apiKey, + apiSecret: apiSecret, + baseURL: baseURL, + client: &http.Client{Timeout: 30 * time.Second}, + cacheDuration: 10 * time.Second, + } +} + +// --- HTTP helpers --- + +func (t *AlpacaTrader) doRequest(method, path string, body interface{}) ([]byte, int, error) { + var reqBody *strings.Reader + if body != nil { + data, err := json.Marshal(body) + if err != nil { + return nil, 0, fmt.Errorf("marshal body: %w", err) + } + reqBody = strings.NewReader(string(data)) + } else { + reqBody = strings.NewReader("") + } + + url := t.baseURL + path + req, err := http.NewRequest(method, url, reqBody) + if err != nil { + return nil, 0, fmt.Errorf("create request: %w", err) + } + + req.Header.Set("APCA-API-KEY-ID", t.apiKey) + req.Header.Set("APCA-API-SECRET-KEY", t.apiSecret) + req.Header.Set("Content-Type", "application/json") + + resp, err := t.client.Do(req) + if err != nil { + return nil, 0, fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + respData, err := safe.ReadAllLimited(resp.Body) + if err != nil { + return nil, resp.StatusCode, fmt.Errorf("read response: %w", err) + } + + return respData, resp.StatusCode, nil +} + +func (t *AlpacaTrader) doGet(path string) ([]byte, error) { + data, status, err := t.doRequest("GET", path, nil) + if err != nil { + return nil, err + } + if status < 200 || status >= 300 { + return nil, fmt.Errorf("Alpaca API error (HTTP %d): %s", status, truncate(string(data), 256)) + } + return data, nil +} + +func (t *AlpacaTrader) doPost(path string, body interface{}) ([]byte, error) { + data, status, err := t.doRequest("POST", path, body) + if err != nil { + return nil, err + } + if status < 200 || status >= 300 { + return nil, fmt.Errorf("Alpaca API error (HTTP %d): %s", status, truncate(string(data), 256)) + } + return data, nil +} + +func (t *AlpacaTrader) doDelete(path string) ([]byte, error) { + data, status, err := t.doRequest("DELETE", path, nil) + if err != nil { + return nil, err + } + // 204 No Content is success for DELETE + if status < 200 || status >= 300 { + return nil, fmt.Errorf("Alpaca API error (HTTP %d): %s", status, truncate(string(data), 256)) + } + return data, nil +} + +// doDataGet makes a GET to the data API (for market data) +func (t *AlpacaTrader) doDataGet(path string) ([]byte, error) { + url := alpacaDataBaseURL + path + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, fmt.Errorf("create request: %w", err) + } + + req.Header.Set("APCA-API-KEY-ID", t.apiKey) + req.Header.Set("APCA-API-SECRET-KEY", t.apiSecret) + + resp, err := t.client.Do(req) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + data, err := safe.ReadAllLimited(resp.Body) + if err != nil { + return nil, fmt.Errorf("read response: %w", err) + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("Alpaca Data API error (HTTP %d): %s", resp.StatusCode, truncate(string(data), 256)) + } + + return data, nil +} + +// --- Trader interface implementation --- + +// GetBalance returns account balance info +func (t *AlpacaTrader) GetBalance() (map[string]interface{}, error) { + t.cacheMutex.RLock() + if t.cachedBalance != nil && time.Since(t.balanceCacheTime) < t.cacheDuration { + result := t.cachedBalance + t.cacheMutex.RUnlock() + return result, nil + } + t.cacheMutex.RUnlock() + + data, err := t.doGet("/v2/account") + if err != nil { + return nil, fmt.Errorf("get account: %w", err) + } + + var acct AlpacaAccount + if err := json.Unmarshal(data, &acct); err != nil { + return nil, fmt.Errorf("parse account: %w", err) + } + + equity := parseFloatStr(acct.Equity) + cash := parseFloatStr(acct.Cash) + buyingPower := parseFloatStr(acct.BuyingPower) + + result := map[string]interface{}{ + "total_equity": equity, + "available_balance": cash, + "buying_power": buyingPower, + "currency": acct.Currency, + "status": acct.Status, + "account_number": acct.AccountNumber, + "pattern_day_trader": acct.PatternDayTrader, + "day_trade_count": acct.DaytradeCount, + } + + t.cacheMutex.Lock() + t.cachedBalance = result + t.balanceCacheTime = time.Now() + t.cacheMutex.Unlock() + + return result, nil +} + +// GetPositions returns all open positions +func (t *AlpacaTrader) GetPositions() ([]map[string]interface{}, error) { + t.cacheMutex.RLock() + if t.cachedPositions != nil && time.Since(t.positionCacheTime) < t.cacheDuration { + result := t.cachedPositions + t.cacheMutex.RUnlock() + return result, nil + } + t.cacheMutex.RUnlock() + + data, err := t.doGet("/v2/positions") + if err != nil { + return nil, fmt.Errorf("get positions: %w", err) + } + + var positions []AlpacaPosition + if err := json.Unmarshal(data, &positions); err != nil { + return nil, fmt.Errorf("parse positions: %w", err) + } + + var result []map[string]interface{} + for _, p := range positions { + qty := parseFloatStr(p.Qty) + side := "long" + if p.Side == "short" { + side = "short" + } + + 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", + }) + } + + t.cacheMutex.Lock() + t.cachedPositions = result + t.positionCacheTime = time.Now() + t.cacheMutex.Unlock() + + return result, nil +} + +// OpenLong buys shares (market order) +func (t *AlpacaTrader) OpenLong(symbol string, quantity float64, leverage int) (map[string]interface{}, error) { + logger.Infof("[Alpaca] BUY %s qty=%.4f", symbol, quantity) + + order := AlpacaOrderRequest{ + Symbol: symbol, + Qty: fmt.Sprintf("%.4f", quantity), // Alpaca supports fractional shares + Side: "buy", + Type: "market", + TimeInForce: "day", + } + + data, err := t.doPost("/v2/orders", order) + if err != nil { + return nil, fmt.Errorf("buy %s: %w", symbol, err) + } + + var resp AlpacaOrder + if err := json.Unmarshal(data, &resp); err != nil { + return nil, fmt.Errorf("parse order response: %w", err) + } + + t.clearCache() + + return map[string]interface{}{ + "orderId": resp.ID, + "clientId": resp.ClientOrderID, + "symbol": resp.Symbol, + "side": resp.Side, + "qty": resp.Qty, + "type": resp.Type, + "status": resp.Status, + }, nil +} + +// OpenShort is not supported for basic stock accounts +func (t *AlpacaTrader) OpenShort(symbol string, quantity float64, leverage int) (map[string]interface{}, error) { + return nil, fmt.Errorf("short selling not supported on Alpaca basic account") +} + +// CloseLong sells shares (market order). quantity=0 means close entire position. +func (t *AlpacaTrader) CloseLong(symbol string, quantity float64) (map[string]interface{}, error) { + if quantity == 0 { + // Close entire position via DELETE endpoint + logger.Infof("[Alpaca] CLOSE ALL %s", symbol) + data, err := t.doDelete("/v2/positions/" + symbol) + if err != nil { + return nil, fmt.Errorf("close position %s: %w", symbol, err) + } + + var resp AlpacaOrder + if err := json.Unmarshal(data, &resp); err != nil { + return nil, fmt.Errorf("parse close response: %w", err) + } + + t.clearCache() + return map[string]interface{}{ + "orderId": resp.ID, + "symbol": resp.Symbol, + "status": resp.Status, + }, nil + } + + // Partial close via sell order + logger.Infof("[Alpaca] SELL %s qty=%.4f", symbol, quantity) + order := AlpacaOrderRequest{ + Symbol: symbol, + Qty: fmt.Sprintf("%.4f", quantity), + Side: "sell", + Type: "market", + TimeInForce: "day", + } + + data, err := t.doPost("/v2/orders", order) + if err != nil { + return nil, fmt.Errorf("sell %s: %w", symbol, err) + } + + var resp AlpacaOrder + if err := json.Unmarshal(data, &resp); err != nil { + return nil, fmt.Errorf("parse order response: %w", err) + } + + t.clearCache() + return map[string]interface{}{ + "orderId": resp.ID, + "symbol": resp.Symbol, + "side": resp.Side, + "qty": resp.Qty, + "status": resp.Status, + }, nil +} + +// CloseShort is not supported +func (t *AlpacaTrader) CloseShort(symbol string, quantity float64) (map[string]interface{}, error) { + return nil, fmt.Errorf("short selling not supported on Alpaca basic account") +} + +// SetLeverage is a no-op for stocks (always 1x) +func (t *AlpacaTrader) SetLeverage(symbol string, leverage int) error { + // Stocks don't have configurable leverage + return nil +} + +// SetMarginMode is a no-op for stocks +func (t *AlpacaTrader) SetMarginMode(symbol string, isCrossMargin bool) error { + return nil +} + +// GetMarketPrice returns the latest trade price for a symbol +func (t *AlpacaTrader) GetMarketPrice(symbol string) (float64, error) { + // Use Alpaca's latest trade endpoint + data, err := t.doDataGet("/v2/stocks/" + symbol + "/trades/latest") + if err != nil { + return 0, fmt.Errorf("get price %s: %w", symbol, err) + } + + var resp struct { + Trade struct { + Price float64 `json:"p"` + } `json:"trade"` + } + if err := json.Unmarshal(data, &resp); err != nil { + return 0, fmt.Errorf("parse price: %w", err) + } + + if resp.Trade.Price <= 0 { + return 0, fmt.Errorf("no price data for %s", symbol) + } + + return resp.Trade.Price, nil +} + +// SetStopLoss places a stop order +func (t *AlpacaTrader) SetStopLoss(symbol string, positionSide string, quantity, stopPrice float64) error { + side := "sell" // stop loss for long = sell when price drops + if positionSide == "SHORT" { + side = "buy" + } + + order := AlpacaOrderRequest{ + Symbol: symbol, + Qty: fmt.Sprintf("%.4f", quantity), + Side: side, + Type: "stop", + TimeInForce: "gtc", + StopPrice: fmt.Sprintf("%.2f", stopPrice), + } + + _, err := t.doPost("/v2/orders", order) + return err +} + +// SetTakeProfit places a limit order as take-profit +func (t *AlpacaTrader) SetTakeProfit(symbol string, positionSide string, quantity, takeProfitPrice float64) error { + side := "sell" // take profit for long = sell when price rises + if positionSide == "SHORT" { + side = "buy" + } + + order := AlpacaOrderRequest{ + Symbol: symbol, + Qty: fmt.Sprintf("%.4f", quantity), + Side: side, + Type: "limit", + TimeInForce: "gtc", + LimitPrice: fmt.Sprintf("%.2f", takeProfitPrice), + } + + _, err := t.doPost("/v2/orders", order) + return err +} + +// CancelStopLossOrders cancels stop orders for a symbol +func (t *AlpacaTrader) CancelStopLossOrders(symbol string) error { + return t.cancelOrdersByType(symbol, "stop") +} + +// CancelTakeProfitOrders cancels limit orders (used as take-profit) for a symbol +func (t *AlpacaTrader) CancelTakeProfitOrders(symbol string) error { + return t.cancelOrdersByType(symbol, "limit") +} + +// CancelAllOrders cancels all pending orders for a symbol +func (t *AlpacaTrader) CancelAllOrders(symbol string) error { + _, err := t.doDelete("/v2/orders") + return err +} + +// CancelStopOrders cancels both stop and limit orders for a symbol +func (t *AlpacaTrader) CancelStopOrders(symbol string) error { + if err := t.CancelStopLossOrders(symbol); err != nil { + logger.Warnf("[Alpaca] cancel stop loss orders: %v", err) + } + return t.CancelTakeProfitOrders(symbol) +} + +// FormatQuantity formats quantity (Alpaca supports fractional shares to 4 decimals) +func (t *AlpacaTrader) FormatQuantity(symbol string, quantity float64) (string, error) { + return fmt.Sprintf("%.4f", quantity), nil +} + +// GetOrderStatus returns the status of an order +func (t *AlpacaTrader) GetOrderStatus(symbol string, orderID string) (map[string]interface{}, error) { + data, err := t.doGet("/v2/orders/" + orderID) + if err != nil { + return nil, fmt.Errorf("get order %s: %w", orderID, err) + } + + var order AlpacaOrder + if err := json.Unmarshal(data, &order); err != nil { + return nil, fmt.Errorf("parse order: %w", err) + } + + return map[string]interface{}{ + "status": strings.ToUpper(order.Status), + "avgPrice": parseFloatStr(order.FilledAvgPrice), + "executedQty": parseFloatStr(order.FilledQty), + "commission": 0.0, // Alpaca is commission-free + }, nil +} + +// GetClosedPnL returns closed position records (Alpaca doesn't have a direct endpoint for this) +func (t *AlpacaTrader) GetClosedPnL(startTime time.Time, limit int) ([]ClosedPnLRecord, error) { + // Alpaca tracks activities, not PnL directly. Return empty for now. + return nil, nil +} + +// GetOpenOrders returns open orders +func (t *AlpacaTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) { + path := "/v2/orders?status=open" + if symbol != "" { + path += "&symbols=" + symbol + } + + data, err := t.doGet(path) + if err != nil { + return nil, fmt.Errorf("get open orders: %w", err) + } + + var orders []AlpacaOrder + if err := json.Unmarshal(data, &orders); err != nil { + return nil, fmt.Errorf("parse orders: %w", err) + } + + var result []OpenOrder + for _, o := range orders { + oo := OpenOrder{ + OrderID: o.ID, + Symbol: o.Symbol, + Side: strings.ToUpper(o.Side), + Type: strings.ToUpper(o.Type), + Price: parseFloatStr(o.LimitPrice), + Quantity: parseFloatStr(o.Qty), + Status: strings.ToUpper(o.Status), + } + if o.StopPrice != "" { + oo.StopPrice = parseFloatStr(o.StopPrice) + } + result = append(result, oo) + } + + return result, nil +} + +// --- Helper: cancel orders by type --- + +func (t *AlpacaTrader) cancelOrdersByType(symbol, orderType string) error { + orders, err := t.GetOpenOrders(symbol) + if err != nil { + return err + } + + for _, o := range orders { + if strings.EqualFold(o.Type, orderType) && (symbol == "" || o.Symbol == symbol) { + if _, err := t.doDelete("/v2/orders/" + o.OrderID); err != nil { + logger.Warnf("[Alpaca] cancel order %s: %v", o.OrderID, err) + } + } + } + return nil +} + +func (t *AlpacaTrader) clearCache() { + t.cacheMutex.Lock() + defer t.cacheMutex.Unlock() + t.cachedBalance = nil + t.cachedPositions = nil +} + +// --- Helpers --- + +func truncate(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] + "..." +} + +func parseFloatStr(s string) float64 { + if s == "" { + return 0 + } + f, _ := strconv.ParseFloat(s, 64) + return f +} diff --git a/trader/alpaca/types.go b/trader/alpaca/types.go new file mode 100644 index 00000000..f14e8c5e --- /dev/null +++ b/trader/alpaca/types.go @@ -0,0 +1,74 @@ +package alpaca + +import "nofx/trader/types" + +// Re-export for convenience +type ( + ClosedPnLRecord = types.ClosedPnLRecord + OpenOrder = types.OpenOrder +) + +// AlpacaAccount represents Alpaca account info +type AlpacaAccount struct { + ID string `json:"id"` + AccountNumber string `json:"account_number"` + Status string `json:"status"` + Currency string `json:"currency"` + Cash string `json:"cash"` + Equity string `json:"equity"` + BuyingPower string `json:"buying_power"` + PortfolioValue string `json:"portfolio_value"` + PatternDayTrader bool `json:"pattern_day_trader"` + DaytradeCount int `json:"daytrade_count"` + TradingBlocked bool `json:"trading_blocked"` +} + +// AlpacaPosition represents an open position +type AlpacaPosition struct { + AssetID string `json:"asset_id"` + Symbol string `json:"symbol"` + Exchange string `json:"exchange"` + AssetClass string `json:"asset_class"` + AvgEntryPrice string `json:"avg_entry_price"` + Qty string `json:"qty"` + Side string `json:"side"` + MarketValue string `json:"market_value"` + CostBasis string `json:"cost_basis"` + UnrealizedPL string `json:"unrealized_pl"` + UnrealizedPLPC string `json:"unrealized_plpc"` + CurrentPrice string `json:"current_price"` + LastdayPrice string `json:"lastday_price"` + ChangeToday string `json:"change_today"` +} + +// AlpacaOrderRequest represents an order submission +type AlpacaOrderRequest struct { + Symbol string `json:"symbol"` + Qty string `json:"qty,omitempty"` + Notional string `json:"notional,omitempty"` // Dollar amount instead of qty + Side string `json:"side"` // buy, sell + Type string `json:"type"` // market, limit, stop, stop_limit + TimeInForce string `json:"time_in_force"` // day, gtc, opg, cls, ioc, fok + LimitPrice string `json:"limit_price,omitempty"` + StopPrice string `json:"stop_price,omitempty"` +} + +// AlpacaOrder represents an order response +type AlpacaOrder struct { + ID string `json:"id"` + ClientOrderID string `json:"client_order_id"` + Symbol string `json:"symbol"` + Side string `json:"side"` + Type string `json:"type"` + Qty string `json:"qty"` + FilledQty string `json:"filled_qty"` + FilledAvgPrice string `json:"filled_avg_price"` + LimitPrice string `json:"limit_price"` + StopPrice string `json:"stop_price"` + Status string `json:"status"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + SubmittedAt string `json:"submitted_at"` + FilledAt string `json:"filled_at"` + TimeInForce string `json:"time_in_force"` +} diff --git a/trader/auto_trader.go b/trader/auto_trader.go index 93d5cd86..b087f1d1 100644 --- a/trader/auto_trader.go +++ b/trader/auto_trader.go @@ -16,6 +16,7 @@ import ( "nofx/trader/bybit" "nofx/trader/gate" "nofx/trader/hyperliquid" + "nofx/trader/alpaca" "nofx/trader/indodax" "nofx/trader/kucoin" "nofx/trader/lighter" @@ -66,6 +67,11 @@ type AutoTraderConfig struct { IndodaxAPIKey string IndodaxSecretKey string + // Alpaca API configuration (US stock trading) + AlpacaAPIKey string + AlpacaAPISecret string + AlpacaPaper bool // true = paper trading, false = live + // Hyperliquid configuration HyperliquidPrivateKey string HyperliquidWalletAddr string @@ -286,6 +292,9 @@ func NewAutoTrader(config AutoTraderConfig, st *store.Store, userID string) (*Au case "indodax": logger.Infof("🏦 [%s] Using Indodax Spot trading", config.Name) trader = indodax.NewIndodaxTrader(config.IndodaxAPIKey, config.IndodaxSecretKey) + case "alpaca": + logger.Infof("🏦 [%s] Using Alpaca US Stock trading (paper=%v)", config.Name, config.AlpacaPaper) + trader = alpaca.NewAlpacaTrader(config.AlpacaAPIKey, config.AlpacaAPISecret, config.AlpacaPaper) default: return nil, fmt.Errorf("unsupported trading platform: %s", config.Exchange) } diff --git a/web/src/components/agent/PositionsPanel.tsx b/web/src/components/agent/PositionsPanel.tsx index 8d5746a0..a44df3c4 100644 --- a/web/src/components/agent/PositionsPanel.tsx +++ b/web/src/components/agent/PositionsPanel.tsx @@ -63,7 +63,11 @@ export function PositionsPanel() { const isProfit = pnl >= 0 const color = isProfit ? '#00e5a0' : '#F6465D' const side = pos.side?.toUpperCase() || (pos.quantity > 0 ? 'LONG' : 'SHORT') - const symbol = (pos.symbol || '').replace('USDT', '') + const rawSymbol = pos.symbol || '' + // Stock symbols are pure letters (1-5 chars), crypto has USDT suffix + const isStock = /^[A-Z]{1,5}$/.test(rawSymbol) && !rawSymbol.endsWith('USDT') + const symbol = isStock ? rawSymbol : rawSymbol.replace('USDT', '') + const currencyPrefix = isStock ? '$' : '' return (
{symbol} + {isStock && ( + 🇺🇸 + )} - {side} + {isStock ? (side === 'LONG' ? 'HOLD' : 'SHORT') : side}
)} {isProfit ? '+' : ''} - {pnl.toFixed(2)} + {currencyPrefix}{pnl.toFixed(2)}
- Qty: {pos.quantity} - Entry: {pos.entry_price.toFixed(2)} + {isStock ? 'Shares' : 'Qty'}: {pos.quantity} + Entry: {currencyPrefix}{pos.entry_price.toFixed(2)}
)