package api import ( "context" "fmt" "net/http" "sort" "strconv" "strings" "time" "nofx/logger" "nofx/market" "nofx/provider/alpaca" "nofx/provider/coinank/coinank_api" "nofx/provider/coinank/coinank_enum" "nofx/provider/hyperliquid" "nofx/provider/twelvedata" "github.com/gin-gonic/gin" ) // handleKlines K-line data (supports multiple exchanges via coinank) func (s *Server) handleKlines(c *gin.Context) { // Get query parameters symbol := c.Query("symbol") if symbol == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "symbol parameter is required"}) return } interval := c.DefaultQuery("interval", "5m") exchange := c.DefaultQuery("exchange", "binance") // Default to binance for backward compatibility limitStr := c.DefaultQuery("limit", "1000") limit, err := strconv.Atoi(limitStr) if err != nil || limit <= 0 { limit = 1000 } // Coinank API has a maximum limit of 1500 klines per request if limit > 1500 { limit = 1500 } var klines []market.Kline exchangeLower := strings.ToLower(exchange) // Route to appropriate data source based on exchange type switch exchangeLower { case "alpaca": // US Stocks via Alpaca klines, err = s.getKlinesFromAlpaca(symbol, interval, limit) if err != nil { SafeInternalError(c, "Get klines from Alpaca", err) return } case "forex", "metals": // Forex and Metals via Twelve Data klines, err = s.getKlinesFromTwelveData(symbol, interval, limit) if err != nil { SafeInternalError(c, "Get klines from TwelveData", err) return } case "hyperliquid", "hyperliquid-xyz", "xyz": // Hyperliquid native API - supports both crypto perps and stock perps (xyz dex) klines, err = s.getKlinesFromHyperliquid(symbol, interval, limit) if err != nil { SafeInternalError(c, "Get klines from Hyperliquid", err) return } default: // Crypto exchanges via CoinAnk symbol = market.Normalize(symbol) klines, err = s.getKlinesFromCoinank(symbol, interval, exchange, limit) if err != nil { SafeInternalError(c, "Get klines from CoinAnk", err) return } } c.JSON(http.StatusOK, klines) } // getKlinesFromCoinank fetches kline data from coinank free/open API for multiple exchanges func (s *Server) getKlinesFromCoinank(symbol, interval, exchange string, limit int) ([]market.Kline, error) { // Map exchange string to coinank enum var coinankExchange coinank_enum.Exchange switch strings.ToLower(exchange) { case "binance": coinankExchange = coinank_enum.Binance case "bybit": coinankExchange = coinank_enum.Bybit case "okx": coinankExchange = coinank_enum.Okex case "bitget": coinankExchange = coinank_enum.Bitget case "gate": coinankExchange = coinank_enum.Gate case "aster": coinankExchange = coinank_enum.Aster case "lighter": // Lighter doesn't have direct CoinAnk support, use Binance data as fallback coinankExchange = coinank_enum.Binance case "kucoin": // KuCoin doesn't have direct CoinAnk support, use Binance data as fallback coinankExchange = coinank_enum.Binance default: // For any unknown exchange, default to Binance logger.Warnf("⚠️ Unknown exchange '%s', defaulting to Binance for CoinAnk", exchange) coinankExchange = coinank_enum.Binance } // Map interval string to coinank enum var coinankInterval coinank_enum.Interval switch interval { case "1s": coinankInterval = coinank_enum.Second1 case "5s": coinankInterval = coinank_enum.Second5 case "10s": coinankInterval = coinank_enum.Second10 case "30s": coinankInterval = coinank_enum.Second30 case "1m": coinankInterval = coinank_enum.Minute1 case "3m": coinankInterval = coinank_enum.Minute3 case "5m": coinankInterval = coinank_enum.Minute5 case "10m": coinankInterval = coinank_enum.Minute10 case "15m": coinankInterval = coinank_enum.Minute15 case "30m": coinankInterval = coinank_enum.Minute30 case "1h": coinankInterval = coinank_enum.Hour1 case "2h": coinankInterval = coinank_enum.Hour2 case "4h": coinankInterval = coinank_enum.Hour4 case "6h": coinankInterval = coinank_enum.Hour6 case "8h": coinankInterval = coinank_enum.Hour8 case "12h": coinankInterval = coinank_enum.Hour12 case "1d": coinankInterval = coinank_enum.Day1 case "3d": coinankInterval = coinank_enum.Day3 case "1w": coinankInterval = coinank_enum.Week1 case "1M": coinankInterval = coinank_enum.Month1 default: return nil, fmt.Errorf("unsupported interval for coinank: %s", interval) } // Convert symbol format for different exchanges // OKX uses "BTC-USDT-SWAP" format instead of "BTCUSDT" apiSymbol := symbol if coinankExchange == coinank_enum.Okex { // Convert BTCUSDT -> BTC-USDT-SWAP if strings.HasSuffix(symbol, "USDT") { base := strings.TrimSuffix(symbol, "USDT") apiSymbol = fmt.Sprintf("%s-USDT-SWAP", base) } } // Call coinank free/open API (no authentication required) ctx := context.Background() ts := time.Now().UnixMilli() // Use "To" side to search backward from current time (get historical klines) coinankKlines, err := coinank_api.Kline(ctx, apiSymbol, coinankExchange, ts, coinank_enum.To, limit, coinankInterval) if err != nil { // Free API doesn't support all exchanges (e.g., OKX, Bitget) // Fallback to Binance data as reference if coinankExchange != coinank_enum.Binance { logger.Warnf("⚠️ CoinAnk free API doesn't support %s, falling back to Binance data", coinankExchange) coinankKlines, err = coinank_api.Kline(ctx, symbol, coinank_enum.Binance, ts, coinank_enum.To, limit, coinankInterval) if err != nil { return nil, fmt.Errorf("coinank API error (fallback): %w", err) } } else { return nil, fmt.Errorf("coinank API error: %w", err) } } // Convert coinank kline format to market.Kline format // Coinank: Volume = BTC quantity, Quantity = USDT turnover klines := make([]market.Kline, len(coinankKlines)) for i, ck := range coinankKlines { klines[i] = market.Kline{ OpenTime: ck.StartTime, Open: ck.Open, High: ck.High, Low: ck.Low, Close: ck.Close, Volume: ck.Volume, // BTC quantity QuoteVolume: ck.Quantity, // USDT turnover CloseTime: ck.EndTime, } } return klines, nil } // getKlinesFromAlpaca fetches kline data from Alpaca API for US stocks func (s *Server) getKlinesFromAlpaca(symbol, interval string, limit int) ([]market.Kline, error) { // Create Alpaca client client := alpaca.NewClient() // Map interval to Alpaca timeframe format timeframe := alpaca.MapTimeframe(interval) // Fetch bars from Alpaca ctx := context.Background() bars, err := client.GetBars(ctx, symbol, timeframe, limit) if err != nil { return nil, fmt.Errorf("alpaca API error: %w", err) } // Convert Alpaca bars to market.Kline format klines := make([]market.Kline, len(bars)) for i, bar := range bars { klines[i] = market.Kline{ OpenTime: bar.Timestamp.UnixMilli(), Open: bar.Open, High: bar.High, Low: bar.Low, Close: bar.Close, Volume: float64(bar.Volume), // share count QuoteVolume: float64(bar.Volume) * bar.Close, // turnover = shares * close price (USD) CloseTime: bar.Timestamp.UnixMilli(), } } return klines, nil } // getKlinesFromTwelveData fetches kline data from Twelve Data API for forex and metals func (s *Server) getKlinesFromTwelveData(symbol, interval string, limit int) ([]market.Kline, error) { // Create Twelve Data client client := twelvedata.NewClient() // Map interval to Twelve Data timeframe format timeframe := twelvedata.MapTimeframe(interval) // Fetch time series from Twelve Data ctx := context.Background() result, err := client.GetTimeSeries(ctx, symbol, timeframe, limit) if err != nil { return nil, fmt.Errorf("twelvedata API error: %w", err) } // Convert Twelve Data bars to market.Kline format // Note: Twelve Data returns bars in reverse order (newest first) klines := make([]market.Kline, len(result.Values)) for i, bar := range result.Values { open, high, low, close, volume, timestamp, err := twelvedata.ParseBar(bar) if err != nil { logger.Warnf("⚠️ Failed to parse TwelveData bar: %v", err) continue } // Reverse order: put oldest first idx := len(result.Values) - 1 - i klines[idx] = market.Kline{ OpenTime: timestamp, Open: open, High: high, Low: low, Close: close, Volume: volume, CloseTime: timestamp, } } return klines, nil } // getKlinesFromHyperliquid fetches kline data from Hyperliquid API // Supports both crypto perps (default dex) and stock perps/forex/commodities (xyz dex) func (s *Server) getKlinesFromHyperliquid(symbol, interval string, limit int) ([]market.Kline, error) { // Create Hyperliquid client client := hyperliquid.NewClient() // Map interval to Hyperliquid format timeframe := hyperliquid.MapTimeframe(interval) // Fetch candles from Hyperliquid // FormatCoinForAPI will automatically add xyz: prefix for stock perps ctx := context.Background() candles, err := client.GetCandles(ctx, symbol, timeframe, limit) if err != nil { return nil, fmt.Errorf("hyperliquid API error: %w", err) } // Convert Hyperliquid candles to market.Kline format klines := make([]market.Kline, len(candles)) for i, candle := range candles { open, _ := strconv.ParseFloat(candle.Open, 64) high, _ := strconv.ParseFloat(candle.High, 64) low, _ := strconv.ParseFloat(candle.Low, 64) close, _ := strconv.ParseFloat(candle.Close, 64) volume, _ := strconv.ParseFloat(candle.Volume, 64) klines[i] = market.Kline{ OpenTime: candle.OpenTime, Open: open, High: high, Low: low, Close: close, Volume: volume, // contract quantity QuoteVolume: volume * close, // turnover (USD) CloseTime: candle.CloseTime, } } return klines, nil } func hyperliquidXYZDisplayBase(baseSymbol string) string { baseSymbol = strings.ToUpper(strings.TrimSpace(baseSymbol)) // User-facing names should be product names, not exchange shorthand tickers. // Keep the internal symbol separate because Hyperliquid's xyz dex still routes // orders/candles by the short coin name (for example xyz:SMSN). fullNames := map[string]string{ "XYZ100": "XYZ100", "TSLA": "TESLA", "NVDA": "NVIDIA", "GOLD": "GOLD", "HOOD": "ROBINHOOD", "INTC": "INTEL", "PLTR": "PALANTIR", "COIN": "COINBASE", "META": "META", "AAPL": "APPLE", "MSFT": "MICROSOFT", "ORCL": "ORACLE", "GOOGL": "GOOGLE", "AMZN": "AMAZON", "AMD": "AMD", "MU": "MICRON", "SNDK": "SANDISK", "MSTR": "MICROSTRATEGY", "CRCL": "CIRCLE", "NFLX": "NETFLIX", "COST": "COSTCO", "LLY": "ELI-LILLY", "SKHX": "SK-HYNIX", "TSM": "TSMC", "JPY": "JPY", "EUR": "EUR", "SILVER": "SILVER", "RIVN": "RIVIAN", "BABA": "ALIBABA", "CL": "CRUDE-OIL", "COPPER": "COPPER", "NATGAS": "NATURAL-GAS", "URANIUM": "URANIUM", "ALUMINIUM": "ALUMINIUM", "SMSN": "SAMSUNG", "PLATINUM": "PLATINUM", "USAR": "USA-RARE-EARTH", "CRWV": "COREWEAVE", "URNM": "URNM", "PALLADIUM": "PALLADIUM", "DXY": "DOLLAR-INDEX", "GME": "GAMESTOP", "KR200": "KOREA-200", "SOFTBANK": "SOFTBANK", "JP225": "JAPAN-225", "HYUNDAI": "HYUNDAI", "KIOXIA": "KIOXIA", "EWY": "SOUTH-KOREA-ETF", "EWJ": "JAPAN-ETF", "BRENTOIL": "BRENT-OIL", "VIX": "VIX", "HIMS": "HIMS-HERS", "SP500": "S&P-500", "DKNG": "DRAFTKINGS", "LITE": "LITECOIN", "CORN": "CORN", "XLE": "ENERGY-SECTOR-ETF", "WHEAT": "WHEAT", "TTF": "TTF-GAS", "BX": "BLACKSTONE", "PURRDAT": "PURRDAT", "MRVL": "MARVELL", "RKLB": "ROCKET-LAB", "BIRD": "BIRD", "VOL": "VOLATILITY", "DRAM": "DRAM", "CBRS": "COINBASE-PRE-IPO", "EWZ": "BRAZIL-ETF", "KRW": "KRW", "ZM": "ZOOM", "EBAY": "EBAY", "H100": "H100", "NIFTY": "NIFTY-50", "ARM": "ARM", "EWT": "TAIWAN-ETF", "GBP": "GBP", "SPCX": "SPACEX-PRE-IPO", "IBOV": "IBOVESPA", "ASML": "ASML", } if fullName, ok := fullNames[baseSymbol]; ok { return fullName } return baseSymbol } func hyperliquidXYZCategory(baseSymbol string) string { baseSymbol = strings.ToUpper(strings.TrimSpace(baseSymbol)) switch baseSymbol { case "GOLD", "SILVER", "CL", "COPPER", "NATGAS", "URANIUM", "ALUMINIUM", "PLATINUM", "PALLADIUM", "BRENTOIL", "CORN", "WHEAT", "TTF": return "commodity" case "XYZ100", "SP500", "JP225", "KR200", "DXY", "VIX", "XLE", "EWY", "EWJ", "EWZ", "EWT", "NIFTY", "IBOV": return "index" case "EUR", "JPY", "GBP", "KRW": return "forex" case "SPCX", "BIRD", "PURRDAT", "H100", "CBRS": return "pre_ipo" default: return "stock" } } func hyperliquidCategoryOrder(category string) int { switch category { case "stock": return 0 case "commodity": return 1 case "index": return 2 case "forex": return 3 case "pre_ipo": return 4 case "crypto": return 5 default: return 99 } } // handleSymbols returns available symbols for a given exchange func (s *Server) handleSymbols(c *gin.Context) { exchange := c.DefaultQuery("exchange", "hyperliquid") type SymbolInfo struct { Symbol string `json:"symbol"` Display string `json:"display"` Name string `json:"name"` Category string `json:"category"` // crypto, stock, forex, commodity, index Exchange string `json:"exchange"` Volume24h float64 `json:"volume_24h"` MarkPrice float64 `json:"mark_price"` PrevDayPrice float64 `json:"prev_day_price,omitempty"` Change24hPct float64 `json:"change_24h_pct,omitempty"` MaxLeverage int `json:"maxLeverage,omitempty"` SzDecimals int `json:"sz_decimals,omitempty"` } var symbols []SymbolInfo exchangeLower := strings.ToLower(exchange) switch exchangeLower { case "hyperliquid", "hyperliquid-xyz", "xyz": ctx := context.Background() // hyperliquid-xyz returns the full USDC trading board in product order: // stocks → commodities → indices → forex → pre-IPO → crypto. if exchangeLower == "hyperliquid-xyz" || exchangeLower == "xyz" { xyzCoins, err := hyperliquid.GetPerpDexCoins(ctx, hyperliquid.XYZDex) if err != nil { SafeInternalError(c, "Get Hyperliquid XYZ symbols", err) return } for _, coin := range xyzCoins { baseSymbol := strings.TrimPrefix(coin.Symbol, "xyz:") displayBase := hyperliquidXYZDisplayBase(baseSymbol) displaySymbol := displayBase + "-USDC" tradeSymbol := baseSymbol + "-USDC" symbols = append(symbols, SymbolInfo{ Symbol: tradeSymbol, Display: displaySymbol, Name: displayBase, Category: hyperliquidXYZCategory(baseSymbol), Exchange: "hyperliquid-xyz", Volume24h: coin.Volume24h, MarkPrice: coin.MarkPrice, PrevDayPrice: coin.PrevDayPrice, Change24hPct: coin.Change24hPct, MaxLeverage: coin.MaxLeverage, SzDecimals: coin.SzDecimals, }) } } // Crypto perps are shown last; only include them on the combined Hyperliquid board. if exchangeLower == "hyperliquid" || exchangeLower == "hyperliquid-xyz" { coins, err := hyperliquid.GetProvider().GetAllCoins(ctx) if err != nil { SafeInternalError(c, "Get Hyperliquid symbols", err) return } for _, coin := range coins { symbols = append(symbols, SymbolInfo{ Symbol: coin.Symbol, Display: coin.Symbol, Name: coin.Symbol, Category: "crypto", Exchange: "hyperliquid", Volume24h: coin.Volume24h, MarkPrice: coin.MarkPrice, PrevDayPrice: coin.PrevDayPrice, Change24hPct: coin.Change24hPct, MaxLeverage: coin.MaxLeverage, SzDecimals: coin.SzDecimals, }) } } default: c.JSON(http.StatusBadRequest, gin.H{"error": "Unsupported exchange for symbol listing"}) return } sort.SliceStable(symbols, func(i, j int) bool { ci := hyperliquidCategoryOrder(symbols[i].Category) cj := hyperliquidCategoryOrder(symbols[j].Category) if ci != cj { return ci < cj } return symbols[i].Volume24h > symbols[j].Volume24h }) c.JSON(http.StatusOK, gin.H{ "exchange": exchange, "symbols": symbols, "count": len(symbols), }) }