mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-06-06 05:51:19 +08:00
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.
544 lines
15 KiB
Go
544 lines
15 KiB
Go
package hyperliquid
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
MainnetAPIURL = "https://api.hyperliquid.xyz/info"
|
|
TestnetAPIURL = "https://api.hyperliquid-testnet.xyz/info"
|
|
)
|
|
|
|
// Candle represents a single OHLCV candle from Hyperliquid
|
|
type Candle struct {
|
|
OpenTime int64 `json:"t"` // Open time in milliseconds
|
|
CloseTime int64 `json:"T"` // Close time in milliseconds
|
|
Symbol string `json:"s"` // Coin symbol
|
|
Interval string `json:"i"` // Interval
|
|
Open string `json:"o"` // Open price
|
|
High string `json:"h"` // High price
|
|
Low string `json:"l"` // Low price
|
|
Close string `json:"c"` // Close price
|
|
Volume string `json:"v"` // Volume in base unit
|
|
TradeCount int `json:"n"` // Number of trades
|
|
}
|
|
|
|
// CandleRequest represents the request for candleSnapshot
|
|
type CandleRequest struct {
|
|
Type string `json:"type"`
|
|
Req CandleRequestBody `json:"req"`
|
|
}
|
|
|
|
// CandleRequestBody represents the body of candleSnapshot request
|
|
type CandleRequestBody struct {
|
|
Coin string `json:"coin"`
|
|
Interval string `json:"interval"`
|
|
StartTime int64 `json:"startTime"`
|
|
EndTime int64 `json:"endTime"`
|
|
}
|
|
|
|
// Client is the Hyperliquid API client
|
|
type Client struct {
|
|
apiURL string
|
|
client *http.Client
|
|
}
|
|
|
|
// NewClient creates a new Hyperliquid client for mainnet
|
|
func NewClient() *Client {
|
|
return &Client{
|
|
apiURL: MainnetAPIURL,
|
|
client: &http.Client{
|
|
Timeout: 30 * time.Second,
|
|
},
|
|
}
|
|
}
|
|
|
|
// NewTestnetClient creates a new Hyperliquid client for testnet
|
|
func NewTestnetClient() *Client {
|
|
return &Client{
|
|
apiURL: TestnetAPIURL,
|
|
client: &http.Client{
|
|
Timeout: 30 * time.Second,
|
|
},
|
|
}
|
|
}
|
|
|
|
// GetCandles fetches historical candlestick data for a symbol
|
|
// coin: symbol name (e.g., "BTC", "TSLA", "AAPL", "xyz:TSLA")
|
|
// interval: "1m", "5m", "15m", "1h", "4h", "1d"
|
|
// limit: number of candles to fetch (max 5000)
|
|
func (c *Client) GetCandles(ctx context.Context, coin string, interval string, limit int) ([]Candle, error) {
|
|
// Format coin name for API (stock perps need xyz: prefix)
|
|
coin = FormatCoinForAPI(coin)
|
|
|
|
// Calculate time range based on interval and limit
|
|
now := time.Now()
|
|
endTime := now.UnixMilli()
|
|
|
|
// Calculate start time based on interval
|
|
intervalDuration := getIntervalDuration(interval)
|
|
startTime := now.Add(-intervalDuration * time.Duration(limit)).UnixMilli()
|
|
|
|
// Build request
|
|
reqBody := CandleRequest{
|
|
Type: "candleSnapshot",
|
|
Req: CandleRequestBody{
|
|
Coin: coin,
|
|
Interval: interval,
|
|
StartTime: startTime,
|
|
EndTime: endTime,
|
|
},
|
|
}
|
|
|
|
jsonBody, err := json.Marshal(reqBody)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to marshal request: %w", err)
|
|
}
|
|
|
|
// Create request
|
|
req, err := http.NewRequestWithContext(ctx, "POST", c.apiURL, bytes.NewBuffer(jsonBody))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
// Execute request
|
|
resp, err := c.client.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to execute request: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
// Read response
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read response: %w", err)
|
|
}
|
|
|
|
// Check status code
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("hyperliquid API error (status %d): %s", resp.StatusCode, string(body))
|
|
}
|
|
|
|
// Parse response
|
|
var candles []Candle
|
|
if err := json.Unmarshal(body, &candles); err != nil {
|
|
return nil, fmt.Errorf("failed to parse response: %w (body: %s)", err, string(body))
|
|
}
|
|
|
|
return candles, nil
|
|
}
|
|
|
|
// GetAllMids fetches current mid prices for all assets (default perp dex)
|
|
func (c *Client) GetAllMids(ctx context.Context) (map[string]string, error) {
|
|
return c.GetAllMidsWithDex(ctx, "")
|
|
}
|
|
|
|
// GetAllMidsXYZ fetches current mid prices for xyz dex (stocks, forex, commodities)
|
|
func (c *Client) GetAllMidsXYZ(ctx context.Context) (map[string]string, error) {
|
|
return c.GetAllMidsWithDex(ctx, XYZDex)
|
|
}
|
|
|
|
// GetAllMidsWithDex fetches current mid prices for a specific dex
|
|
func (c *Client) GetAllMidsWithDex(ctx context.Context, dex string) (map[string]string, error) {
|
|
reqBody := map[string]string{"type": "allMids"}
|
|
if dex != "" {
|
|
reqBody["dex"] = dex
|
|
}
|
|
jsonBody, err := json.Marshal(reqBody)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to marshal request: %w", err)
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, "POST", c.apiURL, bytes.NewBuffer(jsonBody))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := c.client.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to execute request: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read response: %w", err)
|
|
}
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("hyperliquid API error (status %d): %s", resp.StatusCode, string(body))
|
|
}
|
|
|
|
var mids map[string]string
|
|
if err := json.Unmarshal(body, &mids); err != nil {
|
|
return nil, fmt.Errorf("failed to parse response: %w", err)
|
|
}
|
|
|
|
return mids, nil
|
|
}
|
|
|
|
// GetMeta fetches metadata for all perpetual assets
|
|
func (c *Client) GetMeta(ctx context.Context) (*Meta, error) {
|
|
reqBody := map[string]string{"type": "meta"}
|
|
jsonBody, err := json.Marshal(reqBody)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to marshal request: %w", err)
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, "POST", c.apiURL, bytes.NewBuffer(jsonBody))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := c.client.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to execute request: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read response: %w", err)
|
|
}
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("hyperliquid API error (status %d): %s", resp.StatusCode, string(body))
|
|
}
|
|
|
|
var meta Meta
|
|
if err := json.Unmarshal(body, &meta); err != nil {
|
|
return nil, fmt.Errorf("failed to parse response: %w", err)
|
|
}
|
|
|
|
return &meta, nil
|
|
}
|
|
|
|
// Meta represents the metadata response
|
|
type Meta struct {
|
|
Universe []AssetInfo `json:"universe"`
|
|
}
|
|
|
|
// AssetInfo represents information about a single asset
|
|
type AssetInfo struct {
|
|
Name string `json:"name"`
|
|
SzDecimals int `json:"szDecimals"`
|
|
MaxLeverage int `json:"maxLeverage"`
|
|
}
|
|
|
|
// NormalizeCoin normalizes coin name for Hyperliquid API
|
|
// Examples:
|
|
// - "BTCUSDT" -> "BTC"
|
|
// - "TSLA-USDC" -> "TSLA"
|
|
// - "TESLA-USDC" -> "TSLA"
|
|
// - "SAMSUNG-USDC" -> "SMSN"
|
|
// - "xyz:TSLA" -> "TSLA"
|
|
// - "BTC" -> "BTC"
|
|
func NormalizeCoin(symbol string) string {
|
|
return NormalizeCoinBase(symbol)
|
|
}
|
|
|
|
// XYZDisplayNameToCoin maps user-facing product labels back to Hyperliquid xyz coin names.
|
|
// Hyperliquid routes candles/orders by short names (for example xyz:SMSN), while NOFX
|
|
// shows full names (for example SAMSUNG-USDC) in the UI.
|
|
var XYZDisplayNameToCoin = map[string]string{
|
|
"TESLA": "TSLA",
|
|
"NVIDIA": "NVDA",
|
|
"ROBINHOOD": "HOOD",
|
|
"INTEL": "INTC",
|
|
"PALANTIR": "PLTR",
|
|
"COINBASE": "COIN",
|
|
"APPLE": "AAPL",
|
|
"MICROSOFT": "MSFT",
|
|
"ORACLE": "ORCL",
|
|
"GOOGLE": "GOOGL",
|
|
"ALPHABET": "GOOGL",
|
|
"AMAZON": "AMZN",
|
|
"MICRON": "MU",
|
|
"SANDISK": "SNDK",
|
|
"MICROSTRATEGY": "MSTR",
|
|
"CIRCLE": "CRCL",
|
|
"NETFLIX": "NFLX",
|
|
"COSTCO": "COST",
|
|
"ELI-LILLY": "LLY",
|
|
"SK-HYNIX": "SKHX",
|
|
"SKHYNIX": "SKHX",
|
|
"TSMC": "TSM",
|
|
"RIVIAN": "RIVN",
|
|
"ALIBABA": "BABA",
|
|
"CRUDE-OIL": "CL",
|
|
"CRUDEOIL": "CL",
|
|
"NATURAL-GAS": "NATGAS",
|
|
"NATURALGAS": "NATGAS",
|
|
"SAMSUNG": "SMSN",
|
|
"USA-RARE-EARTH": "USAR",
|
|
"USARAREEARTH": "USAR",
|
|
"COREWEAVE": "CRWV",
|
|
"DOLLAR-INDEX": "DXY",
|
|
"DOLLARINDEX": "DXY",
|
|
"GAMESTOP": "GME",
|
|
"KOREA-200": "KR200",
|
|
"KOREA200": "KR200",
|
|
"JAPAN-225": "JP225",
|
|
"JAPAN225": "JP225",
|
|
"SOUTH-KOREA-ETF": "EWY",
|
|
"SOUTHKOREAETF": "EWY",
|
|
"JAPAN-ETF": "EWJ",
|
|
"JAPANETF": "EWJ",
|
|
"BRENT-OIL": "BRENTOIL",
|
|
"BRENTOIL": "BRENTOIL",
|
|
"HIMS-HERS": "HIMS",
|
|
"HIMSHERS": "HIMS",
|
|
"S&P-500": "SP500",
|
|
"SP-500": "SP500",
|
|
"SP500": "SP500",
|
|
"DRAFTKINGS": "DKNG",
|
|
"LITECOIN": "LITE",
|
|
"ENERGY-SECTOR-ETF": "XLE",
|
|
"ENERGYSECTORETF": "XLE",
|
|
"TTF-GAS": "TTF",
|
|
"TTFGAS": "TTF",
|
|
"BLACKSTONE": "BX",
|
|
"MARVELL": "MRVL",
|
|
"ROCKET-LAB": "RKLB",
|
|
"ROCKETLAB": "RKLB",
|
|
"VOLATILITY": "VOL",
|
|
"COINBASE-PRE-IPO": "CBRS",
|
|
"COINBASEPREIPO": "CBRS",
|
|
"BRAZIL-ETF": "EWZ",
|
|
"BRAZILETF": "EWZ",
|
|
"ZOOM": "ZM",
|
|
"NIFTY-50": "NIFTY",
|
|
"NIFTY50": "NIFTY",
|
|
"TAIWAN-ETF": "EWT",
|
|
"TAIWANETF": "EWT",
|
|
"SPACEX-PRE-IPO": "SPCX",
|
|
"SPACEXPREIPO": "SPCX",
|
|
"IBOVESPA": "IBOV",
|
|
}
|
|
|
|
func NormalizeXYZAlias(base string) string {
|
|
base = strings.ToUpper(strings.TrimSpace(base))
|
|
base = strings.TrimPrefix(base, "XYZ:")
|
|
base = strings.TrimSuffix(base, "-USDC")
|
|
base = strings.TrimSuffix(base, "-USD")
|
|
if mapped, ok := XYZDisplayNameToCoin[base]; ok {
|
|
return mapped
|
|
}
|
|
compact := strings.NewReplacer(" ", "", "_", "", ".", "", "/", "", "&", "AND").Replace(base)
|
|
if mapped, ok := XYZDisplayNameToCoin[compact]; ok {
|
|
return mapped
|
|
}
|
|
return base
|
|
}
|
|
|
|
// MapTimeframe maps common timeframe strings to Hyperliquid format
|
|
func MapTimeframe(interval string) string {
|
|
switch interval {
|
|
case "1m":
|
|
return "1m"
|
|
case "3m":
|
|
return "5m" // Hyperliquid doesn't have 3m, use 5m
|
|
case "5m":
|
|
return "5m"
|
|
case "15m":
|
|
return "15m"
|
|
case "30m":
|
|
return "30m"
|
|
case "1h":
|
|
return "1h"
|
|
case "2h":
|
|
return "1h" // Hyperliquid doesn't have 2h, use 1h
|
|
case "4h":
|
|
return "4h"
|
|
case "6h":
|
|
return "4h" // Hyperliquid doesn't have 6h, use 4h
|
|
case "8h":
|
|
return "8h"
|
|
case "12h":
|
|
return "12h"
|
|
case "1d":
|
|
return "1d"
|
|
case "3d":
|
|
return "1d" // Hyperliquid doesn't have 3d, use 1d
|
|
case "1w":
|
|
return "1w"
|
|
case "1M":
|
|
return "1M"
|
|
default:
|
|
return "5m" // Default to 5 minutes
|
|
}
|
|
}
|
|
|
|
// getIntervalDuration returns the duration for a given interval
|
|
func getIntervalDuration(interval string) time.Duration {
|
|
switch interval {
|
|
case "1m":
|
|
return time.Minute
|
|
case "5m":
|
|
return 5 * time.Minute
|
|
case "15m":
|
|
return 15 * time.Minute
|
|
case "30m":
|
|
return 30 * time.Minute
|
|
case "1h":
|
|
return time.Hour
|
|
case "4h":
|
|
return 4 * time.Hour
|
|
case "8h":
|
|
return 8 * time.Hour
|
|
case "12h":
|
|
return 12 * time.Hour
|
|
case "1d":
|
|
return 24 * time.Hour
|
|
case "1w":
|
|
return 7 * 24 * time.Hour
|
|
case "1M":
|
|
return 30 * 24 * time.Hour
|
|
default:
|
|
return 5 * time.Minute
|
|
}
|
|
}
|
|
|
|
// XYZ Dex name for stock perps, forex, and commodities
|
|
const XYZDex = "xyz"
|
|
|
|
// Stock perps symbols available on Hyperliquid xyz dex
|
|
// Use xyz:SYMBOL format when calling the API
|
|
var StockPerpsSymbols = []string{
|
|
"TSLA", // Tesla
|
|
"AAPL", // Apple
|
|
"NVDA", // Nvidia
|
|
"MSFT", // Microsoft
|
|
"META", // Meta
|
|
"AMZN", // Amazon
|
|
"GOOGL", // Alphabet
|
|
"AMD", // AMD
|
|
"COIN", // Coinbase
|
|
"NFLX", // Netflix
|
|
"PLTR", // Palantir
|
|
"HOOD", // Robinhood
|
|
"INTC", // Intel
|
|
"MSTR", // MicroStrategy
|
|
"TSM", // TSMC
|
|
"ORCL", // Oracle
|
|
"MU", // Micron
|
|
"RIVN", // Rivian
|
|
"COST", // Costco
|
|
"LLY", // Eli Lilly
|
|
"CRCL", // Circle (new)
|
|
"SKHX", // Skyward (new)
|
|
"SNDK", // Sandisk (new)
|
|
}
|
|
|
|
// Forex and commodities on xyz dex
|
|
var XYZOtherSymbols = []string{
|
|
"GOLD", // Gold
|
|
"SILVER", // Silver
|
|
"EUR", // EUR/USD
|
|
"JPY", // USD/JPY
|
|
"XYZ100", // Index
|
|
}
|
|
|
|
// IsStockPerp checks if a symbol is a stock perpetual
|
|
func IsStockPerp(symbol string) bool {
|
|
coin := NormalizeCoinBase(symbol)
|
|
for _, s := range StockPerpsSymbols {
|
|
if s == coin {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// 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 {
|
|
if s == coin {
|
|
return true
|
|
}
|
|
}
|
|
// Check other xyz assets
|
|
for _, s := range XYZOtherSymbols {
|
|
if s == coin {
|
|
return true
|
|
}
|
|
}
|
|
// Check newer xyz assets that are represented by full display-name aliases in NOFX.
|
|
for _, s := range XYZDisplayNameToCoin {
|
|
if s == coin {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// NormalizeCoinBase removes common suffixes to get base symbol
|
|
func NormalizeCoinBase(symbol string) string {
|
|
symbol = strings.ToUpper(strings.TrimSpace(symbol))
|
|
hasXYZPrefix := strings.HasPrefix(symbol, "XYZ:")
|
|
// Remove xyz: prefix if present
|
|
if hasXYZPrefix {
|
|
return NormalizeXYZAlias(strings.TrimPrefix(symbol, "XYZ:"))
|
|
}
|
|
// Remove -USDC suffix
|
|
if strings.HasSuffix(symbol, "-USDC") {
|
|
return NormalizeXYZAlias(strings.TrimSuffix(symbol, "-USDC"))
|
|
}
|
|
// Remove USDT suffix
|
|
if strings.HasSuffix(symbol, "USDT") {
|
|
return strings.TrimSuffix(symbol, "USDT")
|
|
}
|
|
// Remove USD suffix
|
|
if strings.HasSuffix(symbol, "USD") {
|
|
return strings.TrimSuffix(symbol, "USD")
|
|
}
|
|
return NormalizeXYZAlias(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 {
|
|
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 || hasUSDCSuffix || IsXYZAsset(base) {
|
|
return "xyz:" + base
|
|
}
|
|
return base
|
|
}
|