diff --git a/market/data_klines.go b/market/data_klines.go index 94c320f0..5799eb21 100644 --- a/market/data_klines.go +++ b/market/data_klines.go @@ -114,18 +114,17 @@ func getKlinesFromCoinAnk(symbol, interval, exchange string, limit int) ([]Kline // getKlinesFromHyperliquid fetches kline data from Hyperliquid API for xyz dex assets func getKlinesFromHyperliquid(symbol, interval string, limit int) ([]Kline, error) { - // Remove xyz: prefix if present for the API call - baseCoin := strings.TrimPrefix(symbol, "xyz:") - - // Map interval to Hyperliquid format + // Pass the symbol AS-IS to GetCandles. It internally calls FormatCoinForAPI + // which handles the xyz: prefix correctly. Stripping the prefix here was a + // bug: if the base symbol (e.g. "QNT") was not in our hardcoded + // StockPerpsSymbols list, FormatCoinForAPI couldn't tell it was an xyz + // asset and the request hit the crypto perp endpoint instead — which + // returns 500 for stock symbols that have no crypto perp on Hyperliquid. hlInterval := hyperliquid.MapTimeframe(interval) - // Create Hyperliquid client client := hyperliquid.NewClient() - - // Fetch candles ctx := context.Background() - candles, err := client.GetCandles(ctx, baseCoin, hlInterval, limit) + candles, err := client.GetCandles(ctx, symbol, hlInterval, limit) if err != nil { return nil, fmt.Errorf("Hyperliquid API error: %w", err) } diff --git a/provider/hyperliquid/kline.go b/provider/hyperliquid/kline.go index 1c7dc85a..b9b987bd 100644 --- a/provider/hyperliquid/kline.go +++ b/provider/hyperliquid/kline.go @@ -460,8 +460,21 @@ func IsStockPerp(symbol string) bool { return false } -// IsXYZAsset checks if a symbol is on the xyz dex (stocks, forex, commodities) +// IsXYZAsset checks if a symbol is on the xyz dex (stocks, forex, commodities). +// +// Detection is suffix-driven first, hardcoded-list second: +// 1. `xyz:` prefix or `-USDC` suffix are unambiguous Hyperliquid signals — +// the only place those tokens originate is the Hyperliquid USDC board. +// This unblocks newly-listed stock perpetuals (QNT, ARM, ...) without +// requiring a code change every time Hyperliquid adds a ticker. +// 2. Bare bases (e.g. "QNT" with no qualifying suffix) still fall back to +// the hardcoded StockPerpsSymbols / XYZOtherSymbols / display alias lists +// so callers passing pre-normalized base symbols continue to work. func IsXYZAsset(symbol string) bool { + trimmed := strings.ToUpper(strings.TrimSpace(symbol)) + if strings.HasPrefix(strings.ToLower(trimmed), "xyz:") || strings.HasSuffix(trimmed, "-USDC") { + return true + } coin := NormalizeCoinBase(symbol) // Check stock perps for _, s := range StockPerpsSymbols { @@ -507,12 +520,23 @@ func NormalizeCoinBase(symbol string) string { return NormalizeXYZAlias(symbol) } -// FormatCoinForAPI formats the coin name for Hyperliquid API -// Stock perps need xyz:SYMBOL format, crypto uses plain symbol +// FormatCoinForAPI formats the coin name for Hyperliquid API. +// Stock perps need xyz:SYMBOL format, crypto uses plain symbol. +// +// Decision order: +// 1. `xyz:` prefix OR `-USDC` suffix on the original input ⇒ xyz asset +// (these tokens are Hyperliquid-specific, so the answer is unambiguous +// regardless of whether the base symbol appears in our hardcoded lists). +// 2. After stripping suffixes, if the bare base matches a known xyz asset +// (stock perps, forex, commodities, display aliases) ⇒ also xyz. +// 3. Otherwise crypto. func FormatCoinForAPI(symbol string) string { - hasExplicitXYZ := strings.HasPrefix(strings.ToLower(strings.TrimSpace(symbol)), "xyz:") + trimmed := strings.TrimSpace(symbol) + upper := strings.ToUpper(trimmed) + hasExplicitXYZ := strings.HasPrefix(strings.ToLower(trimmed), "xyz:") + hasUSDCSuffix := strings.HasSuffix(upper, "-USDC") base := NormalizeCoinBase(symbol) - if hasExplicitXYZ || IsXYZAsset(base) { + if hasExplicitXYZ || hasUSDCSuffix || IsXYZAsset(base) { return "xyz:" + base } return base diff --git a/trader/auto_trader_loop.go b/trader/auto_trader_loop.go index 4e5c5e0b..d7f4ae3e 100644 --- a/trader/auto_trader_loop.go +++ b/trader/auto_trader_loop.go @@ -6,6 +6,7 @@ import ( "nofx/kernel" "nofx/logger" "nofx/market" + "nofx/provider/hyperliquid" "nofx/store" "nofx/wallet" "strings" @@ -292,6 +293,30 @@ func normalizeUniverseSymbol(symbol string) string { return market.Normalize(strings.TrimSpace(symbol)) } +// universeBaseKey returns the bare base ticker used for fuzzy candidate +// matching. The AI sometimes echoes a candidate as "QNTUSDC", "QNT-USDC", +// "QNTUSDT", or even just "QNT" instead of the canonical "xyz:QNT" we +// supplied in the prompt. All of those resolve to the same Hyperliquid +// instrument, so we accept any of them when the base matches an allowed +// candidate's base. +func universeBaseKey(symbol string) string { + s := strings.ToUpper(strings.TrimSpace(symbol)) + if s == "" { + return "" + } + // Drop any xyz:/XYZ: prefix. + s = strings.TrimPrefix(s, "XYZ:") + // Drop common quote suffixes in order of specificity. + for _, suf := range []string{"-USDC", "-USDT", "USDC", "USDT", "USD"} { + if strings.HasSuffix(s, suf) && len(s) > len(suf) { + s = strings.TrimSuffix(s, suf) + break + } + } + // Normalize alias (TESLA -> TSLA, ROBINHOOD -> HOOD, etc.). + return hyperliquid.NormalizeXYZAlias(s) +} + func isOpenDecision(action string) bool { a := strings.ToLower(strings.TrimSpace(action)) return a == "open_long" || a == "open_short" @@ -303,13 +328,21 @@ func (at *AutoTrader) filterDecisionsToStrategyUniverse(decisions []kernel.Decis } allowed := make(map[string]bool, len(ctx.CandidateCoins)) + allowedBases := make(map[string]bool, len(ctx.CandidateCoins)) for _, coin := range ctx.CandidateCoins { allowed[normalizeUniverseSymbol(coin.Symbol)] = true + if base := universeBaseKey(coin.Symbol); base != "" { + allowedBases[base] = true + } } positions := make(map[string]bool, len(ctx.Positions)) + positionBases := make(map[string]bool, len(ctx.Positions)) for _, pos := range ctx.Positions { positions[normalizeUniverseSymbol(pos.Symbol)] = true + if base := universeBaseKey(pos.Symbol); base != "" { + positionBases[base] = true + } } filtered := make([]kernel.Decision, 0, len(decisions)) @@ -325,6 +358,19 @@ func (at *AutoTrader) filterDecisionsToStrategyUniverse(decisions []kernel.Decis continue } + // Fall back to base-level match so AI outputs like "QNTUSDC" still + // resolve to candidate "xyz:QNT". Rewrite the decision's symbol to + // the canonical form so downstream order placement works. + if base := universeBaseKey(d.Symbol); base != "" { + if allowedBases[base] || positionBases[base] { + if canonical := canonicalUniverseSymbolForBase(ctx, base); canonical != "" { + d.Symbol = canonical + } + filtered = append(filtered, d) + continue + } + } + if isOpenDecision(d.Action) { at.logWarnf("🚫 Blocked AI %s for %s: symbol is outside strategy candidate universe", d.Action, d.Symbol) } else { @@ -334,6 +380,23 @@ func (at *AutoTrader) filterDecisionsToStrategyUniverse(decisions []kernel.Decis return filtered } +// canonicalUniverseSymbolForBase finds the canonical candidate or position +// symbol that has the given base. Used to rewrite an AI decision's symbol +// (e.g. "QNTUSDC") to the form our order pipeline expects ("xyz:QNT"). +func canonicalUniverseSymbolForBase(ctx *kernel.Context, base string) string { + for _, coin := range ctx.CandidateCoins { + if universeBaseKey(coin.Symbol) == base { + return coin.Symbol + } + } + for _, pos := range ctx.Positions { + if universeBaseKey(pos.Symbol) == base { + return pos.Symbol + } + } + return "" +} + // buildTradingContext builds trading context func (at *AutoTrader) buildTradingContext() (*kernel.Context, error) { // 1. Get account information