mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-14 16:26:57 +08:00
feat: Alpaca US stock trader integration (Tasks 8-11)
- trader/alpaca/: Full Trader interface implementation for Alpaca - Paper/Live trading support (market orders, stop/limit) - GetBalance, GetPositions, GetMarketPrice via Alpaca API - Fractional share support (4 decimal places) - Commission-free trading - Agent trade routing: stock symbols (AAPL, TSLA) → Alpaca - isStockSymbol() heuristic to distinguish crypto vs stock - execute_trade tool updated for stock buy/sell semantics - get_market_price works for both crypto and stocks - TraderManager + API: Alpaca registered as exchange type - Factory case in auto_trader.go - Config mapping in trader_manager.go - Temp trader creation in handler_trader.go for balance query - Frontend: PositionsPanel distinguishes stock vs crypto - US flag emoji for stock positions - Dollar prefix for stock PnL/prices - 'Shares' label instead of 'Qty' for stocks - System prompts updated for stock trading capabilities
This commit is contained in:
@@ -475,10 +475,10 @@ func (a *Agent) buildSystemPrompt(lang string) string {
|
|||||||
## 工具使用
|
## 工具使用
|
||||||
你可以调用以下工具来执行操作:
|
你可以调用以下工具来执行操作:
|
||||||
- **search_stock** — 搜索股票(支持中文名、英文名、代码)。当用户提到你不认识的股票时,先用这个工具搜索。
|
- **search_stock** — 搜索股票(支持中文名、英文名、代码)。当用户提到你不认识的股票时,先用这个工具搜索。
|
||||||
- **execute_trade** — 下单交易(做多/做空/平多/平空)。调用后会创建待确认订单,用户需回复"确认 trade_xxx"才会真正执行。
|
- **execute_trade** — 下单交易(加密货币或美股)。美股:open_long=买入,close_long=卖出。调用后创建待确认订单,用户需回复"确认 trade_xxx"。
|
||||||
- **get_positions** — 查看当前所有持仓
|
- **get_positions** — 查看当前所有持仓(加密货币 + 股票)
|
||||||
- **get_balance** — 查看账户余额
|
- **get_balance** — 查看账户余额
|
||||||
- **get_market_price** — 获取交易所实时价格
|
- **get_market_price** — 获取实时价格(加密货币或股票代码)
|
||||||
|
|
||||||
### 交易安全规则
|
### 交易安全规则
|
||||||
- 用户明确要求交易时才调用 execute_trade
|
- 用户明确要求交易时才调用 execute_trade
|
||||||
@@ -536,10 +536,10 @@ func (a *Agent) buildSystemPrompt(lang string) string {
|
|||||||
## Tools
|
## Tools
|
||||||
You can call these tools to take action:
|
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.
|
- **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.
|
- **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
|
- **get_positions** — View all current open positions (crypto + stocks)
|
||||||
- **get_balance** — View account balance and equity
|
- **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
|
### Trade Safety Rules
|
||||||
- Only call execute_trade when user explicitly requests a trade
|
- Only call execute_trade when user explicitly requests a trade
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ func buildAgentTools() []mcp.Tool {
|
|||||||
Type: "function",
|
Type: "function",
|
||||||
Function: mcp.FunctionDef{
|
Function: mcp.FunctionDef{
|
||||||
Name: "execute_trade",
|
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{
|
Parameters: map[string]any{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": map[string]any{
|
"properties": map[string]any{
|
||||||
@@ -51,7 +51,7 @@ func buildAgentTools() []mcp.Tool {
|
|||||||
},
|
},
|
||||||
"symbol": map[string]any{
|
"symbol": map[string]any{
|
||||||
"type": "string",
|
"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{
|
"quantity": map[string]any{
|
||||||
"type": "number",
|
"type": "number",
|
||||||
@@ -86,13 +86,13 @@ func buildAgentTools() []mcp.Tool {
|
|||||||
Type: "function",
|
Type: "function",
|
||||||
Function: mcp.FunctionDef{
|
Function: mcp.FunctionDef{
|
||||||
Name: "get_market_price",
|
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{
|
Parameters: map[string]any{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": map[string]any{
|
"properties": map[string]any{
|
||||||
"symbol": map[string]any{
|
"symbol": map[string]any{
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Trading symbol, e.g. BTCUSDT",
|
"description": "Trading symbol, e.g. BTCUSDT for crypto, AAPL for stocks",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"required": []string{"symbol"},
|
"required": []string{"symbol"},
|
||||||
@@ -205,7 +205,8 @@ func (a *Agent) toolExecuteTrade(_ context.Context, userID int64, lang, argsJSON
|
|||||||
|
|
||||||
// Normalize symbol
|
// Normalize symbol
|
||||||
sym := strings.ToUpper(args.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"
|
sym += "USDT"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -330,7 +331,7 @@ func (a *Agent) toolGetMarketPrice(argsJSON string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
sym := strings.ToUpper(args.Symbol)
|
sym := strings.ToUpper(args.Symbol)
|
||||||
if !strings.HasSuffix(sym, "USDT") {
|
if !isStockSymbol(sym) && !strings.HasSuffix(sym, "USDT") {
|
||||||
sym += "USDT"
|
sym += "USDT"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -459,3 +460,31 @@ func (a *Agent) toolGetTradeHistory(argsJSON string) string {
|
|||||||
})
|
})
|
||||||
return string(result)
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -153,6 +153,9 @@ func (a *Agent) executeTrade(ctx context.Context, trade *TradeAction) error {
|
|||||||
return fmt.Errorf("no traders configured")
|
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
|
// Find a running trader's underlying exchange interface
|
||||||
var underlyingTrader interface {
|
var underlyingTrader interface {
|
||||||
OpenLong(symbol string, quantity float64, leverage int) (map[string]interface{}, error)
|
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 {
|
if ut == nil {
|
||||||
continue
|
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
|
underlyingTrader = ut
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if underlyingTrader == nil {
|
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")
|
return fmt.Errorf("no running trader supports trade execution")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"nofx/safe"
|
"nofx/safe"
|
||||||
"nofx/store"
|
"nofx/store"
|
||||||
"nofx/trader"
|
"nofx/trader"
|
||||||
|
alpacatrader "nofx/trader/alpaca"
|
||||||
"nofx/trader/aster"
|
"nofx/trader/aster"
|
||||||
"nofx/trader/binance"
|
"nofx/trader/binance"
|
||||||
"nofx/trader/bitget"
|
"nofx/trader/bitget"
|
||||||
@@ -217,6 +218,12 @@ func (s *Server) handleCreateTrader(c *gin.Context) {
|
|||||||
} else {
|
} else {
|
||||||
createErr = fmt.Errorf("Lighter requires wallet address and API Key private key")
|
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:
|
default:
|
||||||
logger.Infof("⚠️ Unsupported exchange type: %s, using user input for initial balance", exchangeCfg.ExchangeType)
|
logger.Infof("⚠️ Unsupported exchange type: %s, using user input for initial balance", exchangeCfg.ExchangeType)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -704,6 +704,10 @@ func (tm *TraderManager) addTraderFromStore(traderCfg *store.Trader, aiModelCfg
|
|||||||
case "indodax":
|
case "indodax":
|
||||||
traderConfig.IndodaxAPIKey = string(exchangeCfg.APIKey)
|
traderConfig.IndodaxAPIKey = string(exchangeCfg.APIKey)
|
||||||
traderConfig.IndodaxSecretKey = string(exchangeCfg.SecretKey)
|
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)
|
// Set API keys based on AI model (convert EncryptedString to string)
|
||||||
|
|||||||
558
trader/alpaca/trader.go
Normal file
558
trader/alpaca/trader.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
74
trader/alpaca/types.go
Normal file
74
trader/alpaca/types.go
Normal file
@@ -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"`
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
"nofx/trader/bybit"
|
"nofx/trader/bybit"
|
||||||
"nofx/trader/gate"
|
"nofx/trader/gate"
|
||||||
"nofx/trader/hyperliquid"
|
"nofx/trader/hyperliquid"
|
||||||
|
"nofx/trader/alpaca"
|
||||||
"nofx/trader/indodax"
|
"nofx/trader/indodax"
|
||||||
"nofx/trader/kucoin"
|
"nofx/trader/kucoin"
|
||||||
"nofx/trader/lighter"
|
"nofx/trader/lighter"
|
||||||
@@ -66,6 +67,11 @@ type AutoTraderConfig struct {
|
|||||||
IndodaxAPIKey string
|
IndodaxAPIKey string
|
||||||
IndodaxSecretKey string
|
IndodaxSecretKey string
|
||||||
|
|
||||||
|
// Alpaca API configuration (US stock trading)
|
||||||
|
AlpacaAPIKey string
|
||||||
|
AlpacaAPISecret string
|
||||||
|
AlpacaPaper bool // true = paper trading, false = live
|
||||||
|
|
||||||
// Hyperliquid configuration
|
// Hyperliquid configuration
|
||||||
HyperliquidPrivateKey string
|
HyperliquidPrivateKey string
|
||||||
HyperliquidWalletAddr string
|
HyperliquidWalletAddr string
|
||||||
@@ -286,6 +292,9 @@ func NewAutoTrader(config AutoTraderConfig, st *store.Store, userID string) (*Au
|
|||||||
case "indodax":
|
case "indodax":
|
||||||
logger.Infof("🏦 [%s] Using Indodax Spot trading", config.Name)
|
logger.Infof("🏦 [%s] Using Indodax Spot trading", config.Name)
|
||||||
trader = indodax.NewIndodaxTrader(config.IndodaxAPIKey, config.IndodaxSecretKey)
|
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:
|
default:
|
||||||
return nil, fmt.Errorf("unsupported trading platform: %s", config.Exchange)
|
return nil, fmt.Errorf("unsupported trading platform: %s", config.Exchange)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,7 +63,11 @@ export function PositionsPanel() {
|
|||||||
const isProfit = pnl >= 0
|
const isProfit = pnl >= 0
|
||||||
const color = isProfit ? '#00e5a0' : '#F6465D'
|
const color = isProfit ? '#00e5a0' : '#F6465D'
|
||||||
const side = pos.side?.toUpperCase() || (pos.quantity > 0 ? 'LONG' : 'SHORT')
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -93,6 +97,9 @@ export function PositionsPanel() {
|
|||||||
>
|
>
|
||||||
{symbol}
|
{symbol}
|
||||||
</span>
|
</span>
|
||||||
|
{isStock && (
|
||||||
|
<span style={{ fontSize: 10, color: '#8b8ba0' }}>🇺🇸</span>
|
||||||
|
)}
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
@@ -106,7 +113,7 @@ export function PositionsPanel() {
|
|||||||
color: side === 'LONG' ? '#00e5a0' : '#F6465D',
|
color: side === 'LONG' ? '#00e5a0' : '#F6465D',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{side}
|
{isStock ? (side === 'LONG' ? 'HOLD' : 'SHORT') : side}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@@ -125,7 +132,7 @@ export function PositionsPanel() {
|
|||||||
<ArrowDownRight size={12} />
|
<ArrowDownRight size={12} />
|
||||||
)}
|
)}
|
||||||
{isProfit ? '+' : ''}
|
{isProfit ? '+' : ''}
|
||||||
{pnl.toFixed(2)}
|
{currencyPrefix}{pnl.toFixed(2)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@@ -136,8 +143,8 @@ export function PositionsPanel() {
|
|||||||
color: '#5c5c72',
|
color: '#5c5c72',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span>Qty: {pos.quantity}</span>
|
<span>{isStock ? 'Shares' : 'Qty'}: {pos.quantity}</span>
|
||||||
<span>Entry: {pos.entry_price.toFixed(2)}</span>
|
<span>Entry: {currencyPrefix}{pos.entry_price.toFixed(2)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user