Files
shinchan-zhai 4ab4024628 fix: fallback to Binance kline when coinank returns empty data for non-Binance exchanges
CoinAnk recently stopped providing free kline data for OKX/Bitget/Gate
exchanges (returns success but empty array). This caused '3-minute
k-line data is empty' errors for all users on those exchanges.

Fix: detect empty kline response and automatically fallback to Binance
kline data, which is always available.
2026-03-27 13:41:49 +08:00

430 lines
11 KiB
Go

package market
import (
"context"
"fmt"
"nofx/logger"
"nofx/provider/coinank/coinank_api"
"nofx/provider/coinank/coinank_enum"
"nofx/provider/hyperliquid"
"strconv"
"strings"
"time"
)
// Note: Kline data now uses free/open API (coinank_api.Kline) which doesn't require authentication
// getKlinesFromCoinAnk fetches kline data from CoinAnk API (replacement for WSMonitorCli)
func getKlinesFromCoinAnk(symbol, interval, exchange string, limit int) ([]Kline, error) {
// Map interval string to coinank enum
var coinankInterval coinank_enum.Interval
switch interval {
case "1m":
coinankInterval = coinank_enum.Minute1
case "3m":
coinankInterval = coinank_enum.Minute3
case "5m":
coinankInterval = coinank_enum.Minute5
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
default:
return nil, fmt.Errorf("unsupported interval: %s", interval)
}
// 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 "hyperliquid":
coinankExchange = coinank_enum.Hyperliquid
case "aster":
coinankExchange = coinank_enum.Aster
default:
// Default to Binance for unknown exchanges
coinankExchange = coinank_enum.Binance
}
// 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, symbol, coinankExchange, ts, coinank_enum.To, limit, coinankInterval)
if err != nil || len(coinankKlines) == 0 {
// If exchange-specific data fails or returns empty, fallback to Binance
if coinankExchange != coinank_enum.Binance {
if err != nil {
logger.Warnf("⚠️ CoinAnk %s data failed, falling back to Binance: %v", exchange, err)
} else {
logger.Warnf("⚠️ CoinAnk %s %s data empty for %s, falling back to Binance", exchange, interval, symbol)
}
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 if err != nil {
return nil, fmt.Errorf("CoinAnk API error: %w", err)
}
}
// Convert coinank kline format to market.Kline format
klines := make([]Kline, len(coinankKlines))
for i, ck := range coinankKlines {
klines[i] = Kline{
OpenTime: ck.StartTime,
Open: ck.Open,
High: ck.High,
Low: ck.Low,
Close: ck.Close,
Volume: ck.Volume,
CloseTime: ck.EndTime,
}
}
return klines, nil
}
// getKlinesFromHyperliquid fetches kline data from Hyperliquid API for xyz dex assets
func getKlinesFromHyperliquid(symbol, interval string, limit int) ([]Kline, error) {
// Remove xyz: prefix if present for the API call
baseCoin := strings.TrimPrefix(symbol, "xyz:")
// Map interval to Hyperliquid format
hlInterval := hyperliquid.MapTimeframe(interval)
// Create Hyperliquid client
client := hyperliquid.NewClient()
// Fetch candles
ctx := context.Background()
candles, err := client.GetCandles(ctx, baseCoin, hlInterval, limit)
if err != nil {
return nil, fmt.Errorf("Hyperliquid API error: %w", err)
}
// Convert to market.Kline format
klines := make([]Kline, len(candles))
for i, c := range candles {
open, _ := strconv.ParseFloat(c.Open, 64)
high, _ := strconv.ParseFloat(c.High, 64)
low, _ := strconv.ParseFloat(c.Low, 64)
closePrice, _ := strconv.ParseFloat(c.Close, 64)
volume, _ := strconv.ParseFloat(c.Volume, 64)
klines[i] = Kline{
OpenTime: c.OpenTime,
Open: open,
High: high,
Low: low,
Close: closePrice,
Volume: volume,
CloseTime: c.CloseTime,
}
}
return klines, nil
}
// calculateTimeframeSeries calculates series data for a single timeframe
func calculateTimeframeSeries(klines []Kline, timeframe string, count int) *TimeframeSeriesData {
if count <= 0 {
count = 10 // default
}
data := &TimeframeSeriesData{
Timeframe: timeframe,
Klines: make([]KlineBar, 0, count),
MidPrices: make([]float64, 0, count),
EMA20Values: make([]float64, 0, count),
EMA50Values: make([]float64, 0, count),
MACDValues: make([]float64, 0, count),
RSI7Values: make([]float64, 0, count),
RSI14Values: make([]float64, 0, count),
Volume: make([]float64, 0, count),
BOLLUpper: make([]float64, 0, count),
BOLLMiddle: make([]float64, 0, count),
BOLLLower: make([]float64, 0, count),
}
// Get latest N data points based on count from config
start := len(klines) - count
if start < 0 {
start = 0
}
for i := start; i < len(klines); i++ {
// Store full OHLCV kline data
data.Klines = append(data.Klines, KlineBar{
Time: klines[i].OpenTime,
Open: klines[i].Open,
High: klines[i].High,
Low: klines[i].Low,
Close: klines[i].Close,
Volume: klines[i].Volume,
})
// Keep MidPrices and Volume for backward compatibility
data.MidPrices = append(data.MidPrices, klines[i].Close)
data.Volume = append(data.Volume, klines[i].Volume)
// Calculate EMA20 for each point
if i >= 19 {
ema20 := calculateEMA(klines[:i+1], 20)
data.EMA20Values = append(data.EMA20Values, ema20)
}
// Calculate EMA50 for each point
if i >= 49 {
ema50 := calculateEMA(klines[:i+1], 50)
data.EMA50Values = append(data.EMA50Values, ema50)
}
// Calculate MACD for each point
if i >= 25 {
macd := calculateMACD(klines[:i+1])
data.MACDValues = append(data.MACDValues, macd)
}
// Calculate RSI for each point
if i >= 7 {
rsi7 := calculateRSI(klines[:i+1], 7)
data.RSI7Values = append(data.RSI7Values, rsi7)
}
if i >= 14 {
rsi14 := calculateRSI(klines[:i+1], 14)
data.RSI14Values = append(data.RSI14Values, rsi14)
}
// Calculate Bollinger Bands (period 20, std dev multiplier 2)
if i >= 19 {
upper, middle, lower := calculateBOLL(klines[:i+1], 20, 2.0)
data.BOLLUpper = append(data.BOLLUpper, upper)
data.BOLLMiddle = append(data.BOLLMiddle, middle)
data.BOLLLower = append(data.BOLLLower, lower)
}
}
// Calculate ATR14
data.ATR14 = calculateATR(klines, 14)
return data
}
// calculatePriceChangeByBars calculates how many K-lines to look back for price change based on timeframe
func calculatePriceChangeByBars(klines []Kline, timeframe string, targetMinutes int) float64 {
if len(klines) < 2 {
return 0
}
// Parse timeframe to minutes
tfMinutes := parseTimeframeToMinutes(timeframe)
if tfMinutes <= 0 {
return 0
}
// Calculate how many K-lines to look back
barsBack := targetMinutes / tfMinutes
if barsBack < 1 {
barsBack = 1
}
currentPrice := klines[len(klines)-1].Close
idx := len(klines) - 1 - barsBack
if idx < 0 {
idx = 0
}
oldPrice := klines[idx].Close
if oldPrice > 0 {
return ((currentPrice - oldPrice) / oldPrice) * 100
}
return 0
}
// parseTimeframeToMinutes parses timeframe string to minutes
func parseTimeframeToMinutes(tf string) int {
switch tf {
case "1m":
return 1
case "3m":
return 3
case "5m":
return 5
case "15m":
return 15
case "30m":
return 30
case "1h":
return 60
case "2h":
return 120
case "4h":
return 240
case "6h":
return 360
case "8h":
return 480
case "12h":
return 720
case "1d":
return 1440
case "3d":
return 4320
case "1w":
return 10080
default:
return 0
}
}
// calculateIntradaySeries calculates intraday series data
func calculateIntradaySeries(klines []Kline) *IntradayData {
data := &IntradayData{
MidPrices: make([]float64, 0, 10),
EMA20Values: make([]float64, 0, 10),
MACDValues: make([]float64, 0, 10),
RSI7Values: make([]float64, 0, 10),
RSI14Values: make([]float64, 0, 10),
Volume: make([]float64, 0, 10),
}
// Get latest 10 data points
start := len(klines) - 10
if start < 0 {
start = 0
}
for i := start; i < len(klines); i++ {
data.MidPrices = append(data.MidPrices, klines[i].Close)
data.Volume = append(data.Volume, klines[i].Volume)
// Calculate EMA20 for each point
if i >= 19 {
ema20 := calculateEMA(klines[:i+1], 20)
data.EMA20Values = append(data.EMA20Values, ema20)
}
// Calculate MACD for each point
if i >= 25 {
macd := calculateMACD(klines[:i+1])
data.MACDValues = append(data.MACDValues, macd)
}
// Calculate RSI for each point
if i >= 7 {
rsi7 := calculateRSI(klines[:i+1], 7)
data.RSI7Values = append(data.RSI7Values, rsi7)
}
if i >= 14 {
rsi14 := calculateRSI(klines[:i+1], 14)
data.RSI14Values = append(data.RSI14Values, rsi14)
}
}
// Calculate 3m ATR14
data.ATR14 = calculateATR(klines, 14)
return data
}
// calculateLongerTermData calculates longer-term data
func calculateLongerTermData(klines []Kline) *LongerTermData {
data := &LongerTermData{
MACDValues: make([]float64, 0, 10),
RSI14Values: make([]float64, 0, 10),
}
// Calculate EMA
data.EMA20 = calculateEMA(klines, 20)
data.EMA50 = calculateEMA(klines, 50)
// Calculate ATR
data.ATR3 = calculateATR(klines, 3)
data.ATR14 = calculateATR(klines, 14)
// Calculate volume
if len(klines) > 0 {
data.CurrentVolume = klines[len(klines)-1].Volume
// Calculate average volume
sum := 0.0
for _, k := range klines {
sum += k.Volume
}
data.AverageVolume = sum / float64(len(klines))
}
// Calculate MACD and RSI series
start := len(klines) - 10
if start < 0 {
start = 0
}
for i := start; i < len(klines); i++ {
if i >= 25 {
macd := calculateMACD(klines[:i+1])
data.MACDValues = append(data.MACDValues, macd)
}
if i >= 14 {
rsi14 := calculateRSI(klines[:i+1], 14)
data.RSI14Values = append(data.RSI14Values, rsi14)
}
}
return data
}
// GetBoxData fetches 1h klines and calculates box data for a symbol
func GetBoxData(symbol string) (*BoxData, error) {
symbol = Normalize(symbol)
// Fetch 500 1h klines
var klines []Kline
var err error
if IsXyzDexAsset(symbol) {
klines, err = getKlinesFromHyperliquid(symbol, "1h", LongBoxPeriod)
} else {
klines, err = getKlinesFromCoinAnk(symbol, "1h", "binance", LongBoxPeriod)
}
if err != nil {
return nil, fmt.Errorf("failed to get 1h klines: %w", err)
}
if len(klines) == 0 {
return nil, fmt.Errorf("no kline data available")
}
currentPrice := klines[len(klines)-1].Close
return calculateBoxData(klines, currentPrice), nil
}