mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-06-06 05:51:19 +08:00
Gate.io Integration: - Add Gate trader with full Trader interface implementation - Add order_sync.go for background trade synchronization - Fix quantity display (convert contracts to actual tokens via quanto_multiplier) - Fix fill price return in OpenLong/OpenShort/CloseLong/CloseShort - Add Gate-specific CoinAnk K-line data source support - Add Gate to supported exchanges in frontend and backend - Add Gate/KuCoin logo SVG icons Trader Package Refactoring: - Move exchange-specific code into subdirectories (binance/, bybit/, okx/, bitget/, hyperliquid/, aster/, lighter/, gate/) - Create types/ package for shared types to avoid circular dependencies - Move TraderTestSuite to trader/testutil package to avoid import cycles - Update market.GetWithExchange to support exchange-specific data
1345 lines
38 KiB
Go
1345 lines
38 KiB
Go
package market
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"math"
|
||
"nofx/logger"
|
||
"nofx/provider/coinank/coinank_api"
|
||
"nofx/provider/coinank/coinank_enum"
|
||
"nofx/provider/hyperliquid"
|
||
"strconv"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
)
|
||
|
||
// FundingRateCache is the funding rate cache structure
|
||
// Binance Funding Rate only updates every 8 hours, using 1-hour cache can significantly reduce API calls
|
||
type FundingRateCache struct {
|
||
Rate float64
|
||
UpdatedAt time.Time
|
||
}
|
||
|
||
var (
|
||
fundingRateMap sync.Map // map[string]*FundingRateCache
|
||
frCacheTTL = 1 * time.Hour
|
||
)
|
||
|
||
// 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 {
|
||
// If exchange-specific data fails, fallback to Binance
|
||
if coinankExchange != coinank_enum.Binance {
|
||
logger.Warnf("⚠️ CoinAnk %s data failed, falling back to Binance: %v", exchange, err)
|
||
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
|
||
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
|
||
}
|
||
|
||
// Get retrieves market data for the specified token (uses Binance data by default)
|
||
func Get(symbol string) (*Data, error) {
|
||
return GetWithExchange(symbol, "binance")
|
||
}
|
||
|
||
// GetWithExchange retrieves market data for the specified token using exchange-specific data
|
||
func GetWithExchange(symbol, exchange string) (*Data, error) {
|
||
var klines3m, klines4h []Kline
|
||
var err error
|
||
// Normalize symbol
|
||
symbol = Normalize(symbol)
|
||
|
||
// Check if this is an xyz dex asset (use Hyperliquid API)
|
||
isXyzAsset := IsXyzDexAsset(symbol)
|
||
|
||
// For hyperliquid exchange, also use Hyperliquid API
|
||
useHyperliquidAPI := isXyzAsset || strings.ToLower(exchange) == "hyperliquid"
|
||
|
||
// Get 3-minute K-line data (or 5-minute for xyz assets as 3m may not be available)
|
||
if useHyperliquidAPI {
|
||
// Use Hyperliquid API for xyz dex assets (use 5m since 3m may not be available)
|
||
klines3m, err = getKlinesFromHyperliquid(symbol, "5m", 100)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("Failed to get 5-minute K-line from Hyperliquid: %v", err)
|
||
}
|
||
} else {
|
||
// Use CoinAnk for regular crypto assets with exchange-specific data
|
||
klines3m, err = getKlinesFromCoinAnk(symbol, "3m", exchange, 100)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("Failed to get 3-minute K-line from CoinAnk (%s): %v", exchange, err)
|
||
}
|
||
}
|
||
|
||
// Data staleness detection: Prevent DOGEUSDT-style price freeze issues
|
||
if isStaleData(klines3m, symbol) {
|
||
logger.Infof("⚠️ WARNING: %s detected stale data (consecutive price freeze), skipping symbol", symbol)
|
||
return nil, fmt.Errorf("%s data is stale, possible cache failure", symbol)
|
||
}
|
||
|
||
// Get 4-hour K-line data
|
||
if useHyperliquidAPI {
|
||
klines4h, err = getKlinesFromHyperliquid(symbol, "4h", 100)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("Failed to get 4-hour K-line from Hyperliquid: %v", err)
|
||
}
|
||
} else {
|
||
klines4h, err = getKlinesFromCoinAnk(symbol, "4h", exchange, 100)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("Failed to get 4-hour K-line from CoinAnk (%s): %v", exchange, err)
|
||
}
|
||
}
|
||
|
||
// Check if data is empty
|
||
if len(klines3m) == 0 {
|
||
return nil, fmt.Errorf("3-minute K-line data is empty")
|
||
}
|
||
if len(klines4h) == 0 {
|
||
return nil, fmt.Errorf("4-hour K-line data is empty")
|
||
}
|
||
|
||
// Calculate current indicators (based on 3-minute latest data)
|
||
currentPrice := klines3m[len(klines3m)-1].Close
|
||
currentEMA20 := calculateEMA(klines3m, 20)
|
||
currentMACD := calculateMACD(klines3m)
|
||
currentRSI7 := calculateRSI(klines3m, 7)
|
||
|
||
// Calculate price change percentage
|
||
// 1-hour price change = price from 20 3-minute K-lines ago
|
||
priceChange1h := 0.0
|
||
if len(klines3m) >= 21 { // Need at least 21 K-lines (current + 20 previous)
|
||
price1hAgo := klines3m[len(klines3m)-21].Close
|
||
if price1hAgo > 0 {
|
||
priceChange1h = ((currentPrice - price1hAgo) / price1hAgo) * 100
|
||
}
|
||
}
|
||
|
||
// 4-hour price change = price from 1 4-hour K-line ago
|
||
priceChange4h := 0.0
|
||
if len(klines4h) >= 2 {
|
||
price4hAgo := klines4h[len(klines4h)-2].Close
|
||
if price4hAgo > 0 {
|
||
priceChange4h = ((currentPrice - price4hAgo) / price4hAgo) * 100
|
||
}
|
||
}
|
||
|
||
// Get OI data
|
||
oiData, err := getOpenInterestData(symbol)
|
||
if err != nil {
|
||
// OI failure doesn't affect overall result, use default values
|
||
oiData = &OIData{Latest: 0, Average: 0}
|
||
}
|
||
|
||
// Get Funding Rate
|
||
fundingRate, _ := getFundingRate(symbol)
|
||
|
||
// Calculate intraday series data
|
||
intradayData := calculateIntradaySeries(klines3m)
|
||
|
||
// Calculate longer-term data
|
||
longerTermData := calculateLongerTermData(klines4h)
|
||
|
||
return &Data{
|
||
Symbol: symbol,
|
||
CurrentPrice: currentPrice,
|
||
PriceChange1h: priceChange1h,
|
||
PriceChange4h: priceChange4h,
|
||
CurrentEMA20: currentEMA20,
|
||
CurrentMACD: currentMACD,
|
||
CurrentRSI7: currentRSI7,
|
||
OpenInterest: oiData,
|
||
FundingRate: fundingRate,
|
||
IntradaySeries: intradayData,
|
||
LongerTermContext: longerTermData,
|
||
}, nil
|
||
}
|
||
|
||
// GetWithTimeframes retrieves market data for specified multiple timeframes
|
||
// timeframes: list of timeframes, e.g. ["5m", "15m", "1h", "4h"]
|
||
// primaryTimeframe: primary timeframe (used for calculating current indicators), defaults to timeframes[0]
|
||
// count: number of K-lines for each timeframe
|
||
func GetWithTimeframes(symbol string, timeframes []string, primaryTimeframe string, count int) (*Data, error) {
|
||
symbol = Normalize(symbol)
|
||
|
||
if len(timeframes) == 0 {
|
||
return nil, fmt.Errorf("at least one timeframe is required")
|
||
}
|
||
|
||
// If primary timeframe is not specified, use the first one
|
||
if primaryTimeframe == "" {
|
||
primaryTimeframe = timeframes[0]
|
||
}
|
||
|
||
// Ensure primary timeframe is in the list
|
||
hasPrimary := false
|
||
for _, tf := range timeframes {
|
||
if tf == primaryTimeframe {
|
||
hasPrimary = true
|
||
break
|
||
}
|
||
}
|
||
if !hasPrimary {
|
||
timeframes = append([]string{primaryTimeframe}, timeframes...)
|
||
}
|
||
|
||
// Store data for all timeframes
|
||
timeframeData := make(map[string]*TimeframeSeriesData)
|
||
var primaryKlines []Kline
|
||
|
||
// Check if this is an xyz dex asset (use Hyperliquid API)
|
||
isXyzAsset := IsXyzDexAsset(symbol)
|
||
|
||
// Get K-line data for each timeframe
|
||
for _, tf := range timeframes {
|
||
var klines []Kline
|
||
var err error
|
||
|
||
if isXyzAsset {
|
||
// Use Hyperliquid API for xyz dex assets
|
||
klines, err = getKlinesFromHyperliquid(symbol, tf, 200)
|
||
if err != nil {
|
||
logger.Infof("⚠️ Failed to get %s %s K-line from Hyperliquid: %v", symbol, tf, err)
|
||
continue
|
||
}
|
||
} else {
|
||
// Use CoinAnk for regular crypto assets (default to Binance)
|
||
klines, err = getKlinesFromCoinAnk(symbol, tf, "binance", 200)
|
||
if err != nil {
|
||
logger.Infof("⚠️ Failed to get %s %s K-line from CoinAnk: %v", symbol, tf, err)
|
||
continue
|
||
}
|
||
}
|
||
|
||
if len(klines) == 0 {
|
||
logger.Infof("⚠️ %s %s K-line data is empty", symbol, tf)
|
||
continue
|
||
}
|
||
|
||
// Save primary timeframe K-lines for calculating base indicators
|
||
if tf == primaryTimeframe {
|
||
primaryKlines = klines
|
||
}
|
||
|
||
// Calculate series data for this timeframe (use count from config)
|
||
seriesData := calculateTimeframeSeries(klines, tf, count)
|
||
timeframeData[tf] = seriesData
|
||
}
|
||
|
||
// If primary timeframe data is empty, return error
|
||
if len(primaryKlines) == 0 {
|
||
return nil, fmt.Errorf("Primary timeframe %s K-line data is empty", primaryTimeframe)
|
||
}
|
||
|
||
// Data staleness detection
|
||
if isStaleData(primaryKlines, symbol) {
|
||
logger.Infof("⚠️ WARNING: %s detected stale data (consecutive price freeze), skipping symbol", symbol)
|
||
return nil, fmt.Errorf("%s data is stale, possible cache failure", symbol)
|
||
}
|
||
|
||
// Calculate current indicators (based on primary timeframe latest data)
|
||
currentPrice := primaryKlines[len(primaryKlines)-1].Close
|
||
currentEMA20 := calculateEMA(primaryKlines, 20)
|
||
currentMACD := calculateMACD(primaryKlines)
|
||
currentRSI7 := calculateRSI(primaryKlines, 7)
|
||
|
||
// Calculate price changes
|
||
priceChange1h := calculatePriceChangeByBars(primaryKlines, primaryTimeframe, 60) // 1 hour
|
||
priceChange4h := calculatePriceChangeByBars(primaryKlines, primaryTimeframe, 240) // 4 hours
|
||
|
||
// Get OI data
|
||
oiData, err := getOpenInterestData(symbol)
|
||
if err != nil {
|
||
oiData = &OIData{Latest: 0, Average: 0}
|
||
}
|
||
|
||
// Get Funding Rate
|
||
fundingRate, _ := getFundingRate(symbol)
|
||
|
||
return &Data{
|
||
Symbol: symbol,
|
||
CurrentPrice: currentPrice,
|
||
PriceChange1h: priceChange1h,
|
||
PriceChange4h: priceChange4h,
|
||
CurrentEMA20: currentEMA20,
|
||
CurrentMACD: currentMACD,
|
||
CurrentRSI7: currentRSI7,
|
||
OpenInterest: oiData,
|
||
FundingRate: fundingRate,
|
||
TimeframeData: timeframeData,
|
||
}, 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
|
||
}
|
||
}
|
||
|
||
// calculateEMA calculates EMA
|
||
func calculateEMA(klines []Kline, period int) float64 {
|
||
if len(klines) < period {
|
||
return 0
|
||
}
|
||
|
||
// Calculate SMA as initial EMA
|
||
sum := 0.0
|
||
for i := 0; i < period; i++ {
|
||
sum += klines[i].Close
|
||
}
|
||
ema := sum / float64(period)
|
||
|
||
// Calculate EMA
|
||
multiplier := 2.0 / float64(period+1)
|
||
for i := period; i < len(klines); i++ {
|
||
ema = (klines[i].Close-ema)*multiplier + ema
|
||
}
|
||
|
||
return ema
|
||
}
|
||
|
||
// calculateMACD calculates MACD
|
||
func calculateMACD(klines []Kline) float64 {
|
||
if len(klines) < 26 {
|
||
return 0
|
||
}
|
||
|
||
// Calculate 12-period and 26-period EMA
|
||
ema12 := calculateEMA(klines, 12)
|
||
ema26 := calculateEMA(klines, 26)
|
||
|
||
// MACD = EMA12 - EMA26
|
||
return ema12 - ema26
|
||
}
|
||
|
||
// calculateRSI calculates RSI
|
||
func calculateRSI(klines []Kline, period int) float64 {
|
||
if len(klines) <= period {
|
||
return 0
|
||
}
|
||
|
||
gains := 0.0
|
||
losses := 0.0
|
||
|
||
// Calculate initial average gain/loss
|
||
for i := 1; i <= period; i++ {
|
||
change := klines[i].Close - klines[i-1].Close
|
||
if change > 0 {
|
||
gains += change
|
||
} else {
|
||
losses += -change
|
||
}
|
||
}
|
||
|
||
avgGain := gains / float64(period)
|
||
avgLoss := losses / float64(period)
|
||
|
||
// Use Wilder smoothing method to calculate subsequent RSI
|
||
for i := period + 1; i < len(klines); i++ {
|
||
change := klines[i].Close - klines[i-1].Close
|
||
if change > 0 {
|
||
avgGain = (avgGain*float64(period-1) + change) / float64(period)
|
||
avgLoss = (avgLoss * float64(period-1)) / float64(period)
|
||
} else {
|
||
avgGain = (avgGain * float64(period-1)) / float64(period)
|
||
avgLoss = (avgLoss*float64(period-1) + (-change)) / float64(period)
|
||
}
|
||
}
|
||
|
||
if avgLoss == 0 {
|
||
return 100
|
||
}
|
||
|
||
rs := avgGain / avgLoss
|
||
rsi := 100 - (100 / (1 + rs))
|
||
|
||
return rsi
|
||
}
|
||
|
||
// calculateATR calculates ATR
|
||
func calculateATR(klines []Kline, period int) float64 {
|
||
if len(klines) <= period {
|
||
return 0
|
||
}
|
||
|
||
trs := make([]float64, len(klines))
|
||
for i := 1; i < len(klines); i++ {
|
||
high := klines[i].High
|
||
low := klines[i].Low
|
||
prevClose := klines[i-1].Close
|
||
|
||
tr1 := high - low
|
||
tr2 := math.Abs(high - prevClose)
|
||
tr3 := math.Abs(low - prevClose)
|
||
|
||
trs[i] = math.Max(tr1, math.Max(tr2, tr3))
|
||
}
|
||
|
||
// Calculate initial ATR
|
||
sum := 0.0
|
||
for i := 1; i <= period; i++ {
|
||
sum += trs[i]
|
||
}
|
||
atr := sum / float64(period)
|
||
|
||
// Wilder smoothing
|
||
for i := period + 1; i < len(klines); i++ {
|
||
atr = (atr*float64(period-1) + trs[i]) / float64(period)
|
||
}
|
||
|
||
return atr
|
||
}
|
||
|
||
// calculateBOLL calculates Bollinger Bands (upper, middle, lower)
|
||
// period: typically 20, multiplier: typically 2
|
||
func calculateBOLL(klines []Kline, period int, multiplier float64) (upper, middle, lower float64) {
|
||
if len(klines) < period {
|
||
return 0, 0, 0
|
||
}
|
||
|
||
// Calculate SMA (middle band)
|
||
sum := 0.0
|
||
for i := len(klines) - period; i < len(klines); i++ {
|
||
sum += klines[i].Close
|
||
}
|
||
sma := sum / float64(period)
|
||
|
||
// Calculate standard deviation
|
||
variance := 0.0
|
||
for i := len(klines) - period; i < len(klines); i++ {
|
||
diff := klines[i].Close - sma
|
||
variance += diff * diff
|
||
}
|
||
stdDev := math.Sqrt(variance / float64(period))
|
||
|
||
// Calculate bands
|
||
middle = sma
|
||
upper = sma + multiplier*stdDev
|
||
lower = sma - multiplier*stdDev
|
||
|
||
return upper, middle, lower
|
||
}
|
||
|
||
// 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
|
||
}
|
||
|
||
// getOpenInterestData retrieves OI data
|
||
func getOpenInterestData(symbol string) (*OIData, error) {
|
||
url := fmt.Sprintf("https://fapi.binance.com/fapi/v1/openInterest?symbol=%s", symbol)
|
||
|
||
apiClient := NewAPIClient()
|
||
resp, err := apiClient.client.Get(url)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
body, err := io.ReadAll(resp.Body)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
var result struct {
|
||
OpenInterest string `json:"openInterest"`
|
||
Symbol string `json:"symbol"`
|
||
Time int64 `json:"time"`
|
||
}
|
||
|
||
if err := json.Unmarshal(body, &result); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
oi, _ := strconv.ParseFloat(result.OpenInterest, 64)
|
||
|
||
return &OIData{
|
||
Latest: oi,
|
||
Average: oi * 0.999, // Approximate average
|
||
}, nil
|
||
}
|
||
|
||
// getFundingRate retrieves funding rate (optimized: uses 1-hour cache)
|
||
func getFundingRate(symbol string) (float64, error) {
|
||
// Check cache (1-hour validity)
|
||
// Funding Rate only updates every 8 hours, 1-hour cache is very reasonable
|
||
if cached, ok := fundingRateMap.Load(symbol); ok {
|
||
cache := cached.(*FundingRateCache)
|
||
if time.Since(cache.UpdatedAt) < frCacheTTL {
|
||
// Cache hit, return directly
|
||
return cache.Rate, nil
|
||
}
|
||
}
|
||
|
||
// Cache expired or doesn't exist, call API
|
||
url := fmt.Sprintf("https://fapi.binance.com/fapi/v1/premiumIndex?symbol=%s", symbol)
|
||
|
||
apiClient := NewAPIClient()
|
||
resp, err := apiClient.client.Get(url)
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
body, err := io.ReadAll(resp.Body)
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
|
||
var result struct {
|
||
Symbol string `json:"symbol"`
|
||
MarkPrice string `json:"markPrice"`
|
||
IndexPrice string `json:"indexPrice"`
|
||
LastFundingRate string `json:"lastFundingRate"`
|
||
NextFundingTime int64 `json:"nextFundingTime"`
|
||
InterestRate string `json:"interestRate"`
|
||
Time int64 `json:"time"`
|
||
}
|
||
|
||
if err := json.Unmarshal(body, &result); err != nil {
|
||
return 0, err
|
||
}
|
||
|
||
rate, _ := strconv.ParseFloat(result.LastFundingRate, 64)
|
||
|
||
// Update cache
|
||
fundingRateMap.Store(symbol, &FundingRateCache{
|
||
Rate: rate,
|
||
UpdatedAt: time.Now(),
|
||
})
|
||
|
||
return rate, nil
|
||
}
|
||
|
||
// Format formats and outputs market data
|
||
func Format(data *Data) string {
|
||
var sb strings.Builder
|
||
|
||
// Format price with dynamic precision
|
||
priceStr := formatPriceWithDynamicPrecision(data.CurrentPrice)
|
||
sb.WriteString(fmt.Sprintf("current_price = %s, current_ema20 = %.3f, current_macd = %.3f, current_rsi (7 period) = %.3f\n\n",
|
||
priceStr, data.CurrentEMA20, data.CurrentMACD, data.CurrentRSI7))
|
||
|
||
sb.WriteString(fmt.Sprintf("In addition, here is the latest %s open interest and funding rate for perps:\n\n",
|
||
data.Symbol))
|
||
|
||
if data.OpenInterest != nil {
|
||
// Format OI data with dynamic precision
|
||
oiLatestStr := formatPriceWithDynamicPrecision(data.OpenInterest.Latest)
|
||
oiAverageStr := formatPriceWithDynamicPrecision(data.OpenInterest.Average)
|
||
sb.WriteString(fmt.Sprintf("Open Interest: Latest: %s Average: %s\n\n",
|
||
oiLatestStr, oiAverageStr))
|
||
}
|
||
|
||
sb.WriteString(fmt.Sprintf("Funding Rate: %.2e\n\n", data.FundingRate))
|
||
|
||
if data.IntradaySeries != nil {
|
||
sb.WriteString("Intraday series (3‑minute intervals, oldest → latest):\n\n")
|
||
|
||
if len(data.IntradaySeries.MidPrices) > 0 {
|
||
sb.WriteString(fmt.Sprintf("Mid prices: %s\n\n", formatFloatSlice(data.IntradaySeries.MidPrices)))
|
||
}
|
||
|
||
if len(data.IntradaySeries.EMA20Values) > 0 {
|
||
sb.WriteString(fmt.Sprintf("EMA indicators (20‑period): %s\n\n", formatFloatSlice(data.IntradaySeries.EMA20Values)))
|
||
}
|
||
|
||
if len(data.IntradaySeries.MACDValues) > 0 {
|
||
sb.WriteString(fmt.Sprintf("MACD indicators: %s\n\n", formatFloatSlice(data.IntradaySeries.MACDValues)))
|
||
}
|
||
|
||
if len(data.IntradaySeries.RSI7Values) > 0 {
|
||
sb.WriteString(fmt.Sprintf("RSI indicators (7‑Period): %s\n\n", formatFloatSlice(data.IntradaySeries.RSI7Values)))
|
||
}
|
||
|
||
if len(data.IntradaySeries.RSI14Values) > 0 {
|
||
sb.WriteString(fmt.Sprintf("RSI indicators (14‑Period): %s\n\n", formatFloatSlice(data.IntradaySeries.RSI14Values)))
|
||
}
|
||
|
||
if len(data.IntradaySeries.Volume) > 0 {
|
||
sb.WriteString(fmt.Sprintf("Volume: %s\n\n", formatFloatSlice(data.IntradaySeries.Volume)))
|
||
}
|
||
|
||
sb.WriteString(fmt.Sprintf("3m ATR (14‑period): %.3f\n\n", data.IntradaySeries.ATR14))
|
||
}
|
||
|
||
if data.LongerTermContext != nil {
|
||
sb.WriteString("Longer‑term context (4‑hour timeframe):\n\n")
|
||
|
||
sb.WriteString(fmt.Sprintf("20‑Period EMA: %.3f vs. 50‑Period EMA: %.3f\n\n",
|
||
data.LongerTermContext.EMA20, data.LongerTermContext.EMA50))
|
||
|
||
sb.WriteString(fmt.Sprintf("3‑Period ATR: %.3f vs. 14‑Period ATR: %.3f\n\n",
|
||
data.LongerTermContext.ATR3, data.LongerTermContext.ATR14))
|
||
|
||
sb.WriteString(fmt.Sprintf("Current Volume: %.3f vs. Average Volume: %.3f\n\n",
|
||
data.LongerTermContext.CurrentVolume, data.LongerTermContext.AverageVolume))
|
||
|
||
if len(data.LongerTermContext.MACDValues) > 0 {
|
||
sb.WriteString(fmt.Sprintf("MACD indicators: %s\n\n", formatFloatSlice(data.LongerTermContext.MACDValues)))
|
||
}
|
||
|
||
if len(data.LongerTermContext.RSI14Values) > 0 {
|
||
sb.WriteString(fmt.Sprintf("RSI indicators (14‑Period): %s\n\n", formatFloatSlice(data.LongerTermContext.RSI14Values)))
|
||
}
|
||
}
|
||
|
||
// Multi-timeframe data (new)
|
||
if len(data.TimeframeData) > 0 {
|
||
// Output sorted by timeframe
|
||
timeframeOrder := []string{"1m", "3m", "5m", "15m", "30m", "1h", "2h", "4h", "6h", "8h", "12h", "1d", "3d", "1w"}
|
||
for _, tf := range timeframeOrder {
|
||
if tfData, ok := data.TimeframeData[tf]; ok {
|
||
sb.WriteString(fmt.Sprintf("=== %s Timeframe ===\n\n", strings.ToUpper(tf)))
|
||
formatTimeframeData(&sb, tfData)
|
||
}
|
||
}
|
||
}
|
||
|
||
return sb.String()
|
||
}
|
||
|
||
// formatTimeframeData formats data for a single timeframe
|
||
func formatTimeframeData(sb *strings.Builder, data *TimeframeSeriesData) {
|
||
// Use OHLCV table format if kline data is available
|
||
if len(data.Klines) > 0 {
|
||
sb.WriteString("Time(UTC) Open High Low Close Volume\n")
|
||
for i, k := range data.Klines {
|
||
t := time.Unix(k.Time/1000, 0).UTC()
|
||
timeStr := t.Format("01-02 15:04")
|
||
marker := ""
|
||
if i == len(data.Klines)-1 {
|
||
marker = " <- current"
|
||
}
|
||
sb.WriteString(fmt.Sprintf("%-14s %-9.4f %-9.4f %-9.4f %-9.4f %-12.2f%s\n",
|
||
timeStr, k.Open, k.High, k.Low, k.Close, k.Volume, marker))
|
||
}
|
||
sb.WriteString("\n")
|
||
} else if len(data.MidPrices) > 0 {
|
||
// Fallback to old format for backward compatibility
|
||
sb.WriteString(fmt.Sprintf("Mid prices: %s\n\n", formatFloatSlice(data.MidPrices)))
|
||
if len(data.Volume) > 0 {
|
||
sb.WriteString(fmt.Sprintf("Volume: %s\n\n", formatFloatSlice(data.Volume)))
|
||
}
|
||
}
|
||
|
||
// Technical indicators
|
||
if len(data.EMA20Values) > 0 {
|
||
sb.WriteString(fmt.Sprintf("EMA20: %s\n", formatFloatSlice(data.EMA20Values)))
|
||
}
|
||
|
||
if len(data.EMA50Values) > 0 {
|
||
sb.WriteString(fmt.Sprintf("EMA50: %s\n", formatFloatSlice(data.EMA50Values)))
|
||
}
|
||
|
||
if len(data.MACDValues) > 0 {
|
||
sb.WriteString(fmt.Sprintf("MACD: %s\n", formatFloatSlice(data.MACDValues)))
|
||
}
|
||
|
||
if len(data.RSI7Values) > 0 {
|
||
sb.WriteString(fmt.Sprintf("RSI7: %s\n", formatFloatSlice(data.RSI7Values)))
|
||
}
|
||
|
||
if len(data.RSI14Values) > 0 {
|
||
sb.WriteString(fmt.Sprintf("RSI14: %s\n", formatFloatSlice(data.RSI14Values)))
|
||
}
|
||
|
||
if data.ATR14 > 0 {
|
||
sb.WriteString(fmt.Sprintf("ATR14: %.4f\n", data.ATR14))
|
||
}
|
||
|
||
sb.WriteString("\n")
|
||
}
|
||
|
||
// formatPriceWithDynamicPrecision dynamically selects precision based on price range
|
||
// This perfectly supports all coins from ultra-low price meme coins (< 0.0001) to BTC/ETH
|
||
func formatPriceWithDynamicPrecision(price float64) string {
|
||
switch {
|
||
case price < 0.0001:
|
||
// Ultra-low price meme coins: 1000SATS, 1000WHY, DOGS
|
||
// 0.00002070 → "0.00002070" (8 decimal places)
|
||
return fmt.Sprintf("%.8f", price)
|
||
case price < 0.001:
|
||
// Low price meme coins: NEIRO, HMSTR, HOT, NOT
|
||
// 0.00015060 → "0.000151" (6 decimal places)
|
||
return fmt.Sprintf("%.6f", price)
|
||
case price < 0.01:
|
||
// Mid-low price coins: PEPE, SHIB, MEME
|
||
// 0.00556800 → "0.005568" (6 decimal places)
|
||
return fmt.Sprintf("%.6f", price)
|
||
case price < 1.0:
|
||
// Low price coins: ASTER, DOGE, ADA, TRX
|
||
// 0.9954 → "0.9954" (4 decimal places)
|
||
return fmt.Sprintf("%.4f", price)
|
||
case price < 100:
|
||
// Mid price coins: SOL, AVAX, LINK, MATIC
|
||
// 23.4567 → "23.4567" (4 decimal places)
|
||
return fmt.Sprintf("%.4f", price)
|
||
default:
|
||
// High price coins: BTC, ETH (save tokens)
|
||
// 45678.9123 → "45678.91" (2 decimal places)
|
||
return fmt.Sprintf("%.2f", price)
|
||
}
|
||
}
|
||
|
||
// formatFloatSlice formats float64 slice to string (using dynamic precision)
|
||
func formatFloatSlice(values []float64) string {
|
||
strValues := make([]string, len(values))
|
||
for i, v := range values {
|
||
strValues[i] = formatPriceWithDynamicPrecision(v)
|
||
}
|
||
return "[" + strings.Join(strValues, ", ") + "]"
|
||
}
|
||
|
||
// xyz dex assets that should NOT get USDT suffix
|
||
var xyzDexAssets = map[string]bool{
|
||
// Stocks
|
||
"TSLA": true, "NVDA": true, "AAPL": true, "MSFT": true, "META": true,
|
||
"AMZN": true, "GOOGL": true, "AMD": true, "COIN": true, "NFLX": true,
|
||
"PLTR": true, "HOOD": true, "INTC": true, "MSTR": true, "TSM": true,
|
||
"ORCL": true, "MU": true, "RIVN": true, "COST": true, "LLY": true,
|
||
"CRCL": true, "SKHX": true, "SNDK": true,
|
||
// Forex
|
||
"EUR": true, "JPY": true,
|
||
// Commodities
|
||
"GOLD": true, "SILVER": true,
|
||
// Index
|
||
"XYZ100": true,
|
||
}
|
||
|
||
// IsXyzDexAsset checks if a symbol is an xyz dex asset
|
||
func IsXyzDexAsset(symbol string) bool {
|
||
base := strings.ToUpper(symbol)
|
||
// Remove any prefix/suffix
|
||
base = strings.TrimPrefix(base, "XYZ:")
|
||
for _, suffix := range []string{"USDT", "USD", "-USDC"} {
|
||
if strings.HasSuffix(base, suffix) {
|
||
base = strings.TrimSuffix(base, suffix)
|
||
break
|
||
}
|
||
}
|
||
return xyzDexAssets[base]
|
||
}
|
||
|
||
// Normalize normalizes symbol
|
||
// For crypto: ensures it's a USDT trading pair
|
||
// For xyz dex assets (stocks, forex, commodities): uses xyz: prefix without USDT suffix
|
||
func Normalize(symbol string) string {
|
||
symbol = strings.ToUpper(symbol)
|
||
|
||
// Check if this is an xyz dex asset
|
||
if IsXyzDexAsset(symbol) {
|
||
// Remove any xyz: prefix (case-insensitive) and USDT suffix, then add xyz: prefix
|
||
base := symbol
|
||
// Handle both lowercase and uppercase xyz: prefix
|
||
if strings.HasPrefix(strings.ToLower(base), "xyz:") {
|
||
base = base[4:] // Remove first 4 characters ("xyz:")
|
||
}
|
||
for _, suffix := range []string{"USDT", "USD", "-USDC"} {
|
||
if strings.HasSuffix(base, suffix) {
|
||
base = strings.TrimSuffix(base, suffix)
|
||
break
|
||
}
|
||
}
|
||
return "xyz:" + base
|
||
}
|
||
|
||
// Remove exchange-specific separators (Gate uses BTC_USDT, OKX uses BTC-USDT-SWAP)
|
||
symbol = strings.ReplaceAll(symbol, "_", "")
|
||
symbol = strings.ReplaceAll(symbol, "-SWAP", "")
|
||
symbol = strings.ReplaceAll(symbol, "-", "")
|
||
|
||
// For regular crypto assets
|
||
if strings.HasSuffix(symbol, "USDT") {
|
||
return symbol
|
||
}
|
||
return symbol + "USDT"
|
||
}
|
||
|
||
// parseFloat parses float value
|
||
func parseFloat(v interface{}) (float64, error) {
|
||
switch val := v.(type) {
|
||
case string:
|
||
return strconv.ParseFloat(val, 64)
|
||
case float64:
|
||
return val, nil
|
||
case int:
|
||
return float64(val), nil
|
||
case int64:
|
||
return float64(val), nil
|
||
default:
|
||
return 0, fmt.Errorf("unsupported type: %T", v)
|
||
}
|
||
}
|
||
|
||
// BuildDataFromKlines constructs market data snapshot from preloaded K-line series (for backtesting/simulation).
|
||
func BuildDataFromKlines(symbol string, primary []Kline, longer []Kline) (*Data, error) {
|
||
if len(primary) == 0 {
|
||
return nil, fmt.Errorf("primary series is empty")
|
||
}
|
||
|
||
symbol = Normalize(symbol)
|
||
current := primary[len(primary)-1]
|
||
currentPrice := current.Close
|
||
|
||
data := &Data{
|
||
Symbol: symbol,
|
||
CurrentPrice: currentPrice,
|
||
CurrentEMA20: calculateEMA(primary, 20),
|
||
CurrentMACD: calculateMACD(primary),
|
||
CurrentRSI7: calculateRSI(primary, 7),
|
||
PriceChange1h: priceChangeFromSeries(primary, time.Hour),
|
||
PriceChange4h: priceChangeFromSeries(primary, 4*time.Hour),
|
||
OpenInterest: &OIData{Latest: 0, Average: 0},
|
||
FundingRate: 0,
|
||
IntradaySeries: calculateIntradaySeries(primary),
|
||
LongerTermContext: nil,
|
||
}
|
||
|
||
if len(longer) > 0 {
|
||
data.LongerTermContext = calculateLongerTermData(longer)
|
||
}
|
||
|
||
return data, nil
|
||
}
|
||
|
||
func priceChangeFromSeries(series []Kline, duration time.Duration) float64 {
|
||
if len(series) == 0 || duration <= 0 {
|
||
return 0
|
||
}
|
||
last := series[len(series)-1]
|
||
target := last.CloseTime - duration.Milliseconds()
|
||
for i := len(series) - 1; i >= 0; i-- {
|
||
if series[i].CloseTime <= target {
|
||
price := series[i].Close
|
||
if price > 0 {
|
||
return ((last.Close - price) / price) * 100
|
||
}
|
||
break
|
||
}
|
||
}
|
||
return 0
|
||
}
|
||
|
||
// isStaleData detects stale data (consecutive price freeze)
|
||
// Fix DOGEUSDT-style issue: consecutive N periods with completely unchanged prices indicate data source anomaly
|
||
func isStaleData(klines []Kline, symbol string) bool {
|
||
if len(klines) < 5 {
|
||
return false // Insufficient data to determine
|
||
}
|
||
|
||
// Detection threshold: 5 consecutive 3-minute periods with unchanged price (15 minutes without fluctuation)
|
||
const stalePriceThreshold = 5
|
||
const priceTolerancePct = 0.0001 // 0.01% fluctuation tolerance (avoid false positives)
|
||
|
||
// Take the last stalePriceThreshold K-lines
|
||
recentKlines := klines[len(klines)-stalePriceThreshold:]
|
||
firstPrice := recentKlines[0].Close
|
||
|
||
// Check if all prices are within tolerance
|
||
for i := 1; i < len(recentKlines); i++ {
|
||
priceDiff := math.Abs(recentKlines[i].Close-firstPrice) / firstPrice
|
||
if priceDiff > priceTolerancePct {
|
||
return false // Price fluctuation exists, data is normal
|
||
}
|
||
}
|
||
|
||
// Additional check: MACD and volume
|
||
// If price is unchanged but MACD/volume shows normal fluctuation, it might be a real market situation (extremely low volatility)
|
||
// Check if volume is also 0 (data completely frozen)
|
||
allVolumeZero := true
|
||
for _, k := range recentKlines {
|
||
if k.Volume > 0 {
|
||
allVolumeZero = false
|
||
break
|
||
}
|
||
}
|
||
|
||
if allVolumeZero {
|
||
logger.Infof("⚠️ %s stale data confirmed: price freeze + zero volume", symbol)
|
||
return true
|
||
}
|
||
|
||
// Price frozen but has volume: might be extremely low volatility market, allow but log warning
|
||
logger.Infof("⚠️ %s detected extreme price stability (no fluctuation for %d consecutive periods), but volume is normal", symbol, stalePriceThreshold)
|
||
return false
|
||
}
|
||
|
||
// ========== 导出的指标计算函数(供测试使用) ==========
|
||
|
||
// ExportCalculateEMA exports calculateEMA for testing
|
||
func ExportCalculateEMA(klines []Kline, period int) float64 {
|
||
return calculateEMA(klines, period)
|
||
}
|
||
|
||
// ExportCalculateMACD exports calculateMACD for testing
|
||
func ExportCalculateMACD(klines []Kline) float64 {
|
||
return calculateMACD(klines)
|
||
}
|
||
|
||
// ExportCalculateRSI exports calculateRSI for testing
|
||
func ExportCalculateRSI(klines []Kline, period int) float64 {
|
||
return calculateRSI(klines, period)
|
||
}
|
||
|
||
// ExportCalculateATR exports calculateATR for testing
|
||
func ExportCalculateATR(klines []Kline, period int) float64 {
|
||
return calculateATR(klines, period)
|
||
}
|
||
|
||
// ExportCalculateBOLL exports calculateBOLL for testing
|
||
func ExportCalculateBOLL(klines []Kline, period int, multiplier float64) (upper, middle, lower float64) {
|
||
return calculateBOLL(klines, period, multiplier)
|
||
}
|
||
|
||
// calculateDonchian calculates Donchian channel (highest high, lowest low) for given period
|
||
func calculateDonchian(klines []Kline, period int) (upper, lower float64) {
|
||
if len(klines) == 0 || period <= 0 {
|
||
return 0, 0
|
||
}
|
||
|
||
// Use all available klines if period > len(klines)
|
||
start := len(klines) - period
|
||
if start < 0 {
|
||
start = 0
|
||
}
|
||
|
||
upper = klines[start].High
|
||
lower = klines[start].Low
|
||
|
||
for i := start + 1; i < len(klines); i++ {
|
||
if klines[i].High > upper {
|
||
upper = klines[i].High
|
||
}
|
||
if klines[i].Low < lower {
|
||
lower = klines[i].Low
|
||
}
|
||
}
|
||
|
||
return upper, lower
|
||
}
|
||
|
||
// ExportCalculateDonchian exports calculateDonchian for testing
|
||
func ExportCalculateDonchian(klines []Kline, period int) (float64, float64) {
|
||
return calculateDonchian(klines, period)
|
||
}
|
||
|
||
// Box period constants (in 1h candles)
|
||
const (
|
||
ShortBoxPeriod = 72 // 3 days of 1h candles
|
||
MidBoxPeriod = 240 // 10 days of 1h candles
|
||
LongBoxPeriod = 500 // ~21 days of 1h candles
|
||
)
|
||
|
||
// calculateBoxData calculates multi-period box data from klines
|
||
func calculateBoxData(klines []Kline, currentPrice float64) *BoxData {
|
||
box := &BoxData{
|
||
CurrentPrice: currentPrice,
|
||
}
|
||
|
||
if len(klines) == 0 {
|
||
return box
|
||
}
|
||
|
||
box.ShortUpper, box.ShortLower = calculateDonchian(klines, ShortBoxPeriod)
|
||
box.MidUpper, box.MidLower = calculateDonchian(klines, MidBoxPeriod)
|
||
box.LongUpper, box.LongLower = calculateDonchian(klines, LongBoxPeriod)
|
||
|
||
return box
|
||
}
|
||
|
||
// ExportCalculateBoxData exports calculateBoxData for testing
|
||
func ExportCalculateBoxData(klines []Kline, currentPrice float64) *BoxData {
|
||
return calculateBoxData(klines, currentPrice)
|
||
}
|
||
|
||
// 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
|
||
}
|