mirror of
https://github.com/laoxong/nofx.git
synced 2026-06-04 09:58:22 +08:00
393 lines
12 KiB
Go
393 lines
12 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
"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
|
|
}
|
|
|
|
// 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"`
|
|
Name string `json:"name"`
|
|
Category string `json:"category"` // crypto, stock, forex, commodity, index
|
|
MaxLeverage int `json:"maxLeverage,omitempty"`
|
|
}
|
|
|
|
var symbols []SymbolInfo
|
|
|
|
switch strings.ToLower(exchange) {
|
|
case "hyperliquid", "hyperliquid-xyz", "xyz":
|
|
// Fetch symbols from Hyperliquid
|
|
client := hyperliquid.NewClient()
|
|
ctx := context.Background()
|
|
|
|
// Get crypto perps from default dex
|
|
if exchange == "hyperliquid" || exchange == "hyperliquid-xyz" {
|
|
mids, err := client.GetAllMids(ctx)
|
|
if err == nil {
|
|
for symbol := range mids {
|
|
// Skip spot tokens (start with @)
|
|
if strings.HasPrefix(symbol, "@") {
|
|
continue
|
|
}
|
|
symbols = append(symbols, SymbolInfo{
|
|
Symbol: symbol,
|
|
Name: symbol,
|
|
Category: "crypto",
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
// Get xyz dex symbols (stocks, forex, commodities)
|
|
xyzMids, err := client.GetAllMidsXYZ(ctx)
|
|
if err == nil {
|
|
for symbol := range xyzMids {
|
|
// Remove xyz: prefix for display
|
|
displaySymbol := strings.TrimPrefix(symbol, "xyz:")
|
|
category := "stock"
|
|
if displaySymbol == "GOLD" || displaySymbol == "SILVER" {
|
|
category = "commodity"
|
|
} else if displaySymbol == "EUR" || displaySymbol == "JPY" {
|
|
category = "forex"
|
|
} else if displaySymbol == "XYZ100" {
|
|
category = "index"
|
|
}
|
|
symbols = append(symbols, SymbolInfo{
|
|
Symbol: displaySymbol,
|
|
Name: displaySymbol,
|
|
Category: category,
|
|
})
|
|
}
|
|
}
|
|
|
|
default:
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Unsupported exchange for symbol listing"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"exchange": exchange,
|
|
"symbols": symbols,
|
|
"count": len(symbols),
|
|
})
|
|
}
|