From 06824915a44527f8d0ef632932c2c5b6307501ea Mon Sep 17 00:00:00 2001 From: shinchan-zhai Date: Mon, 23 Mar 2026 20:18:24 +0800 Subject: [PATCH] fix: isStockSymbol misclassifies crypto symbols (BTC/ETH/SOL) as stock tickers Critical bug: isStockSymbol('BTC') returned true because 'BTC' is 3 uppercase letters and the suffix check only catches 'BTCUSDT'-style pairs. This caused crypto trade commands to be routed to Alpaca (stock trader) instead of crypto exchanges. Fix: add knownCryptoSymbols map with 60+ common crypto base symbols. Check this map first before the heuristic letter-count check. Also includes: - Unit tests for isStockSymbol (crypto vs stock classification) - Alpaca support in handleSyncBalance/handleClosePosition API handlers - Cancel orphaned SL/TP orders before manual position close - Clear peak PnL cache on position close (prevent stale data) - Lighter FormatQuantity uses actual market precision instead of hardcoded 4 --- agent/tools.go | 28 +++++++++++++++- agent/tools_test.go | 65 ++++++++++++++++++++++++++++++++++++ api/handler_trader_status.go | 21 ++++++++++++ trader/auto_trader_orders.go | 6 ++++ trader/lighter/account.go | 11 ++++-- 5 files changed, 127 insertions(+), 4 deletions(-) create mode 100644 agent/tools_test.go diff --git a/agent/tools.go b/agent/tools.go index dcdecd4d..863c7d4a 100644 --- a/agent/tools.go +++ b/agent/tools.go @@ -471,11 +471,37 @@ func (a *Agent) toolGetTradeHistory(argsJSON string) string { return string(result) } +// knownCryptoSymbols is a set of well-known cryptocurrency base symbols. +// Without this, isStockSymbol("BTC") would incorrectly return true because +// "BTC" is 3 uppercase letters and the suffix check only catches "BTCUSDT"-style pairs. +var knownCryptoSymbols = map[string]bool{ + "BTC": true, "ETH": true, "SOL": true, "BNB": true, "XRP": true, + "DOGE": true, "ADA": true, "AVAX": true, "DOT": true, "LINK": true, + "PEPE": true, "SHIB": true, "ARB": true, "OP": true, "SUI": true, + "APT": true, "SEI": true, "TIA": true, "JUP": true, "WIF": true, + "NEAR": true, "ATOM": true, "FTM": true, "MATIC": true, "INJ": true, + "RENDER": true, "FET": true, "TAO": true, "WLD": true, "USDT": true, + "USDC": true, "BUSD": true, "DAI": true, "UNI": true, "AAVE": true, + "LDO": true, "MKR": true, "CRV": true, "PENDLE": true, "ENA": true, + "ONDO": true, "TRUMP": true, "TON": true, "TRX": true, "LTC": true, + "BCH": true, "ETC": true, "FIL": true, "ICP": true, "HBAR": true, + "VET": true, "ALGO": true, "SAND": true, "MANA": true, "AXS": true, + "GMT": true, "APE": true, "GALA": true, "IMX": true, "BLUR": true, + "STRK": true, "ZK": true, "W": true, "IO": true, "ZRO": true, + "BONK": true, "FLOKI": true, "ORDI": true, "STX": true, "RUNE": true, +} + // 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. +// Known crypto base symbols (BTC, ETH, SOL etc.) are excluded. func isStockSymbol(sym string) bool { sym = strings.ToUpper(sym) + + // Check known crypto base symbols first (critical: "BTC", "ETH" etc. are NOT stocks) + if knownCryptoSymbols[sym] { + return false + } + // If it already has a crypto quote suffix, it's crypto cryptoSuffixes := []string{"USDT", "BUSD", "USDC", "BTC", "ETH", "BNB"} for _, suffix := range cryptoSuffixes { diff --git a/agent/tools_test.go b/agent/tools_test.go new file mode 100644 index 00000000..d7ea4918 --- /dev/null +++ b/agent/tools_test.go @@ -0,0 +1,65 @@ +package agent + +import "testing" + +func TestIsStockSymbol(t *testing.T) { + tests := []struct { + sym string + want bool + }{ + // Known crypto base symbols — must NOT be detected as stock + {"BTC", false}, + {"ETH", false}, + {"SOL", false}, + {"BNB", false}, + {"XRP", false}, + {"DOGE", false}, + {"ADA", false}, + {"AVAX", false}, + {"DOT", false}, + {"LINK", false}, + {"PEPE", false}, + {"SHIB", false}, + {"TRUMP", false}, + {"USDT", false}, + {"USDC", false}, + {"W", false}, // single letter crypto + + // Crypto pairs — must NOT be stock + {"BTCUSDT", false}, + {"ETHUSDT", false}, + {"SOLUSDT", false}, + {"DOGEUSDT", false}, + + // Real stock tickers — must be detected as stock + {"AAPL", true}, + {"TSLA", true}, + {"NVDA", true}, + {"MSFT", true}, + {"GOOGL", true}, + {"AMZN", true}, + {"META", true}, + {"AMD", true}, + {"PLTR", true}, + {"BA", true}, + {"F", true}, // Ford — 1 letter + {"GM", true}, // 2 letters + {"JPM", true}, // 3 letters + + // Mixed / edge cases + {"btc", false}, // lowercase crypto + {"aapl", true}, // lowercase stock (uppercased internally) + {"BTC123", false}, // not pure letters + {"123456", false}, // digits + {"", false}, + } + + for _, tt := range tests { + t.Run(tt.sym, func(t *testing.T) { + got := isStockSymbol(tt.sym) + if got != tt.want { + t.Errorf("isStockSymbol(%q) = %v, want %v", tt.sym, got, tt.want) + } + }) + } +} diff --git a/api/handler_trader_status.go b/api/handler_trader_status.go index cbae3a84..cadd02a0 100644 --- a/api/handler_trader_status.go +++ b/api/handler_trader_status.go @@ -9,6 +9,7 @@ import ( "nofx/logger" "nofx/store" "nofx/trader" + "nofx/trader/alpaca" "nofx/trader/aster" "nofx/trader/binance" "nofx/trader/bitget" @@ -120,6 +121,12 @@ func (s *Server) handleSyncBalance(c *gin.Context) { } else { createErr = fmt.Errorf("Lighter requires wallet address and API Key private key") } + case "alpaca": + tempTrader = alpaca.NewAlpacaTrader( + string(exchangeCfg.APIKey), + string(exchangeCfg.SecretKey), + exchangeCfg.Testnet, + ) default: c.JSON(http.StatusBadRequest, gin.H{"error": "Unsupported exchange type"}) return @@ -284,6 +291,12 @@ func (s *Server) handleClosePosition(c *gin.Context) { } else { createErr = fmt.Errorf("Lighter requires wallet address and API Key private key") } + case "alpaca": + tempTrader = alpaca.NewAlpacaTrader( + string(exchangeCfg.APIKey), + string(exchangeCfg.SecretKey), + exchangeCfg.Testnet, + ) default: c.JSON(http.StatusBadRequest, gin.H{"error": "Unsupported exchange type"}) return @@ -318,6 +331,14 @@ func (s *Server) handleClosePosition(c *gin.Context) { } } + // Cancel existing stop-loss and take-profit orders BEFORE closing position + // Critical: orphaned SL/TP orders could trigger after position is closed and create unintended positions + if err := tempTrader.CancelStopOrders(req.Symbol); err != nil { + logger.Warnf(" ⚠️ Failed to cancel stop orders for %s: %v (proceeding with close)", req.Symbol, err) + } else { + logger.Infof(" 🗑️ Cancelled stop/TP orders for %s before manual close", req.Symbol) + } + // Execute close position operation var result map[string]interface{} var closeErr error diff --git a/trader/auto_trader_orders.go b/trader/auto_trader_orders.go index 4328d8d4..19e850c6 100644 --- a/trader/auto_trader_orders.go +++ b/trader/auto_trader_orders.go @@ -336,6 +336,9 @@ func (at *AutoTrader) executeCloseLongWithRecord(decision *kernel.Decision, acti // Record order to database and poll for confirmation at.recordAndConfirmOrder(order, decision.Symbol, "close_long", quantity, marketData.CurrentPrice, 0, entryPrice) + // Clear peak PnL cache for this position (prevent stale data if same symbol is reopened) + at.ClearPeakPnLCache(decision.Symbol, "long") + logger.Infof(" ✓ Position closed successfully") return nil } @@ -406,6 +409,9 @@ func (at *AutoTrader) executeCloseShortWithRecord(decision *kernel.Decision, act // Record order to database and poll for confirmation at.recordAndConfirmOrder(order, decision.Symbol, "close_short", quantity, marketData.CurrentPrice, 0, entryPrice) + // Clear peak PnL cache for this position (prevent stale data if same symbol is reopened) + at.ClearPeakPnLCache(decision.Symbol, "short") + logger.Infof(" ✓ Position closed successfully") return nil } diff --git a/trader/lighter/account.go b/trader/lighter/account.go index a85345cc..1ae17149 100644 --- a/trader/lighter/account.go +++ b/trader/lighter/account.go @@ -351,9 +351,14 @@ func (t *LighterTraderV2) GetMarketPrice(symbol string) (float64, error) { // FormatQuantity Format quantity to correct precision (implements Trader interface) func (t *LighterTraderV2) FormatQuantity(symbol string, quantity float64) (string, error) { - // TODO: Get symbol precision from API - // Using default precision for now - return fmt.Sprintf("%.4f", quantity), nil + marketInfo, err := t.getMarketInfo(symbol) + if err != nil { + // Fallback to 4 decimal places if market info unavailable + logger.Infof("⚠️ FormatQuantity: failed to get market info for %s, using default precision: %v", symbol, err) + return fmt.Sprintf("%.4f", quantity), nil + } + format := fmt.Sprintf("%%.%df", marketInfo.SizeDecimals) + return fmt.Sprintf(format, quantity), nil } // GetOrderBook Get order book (implements GridTrader interface)