mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-06-06 05:51:19 +08:00
- Add Hyperliquid/XYZ symbol normalization tests and backend coverage - Extend kline and market data lookup paths for US stock symbols - Wire frontend data API types for stock-oriented market requests
549 lines
16 KiB
Go
549 lines
16 KiB
Go
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),
|
|
})
|
|
}
|