| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272 |
- 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
- }
|