mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-06-06 05:51:19 +08:00
fix(market): route Hyperliquid USDC perps correctly + symbol fuzzy match
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.
This commit is contained in:
@@ -114,18 +114,17 @@ func getKlinesFromCoinAnk(symbol, interval, exchange string, limit int) ([]Kline
|
|||||||
|
|
||||||
// getKlinesFromHyperliquid fetches kline data from Hyperliquid API for xyz dex assets
|
// getKlinesFromHyperliquid fetches kline data from Hyperliquid API for xyz dex assets
|
||||||
func getKlinesFromHyperliquid(symbol, interval string, limit int) ([]Kline, error) {
|
func getKlinesFromHyperliquid(symbol, interval string, limit int) ([]Kline, error) {
|
||||||
// Remove xyz: prefix if present for the API call
|
// Pass the symbol AS-IS to GetCandles. It internally calls FormatCoinForAPI
|
||||||
baseCoin := strings.TrimPrefix(symbol, "xyz:")
|
// 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
|
||||||
// Map interval to Hyperliquid format
|
// 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)
|
hlInterval := hyperliquid.MapTimeframe(interval)
|
||||||
|
|
||||||
// Create Hyperliquid client
|
|
||||||
client := hyperliquid.NewClient()
|
client := hyperliquid.NewClient()
|
||||||
|
|
||||||
// Fetch candles
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
candles, err := client.GetCandles(ctx, baseCoin, hlInterval, limit)
|
candles, err := client.GetCandles(ctx, symbol, hlInterval, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("Hyperliquid API error: %w", err)
|
return nil, fmt.Errorf("Hyperliquid API error: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -460,8 +460,21 @@ func IsStockPerp(symbol string) bool {
|
|||||||
return false
|
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 {
|
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)
|
coin := NormalizeCoinBase(symbol)
|
||||||
// Check stock perps
|
// Check stock perps
|
||||||
for _, s := range StockPerpsSymbols {
|
for _, s := range StockPerpsSymbols {
|
||||||
@@ -507,12 +520,23 @@ func NormalizeCoinBase(symbol string) string {
|
|||||||
return NormalizeXYZAlias(symbol)
|
return NormalizeXYZAlias(symbol)
|
||||||
}
|
}
|
||||||
|
|
||||||
// FormatCoinForAPI formats the coin name for Hyperliquid API
|
// FormatCoinForAPI formats the coin name for Hyperliquid API.
|
||||||
// Stock perps need xyz:SYMBOL format, crypto uses plain symbol
|
// 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 {
|
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)
|
base := NormalizeCoinBase(symbol)
|
||||||
if hasExplicitXYZ || IsXYZAsset(base) {
|
if hasExplicitXYZ || hasUSDCSuffix || IsXYZAsset(base) {
|
||||||
return "xyz:" + base
|
return "xyz:" + base
|
||||||
}
|
}
|
||||||
return base
|
return base
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"nofx/kernel"
|
"nofx/kernel"
|
||||||
"nofx/logger"
|
"nofx/logger"
|
||||||
"nofx/market"
|
"nofx/market"
|
||||||
|
"nofx/provider/hyperliquid"
|
||||||
"nofx/store"
|
"nofx/store"
|
||||||
"nofx/wallet"
|
"nofx/wallet"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -292,6 +293,30 @@ func normalizeUniverseSymbol(symbol string) string {
|
|||||||
return market.Normalize(strings.TrimSpace(symbol))
|
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 {
|
func isOpenDecision(action string) bool {
|
||||||
a := strings.ToLower(strings.TrimSpace(action))
|
a := strings.ToLower(strings.TrimSpace(action))
|
||||||
return a == "open_long" || a == "open_short"
|
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))
|
allowed := make(map[string]bool, len(ctx.CandidateCoins))
|
||||||
|
allowedBases := make(map[string]bool, len(ctx.CandidateCoins))
|
||||||
for _, coin := range ctx.CandidateCoins {
|
for _, coin := range ctx.CandidateCoins {
|
||||||
allowed[normalizeUniverseSymbol(coin.Symbol)] = true
|
allowed[normalizeUniverseSymbol(coin.Symbol)] = true
|
||||||
|
if base := universeBaseKey(coin.Symbol); base != "" {
|
||||||
|
allowedBases[base] = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
positions := make(map[string]bool, len(ctx.Positions))
|
positions := make(map[string]bool, len(ctx.Positions))
|
||||||
|
positionBases := make(map[string]bool, len(ctx.Positions))
|
||||||
for _, pos := range ctx.Positions {
|
for _, pos := range ctx.Positions {
|
||||||
positions[normalizeUniverseSymbol(pos.Symbol)] = true
|
positions[normalizeUniverseSymbol(pos.Symbol)] = true
|
||||||
|
if base := universeBaseKey(pos.Symbol); base != "" {
|
||||||
|
positionBases[base] = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
filtered := make([]kernel.Decision, 0, len(decisions))
|
filtered := make([]kernel.Decision, 0, len(decisions))
|
||||||
@@ -325,6 +358,19 @@ func (at *AutoTrader) filterDecisionsToStrategyUniverse(decisions []kernel.Decis
|
|||||||
continue
|
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) {
|
if isOpenDecision(d.Action) {
|
||||||
at.logWarnf("🚫 Blocked AI %s for %s: symbol is outside strategy candidate universe", d.Action, d.Symbol)
|
at.logWarnf("🚫 Blocked AI %s for %s: symbol is outside strategy candidate universe", d.Action, d.Symbol)
|
||||||
} else {
|
} else {
|
||||||
@@ -334,6 +380,23 @@ func (at *AutoTrader) filterDecisionsToStrategyUniverse(decisions []kernel.Decis
|
|||||||
return filtered
|
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
|
// buildTradingContext builds trading context
|
||||||
func (at *AutoTrader) buildTradingContext() (*kernel.Context, error) {
|
func (at *AutoTrader) buildTradingContext() (*kernel.Context, error) {
|
||||||
// 1. Get account information
|
// 1. Get account information
|
||||||
|
|||||||
Reference in New Issue
Block a user