package services import ( "context" "encoding/json" "errors" "fmt" "io" "math" "net" "net/http" "net/url" "strconv" "strings" "time" beego "github.com/beego/beego/v2/server/web" ) type KlineItem struct { OpenTime int64 `json:"open_time"` Open string `json:"open"` High string `json:"high"` Low string `json:"low"` Close string `json:"close"` Volume string `json:"volume"` CloseTime int64 `json:"close_time"` QuoteAssetVolume string `json:"quote_asset_volume"` NumberOfTrades int64 `json:"number_of_trades"` TakerBuyBase string `json:"taker_buy_base"` TakerBuyQuote string `json:"taker_buy_quote"` } type KlineService struct{} func getIntConfig(key string, fallback int) int { raw, err := beego.AppConfig.String(key) if err != nil || strings.TrimSpace(raw) == "" { return fallback } v, err := strconv.Atoi(raw) if err != nil || v <= 0 { return fallback } return v } func getStringConfig(key, fallback string) string { v, err := beego.AppConfig.String(key) if err != nil || strings.TrimSpace(v) == "" { return fallback } return strings.TrimSpace(v) } func getBaseURLs() []string { // comma separated base urls, example: // https://api.binance.com,https://api1.binance.com,https://api2.binance.com raw := getStringConfig( "kline_api_base_urls", "https://api.binance.com,https://api1.binance.com,https://api2.binance.com,https://api3.binance.com", ) parts := strings.Split(raw, ",") out := make([]string, 0, len(parts)) for _, p := range parts { p = strings.TrimSpace(strings.TrimRight(p, "/")) if p != "" { out = append(out, p) } } if len(out) == 0 { return []string{"https://api.binance.com"} } return out } func getBoolConfig(key string, fallback bool) bool { raw, err := beego.AppConfig.String(key) if err != nil || strings.TrimSpace(raw) == "" { return fallback } v, err := strconv.ParseBool(strings.TrimSpace(raw)) if err != nil { return fallback } return v } func newKlineHTTPClient(timeoutMS int) *http.Client { dialer := &net.Dialer{ Timeout: time.Duration(timeoutMS) * time.Millisecond, KeepAlive: 30 * time.Second, } useSystemProxy := getBoolConfig("kline_use_system_proxy", false) var proxyFn func(*http.Request) (*url.URL, error) if useSystemProxy { proxyFn = http.ProxyFromEnvironment } transport := &http.Transport{ Proxy: proxyFn, DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { return dialer.DialContext(ctx, "tcp4", addr) }, TLSHandshakeTimeout: time.Duration(timeoutMS) * time.Millisecond, IdleConnTimeout: 60 * time.Second, } return &http.Client{ Timeout: time.Duration(timeoutMS) * time.Millisecond, Transport: transport, } } func normalizeKlineInterval(interval string) (string, error) { s := strings.TrimSpace(strings.ToLower(interval)) switch s { case "hour", "h", "1h", "hourly": return "1h", nil case "day", "d", "1d", "daily": return "1d", nil case "week", "w", "1w", "weekly": return "1w", nil case "month", "m", "1m", "1mo", "1mon", "1month", "monthly": return "1M", nil default: return "", errors.New("interval must be one of: hour/day/week/month") } } func asString(v interface{}) string { s, ok := v.(string) if ok { return s } return fmt.Sprint(v) } func asInt64(v interface{}) int64 { switch vv := v.(type) { case int64: return vv case int: return int64(vv) case float64: // json.Unmarshal decodes numbers as float64 by default. return int64(math.Round(vv)) case json.Number: n, _ := vv.Int64() return n case string: n, _ := strconv.ParseInt(strings.TrimSpace(vv), 10, 64) return n default: s := strings.TrimSpace(fmt.Sprint(v)) if strings.ContainsAny(s, ".eE") { f, _ := strconv.ParseFloat(s, 64) return int64(math.Round(f)) } n, _ := strconv.ParseInt(s, 10, 64) return n } } func (s *KlineService) FetchKlines(symbol, interval string, limit int, startTime, endTime int64) ([]KlineItem, error) { if strings.TrimSpace(symbol) == "" { symbol = "BTCUSDT" } symbol = strings.ToUpper(strings.TrimSpace(symbol)) apiInterval, err := normalizeKlineInterval(interval) if err != nil { return nil, err } if limit <= 0 { limit = 200 } if limit > 1000 { limit = 1000 } timeoutMS := getIntConfig("kline_http_timeout_ms", 12000) retries := getIntConfig("kline_http_retries", 2) if retries < 1 { retries = 1 } client := newKlineHTTPClient(timeoutMS) baseURLs := getBaseURLs() var body []byte var lastErr error for _, base := range baseURLs { for i := 0; i < retries; i++ { u, _ := url.Parse(base + "/api/v3/klines") q := u.Query() q.Set("symbol", symbol) q.Set("interval", apiInterval) q.Set("limit", strconv.Itoa(limit)) if startTime > 0 { q.Set("startTime", strconv.FormatInt(startTime, 10)) } if endTime > 0 { q.Set("endTime", strconv.FormatInt(endTime, 10)) } u.RawQuery = q.Encode() req, err := http.NewRequest(http.MethodGet, u.String(), nil) if err != nil { lastErr = err continue } req.Header.Set("Accept", "application/json") req.Header.Set("User-Agent", "think-go-kline/1.0") resp, err := client.Do(req) if err != nil { lastErr = fmt.Errorf("request %s failed: %w", base, err) continue } body, err = io.ReadAll(resp.Body) resp.Body.Close() if err != nil { lastErr = fmt.Errorf("read %s response failed: %w", base, err) continue } if resp.StatusCode != http.StatusOK { lastErr = fmt.Errorf("kline api %s error: status=%d body=%s", base, resp.StatusCode, string(body)) continue } lastErr = nil break } if lastErr == nil { break } } if lastErr != nil { return nil, fmt.Errorf("all kline endpoints failed: %w", lastErr) } var raw [][]interface{} if err := json.Unmarshal(body, &raw); err != nil { return nil, err } out := make([]KlineItem, 0, len(raw)) for _, row := range raw { if len(row) < 11 { continue } out = append(out, KlineItem{ OpenTime: asInt64(row[0]), Open: asString(row[1]), High: asString(row[2]), Low: asString(row[3]), Close: asString(row[4]), Volume: asString(row[5]), CloseTime: asInt64(row[6]), QuoteAssetVolume: asString(row[7]), NumberOfTrades: asInt64(row[8]), TakerBuyBase: asString(row[9]), TakerBuyQuote: asString(row[10]), }) } return out, nil }