KlineService.go 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272
  1. package services
  2. import (
  3. "context"
  4. "encoding/json"
  5. "errors"
  6. "fmt"
  7. "io"
  8. "math"
  9. "net"
  10. "net/http"
  11. "net/url"
  12. "strconv"
  13. "strings"
  14. "time"
  15. beego "github.com/beego/beego/v2/server/web"
  16. )
  17. type KlineItem struct {
  18. OpenTime int64 `json:"open_time"`
  19. Open string `json:"open"`
  20. High string `json:"high"`
  21. Low string `json:"low"`
  22. Close string `json:"close"`
  23. Volume string `json:"volume"`
  24. CloseTime int64 `json:"close_time"`
  25. QuoteAssetVolume string `json:"quote_asset_volume"`
  26. NumberOfTrades int64 `json:"number_of_trades"`
  27. TakerBuyBase string `json:"taker_buy_base"`
  28. TakerBuyQuote string `json:"taker_buy_quote"`
  29. }
  30. type KlineService struct{}
  31. func getIntConfig(key string, fallback int) int {
  32. raw, err := beego.AppConfig.String(key)
  33. if err != nil || strings.TrimSpace(raw) == "" {
  34. return fallback
  35. }
  36. v, err := strconv.Atoi(raw)
  37. if err != nil || v <= 0 {
  38. return fallback
  39. }
  40. return v
  41. }
  42. func getStringConfig(key, fallback string) string {
  43. v, err := beego.AppConfig.String(key)
  44. if err != nil || strings.TrimSpace(v) == "" {
  45. return fallback
  46. }
  47. return strings.TrimSpace(v)
  48. }
  49. func getBaseURLs() []string {
  50. // comma separated base urls, example:
  51. // https://api.binance.com,https://api1.binance.com,https://api2.binance.com
  52. raw := getStringConfig(
  53. "kline_api_base_urls",
  54. "https://api.binance.com,https://api1.binance.com,https://api2.binance.com,https://api3.binance.com",
  55. )
  56. parts := strings.Split(raw, ",")
  57. out := make([]string, 0, len(parts))
  58. for _, p := range parts {
  59. p = strings.TrimSpace(strings.TrimRight(p, "/"))
  60. if p != "" {
  61. out = append(out, p)
  62. }
  63. }
  64. if len(out) == 0 {
  65. return []string{"https://api.binance.com"}
  66. }
  67. return out
  68. }
  69. func getBoolConfig(key string, fallback bool) bool {
  70. raw, err := beego.AppConfig.String(key)
  71. if err != nil || strings.TrimSpace(raw) == "" {
  72. return fallback
  73. }
  74. v, err := strconv.ParseBool(strings.TrimSpace(raw))
  75. if err != nil {
  76. return fallback
  77. }
  78. return v
  79. }
  80. func newKlineHTTPClient(timeoutMS int) *http.Client {
  81. dialer := &net.Dialer{
  82. Timeout: time.Duration(timeoutMS) * time.Millisecond,
  83. KeepAlive: 30 * time.Second,
  84. }
  85. useSystemProxy := getBoolConfig("kline_use_system_proxy", false)
  86. var proxyFn func(*http.Request) (*url.URL, error)
  87. if useSystemProxy {
  88. proxyFn = http.ProxyFromEnvironment
  89. }
  90. transport := &http.Transport{
  91. Proxy: proxyFn,
  92. DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
  93. return dialer.DialContext(ctx, "tcp4", addr)
  94. },
  95. TLSHandshakeTimeout: time.Duration(timeoutMS) * time.Millisecond,
  96. IdleConnTimeout: 60 * time.Second,
  97. }
  98. return &http.Client{
  99. Timeout: time.Duration(timeoutMS) * time.Millisecond,
  100. Transport: transport,
  101. }
  102. }
  103. func normalizeKlineInterval(interval string) (string, error) {
  104. s := strings.TrimSpace(strings.ToLower(interval))
  105. switch s {
  106. case "hour", "h", "1h", "hourly":
  107. return "1h", nil
  108. case "day", "d", "1d", "daily":
  109. return "1d", nil
  110. case "week", "w", "1w", "weekly":
  111. return "1w", nil
  112. case "month", "m", "1m", "1mo", "1mon", "1month", "monthly":
  113. return "1M", nil
  114. default:
  115. return "", errors.New("interval must be one of: hour/day/week/month")
  116. }
  117. }
  118. func asString(v interface{}) string {
  119. s, ok := v.(string)
  120. if ok {
  121. return s
  122. }
  123. return fmt.Sprint(v)
  124. }
  125. func asInt64(v interface{}) int64 {
  126. switch vv := v.(type) {
  127. case int64:
  128. return vv
  129. case int:
  130. return int64(vv)
  131. case float64:
  132. // json.Unmarshal decodes numbers as float64 by default.
  133. return int64(math.Round(vv))
  134. case json.Number:
  135. n, _ := vv.Int64()
  136. return n
  137. case string:
  138. n, _ := strconv.ParseInt(strings.TrimSpace(vv), 10, 64)
  139. return n
  140. default:
  141. s := strings.TrimSpace(fmt.Sprint(v))
  142. if strings.ContainsAny(s, ".eE") {
  143. f, _ := strconv.ParseFloat(s, 64)
  144. return int64(math.Round(f))
  145. }
  146. n, _ := strconv.ParseInt(s, 10, 64)
  147. return n
  148. }
  149. }
  150. func (s *KlineService) FetchKlines(symbol, interval string, limit int, startTime, endTime int64) ([]KlineItem, error) {
  151. if strings.TrimSpace(symbol) == "" {
  152. symbol = "BTCUSDT"
  153. }
  154. symbol = strings.ToUpper(strings.TrimSpace(symbol))
  155. apiInterval, err := normalizeKlineInterval(interval)
  156. if err != nil {
  157. return nil, err
  158. }
  159. if limit <= 0 {
  160. limit = 200
  161. }
  162. if limit > 1000 {
  163. limit = 1000
  164. }
  165. timeoutMS := getIntConfig("kline_http_timeout_ms", 12000)
  166. retries := getIntConfig("kline_http_retries", 2)
  167. if retries < 1 {
  168. retries = 1
  169. }
  170. client := newKlineHTTPClient(timeoutMS)
  171. baseURLs := getBaseURLs()
  172. var body []byte
  173. var lastErr error
  174. for _, base := range baseURLs {
  175. for i := 0; i < retries; i++ {
  176. u, _ := url.Parse(base + "/api/v3/klines")
  177. q := u.Query()
  178. q.Set("symbol", symbol)
  179. q.Set("interval", apiInterval)
  180. q.Set("limit", strconv.Itoa(limit))
  181. if startTime > 0 {
  182. q.Set("startTime", strconv.FormatInt(startTime, 10))
  183. }
  184. if endTime > 0 {
  185. q.Set("endTime", strconv.FormatInt(endTime, 10))
  186. }
  187. u.RawQuery = q.Encode()
  188. req, err := http.NewRequest(http.MethodGet, u.String(), nil)
  189. if err != nil {
  190. lastErr = err
  191. continue
  192. }
  193. req.Header.Set("Accept", "application/json")
  194. req.Header.Set("User-Agent", "think-go-kline/1.0")
  195. resp, err := client.Do(req)
  196. if err != nil {
  197. lastErr = fmt.Errorf("request %s failed: %w", base, err)
  198. continue
  199. }
  200. body, err = io.ReadAll(resp.Body)
  201. resp.Body.Close()
  202. if err != nil {
  203. lastErr = fmt.Errorf("read %s response failed: %w", base, err)
  204. continue
  205. }
  206. if resp.StatusCode != http.StatusOK {
  207. lastErr = fmt.Errorf("kline api %s error: status=%d body=%s", base, resp.StatusCode, string(body))
  208. continue
  209. }
  210. lastErr = nil
  211. break
  212. }
  213. if lastErr == nil {
  214. break
  215. }
  216. }
  217. if lastErr != nil {
  218. return nil, fmt.Errorf("all kline endpoints failed: %w", lastErr)
  219. }
  220. var raw [][]interface{}
  221. if err := json.Unmarshal(body, &raw); err != nil {
  222. return nil, err
  223. }
  224. out := make([]KlineItem, 0, len(raw))
  225. for _, row := range raw {
  226. if len(row) < 11 {
  227. continue
  228. }
  229. out = append(out, KlineItem{
  230. OpenTime: asInt64(row[0]),
  231. Open: asString(row[1]),
  232. High: asString(row[2]),
  233. Low: asString(row[3]),
  234. Close: asString(row[4]),
  235. Volume: asString(row[5]),
  236. CloseTime: asInt64(row[6]),
  237. QuoteAssetVolume: asString(row[7]),
  238. NumberOfTrades: asInt64(row[8]),
  239. TakerBuyBase: asString(row[9]),
  240. TakerBuyQuote: asString(row[10]),
  241. })
  242. }
  243. return out, nil
  244. }