mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-16 17:12:25 +08:00
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
This commit is contained in:
@@ -471,11 +471,37 @@ func (a *Agent) toolGetTradeHistory(argsJSON string) string {
|
|||||||
return string(result)
|
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).
|
// isStockSymbol heuristically determines if a symbol is a stock ticker (not crypto).
|
||||||
// Stock tickers are 1-5 uppercase letters without numeric suffixes like "USDT".
|
// 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 {
|
func isStockSymbol(sym string) bool {
|
||||||
sym = strings.ToUpper(sym)
|
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
|
// If it already has a crypto quote suffix, it's crypto
|
||||||
cryptoSuffixes := []string{"USDT", "BUSD", "USDC", "BTC", "ETH", "BNB"}
|
cryptoSuffixes := []string{"USDT", "BUSD", "USDC", "BTC", "ETH", "BNB"}
|
||||||
for _, suffix := range cryptoSuffixes {
|
for _, suffix := range cryptoSuffixes {
|
||||||
|
|||||||
65
agent/tools_test.go
Normal file
65
agent/tools_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"nofx/logger"
|
"nofx/logger"
|
||||||
"nofx/store"
|
"nofx/store"
|
||||||
"nofx/trader"
|
"nofx/trader"
|
||||||
|
"nofx/trader/alpaca"
|
||||||
"nofx/trader/aster"
|
"nofx/trader/aster"
|
||||||
"nofx/trader/binance"
|
"nofx/trader/binance"
|
||||||
"nofx/trader/bitget"
|
"nofx/trader/bitget"
|
||||||
@@ -120,6 +121,12 @@ func (s *Server) handleSyncBalance(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 = alpaca.NewAlpacaTrader(
|
||||||
|
string(exchangeCfg.APIKey),
|
||||||
|
string(exchangeCfg.SecretKey),
|
||||||
|
exchangeCfg.Testnet,
|
||||||
|
)
|
||||||
default:
|
default:
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Unsupported exchange type"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Unsupported exchange type"})
|
||||||
return
|
return
|
||||||
@@ -284,6 +291,12 @@ func (s *Server) handleClosePosition(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 = alpaca.NewAlpacaTrader(
|
||||||
|
string(exchangeCfg.APIKey),
|
||||||
|
string(exchangeCfg.SecretKey),
|
||||||
|
exchangeCfg.Testnet,
|
||||||
|
)
|
||||||
default:
|
default:
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Unsupported exchange type"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Unsupported exchange type"})
|
||||||
return
|
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
|
// Execute close position operation
|
||||||
var result map[string]interface{}
|
var result map[string]interface{}
|
||||||
var closeErr error
|
var closeErr error
|
||||||
|
|||||||
@@ -336,6 +336,9 @@ func (at *AutoTrader) executeCloseLongWithRecord(decision *kernel.Decision, acti
|
|||||||
// Record order to database and poll for confirmation
|
// Record order to database and poll for confirmation
|
||||||
at.recordAndConfirmOrder(order, decision.Symbol, "close_long", quantity, marketData.CurrentPrice, 0, entryPrice)
|
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")
|
logger.Infof(" ✓ Position closed successfully")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -406,6 +409,9 @@ func (at *AutoTrader) executeCloseShortWithRecord(decision *kernel.Decision, act
|
|||||||
// Record order to database and poll for confirmation
|
// Record order to database and poll for confirmation
|
||||||
at.recordAndConfirmOrder(order, decision.Symbol, "close_short", quantity, marketData.CurrentPrice, 0, entryPrice)
|
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")
|
logger.Infof(" ✓ Position closed successfully")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -351,9 +351,14 @@ func (t *LighterTraderV2) GetMarketPrice(symbol string) (float64, error) {
|
|||||||
|
|
||||||
// FormatQuantity Format quantity to correct precision (implements Trader interface)
|
// FormatQuantity Format quantity to correct precision (implements Trader interface)
|
||||||
func (t *LighterTraderV2) FormatQuantity(symbol string, quantity float64) (string, error) {
|
func (t *LighterTraderV2) FormatQuantity(symbol string, quantity float64) (string, error) {
|
||||||
// TODO: Get symbol precision from API
|
marketInfo, err := t.getMarketInfo(symbol)
|
||||||
// Using default precision for now
|
if err != nil {
|
||||||
return fmt.Sprintf("%.4f", quantity), 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)
|
// GetOrderBook Get order book (implements GridTrader interface)
|
||||||
|
|||||||
Reference in New Issue
Block a user