From d008ccc6ab2597df03b0327786c1cf7beea8ba77 Mon Sep 17 00:00:00 2001 From: tinkle-community Date: Fri, 29 May 2026 22:14:41 +0800 Subject: [PATCH] fix(market): route Hyperliquid USDC perps correctly + symbol fuzzy match MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A single-symbol QNT-USDC trader produced 0 candidate coins, 500 errors from Hyperliquid, and "🚫 Dropped AI decision" warnings — the agent had no market data to reason about, so it sat in `wait` forever. Three chained bugs: 1. provider/hyperliquid/kline.go (IsXYZAsset / FormatCoinForAPI): asset detection required the base symbol to appear in the hardcoded StockPerpsSymbols / XYZOtherSymbols / display-alias lists. QNT, ARM, and every other newly-listed Hyperliquid USDC perp wasn't in the list, so the code routed them to the crypto path (CoinAnk) which doesn't have them. Now the `-USDC` suffix and `xyz:` prefix are trusted as definitive Hyperliquid signals — these tokens are Hyperliquid-specific and new listings don't require a code change. The hardcoded lists are kept as fallbacks for bare base symbols. 2. market/data_klines.go (getKlinesFromHyperliquid): the function stripped the `xyz:` prefix before calling GetCandles, defeating GetCandles's own FormatCoinForAPI logic. With the hardcoded list missing the new ticker, FormatCoinForAPI couldn't re-add the prefix and the request hit Hyperliquid's crypto perp endpoint — which returns 500 for stock-only tickers. Pass the symbol through as-is. 3. trader/auto_trader_loop.go (filterDecisionsToStrategyUniverse): the AI sometimes echoes a candidate as "QNTUSDC" / "QNT-USDC" / "QNTUSDT" / bare "QNT" instead of the canonical "xyz:QNT" we supplied. Strict exact-match was dropping all of them. Added a base-level key (strips xyz:, -USDC, -USDT, USDC, USDT, USD; normalizes display aliases like ROBINHOOD → HOOD) and rewrites the matched decision's symbol to the canonical form so the order pipeline downstream sees the format it expects. After this, a single-symbol stock trader fetches real K-line data from Hyperliquid, the AI sees real candidates, and decisions get executed on-chain instead of silently filtered. --- market/data_klines.go | 15 ++++----- provider/hyperliquid/kline.go | 34 ++++++++++++++++--- trader/auto_trader_loop.go | 63 +++++++++++++++++++++++++++++++++++ 3 files changed, 99 insertions(+), 13 deletions(-) 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