- 实时行情:东财push2主源 + 腾讯批量报价备用源(自动切换) - 历史K线:东财push2his主源 + 腾讯web子域备用源(含熔断机制) - 增量同步:支持 -incr/-since 参数按存档日期增量入库 - 策略回测:净值曲线/10指标/基准对比,含基本面过滤 - 用户认证:用户名密码 + 邮箱验证码双登录方式 - 前端:三Tab SPA(实时行情/策略回测/数据管理)
115 lines
3.4 KiB
Go
115 lines
3.4 KiB
Go
// Package eastmoney 实现 datasource.MarketDataSource 接口。
|
||
// 数据来源:push2.eastmoney.com(实时行情)、push2his.eastmoney.com(历史K线)。
|
||
// 注意:该接口为非官方免费接口,无 SLA 保障,使用需限速并做好容错。
|
||
package eastmoney
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"net/url"
|
||
"time"
|
||
)
|
||
|
||
const (
|
||
// requestInterval 请求间隔,避免高频调用被封 IP(东财对短时高频请求限流较严格)。
|
||
requestInterval = 2 * time.Second
|
||
// maxRetry 每个域名上的失败重试次数。
|
||
maxRetry = 2
|
||
)
|
||
|
||
// realTimeHosts 实时行情域名列表,主域名被限流时轮询备用(东财数字前缀节点)。
|
||
var realTimeHosts = []string{
|
||
"https://push2.eastmoney.com",
|
||
"https://16.push2.eastmoney.com",
|
||
"https://20.push2.eastmoney.com",
|
||
"https://30.push2.eastmoney.com",
|
||
"https://41.push2.eastmoney.com",
|
||
"https://54.push2.eastmoney.com",
|
||
"https://69.push2.eastmoney.com",
|
||
"https://82.push2.eastmoney.com",
|
||
}
|
||
|
||
// historyHosts 历史K线域名列表(主域名被限流时轮询备用节点)。
|
||
var historyHosts = []string{
|
||
"https://push2his.eastmoney.com",
|
||
"https://16.push2his.eastmoney.com",
|
||
"https://20.push2his.eastmoney.com",
|
||
"https://30.push2his.eastmoney.com",
|
||
"https://41.push2his.eastmoney.com",
|
||
"https://54.push2his.eastmoney.com",
|
||
"https://69.push2his.eastmoney.com",
|
||
"https://82.push2his.eastmoney.com",
|
||
}
|
||
|
||
// Client 是东方财富行情接口客户端,实现 datasource.MarketDataSource。
|
||
type Client struct {
|
||
http *http.Client
|
||
lastReq time.Time
|
||
}
|
||
|
||
// NewClient 创建东财行情客户端。
|
||
func NewClient() *Client {
|
||
return &Client{
|
||
http: &http.Client{Timeout: 15 * time.Second},
|
||
}
|
||
}
|
||
|
||
// getJSON 带限速与多域名轮询重试的 GET 请求,解析 JSON 到 out。
|
||
// hostIdx 标识域名列表(0 实时 / 1 历史),path 为接口路径(如 /api/qt/clist/get)。
|
||
func (c *Client) getJSON(ctx context.Context, hosts []string, path string, params url.Values, out any) error {
|
||
var lastErr error
|
||
for _, base := range hosts {
|
||
reqURL := base + path + "?" + params.Encode()
|
||
for attempt := 0; attempt < maxRetry; attempt++ {
|
||
if attempt > 0 {
|
||
// 退避:3s、6s
|
||
time.Sleep(time.Duration(3*(attempt+1)) * time.Second)
|
||
}
|
||
if err := c.fetch(ctx, reqURL, out); err != nil {
|
||
lastErr = err
|
||
continue
|
||
}
|
||
return nil
|
||
}
|
||
}
|
||
return fmt.Errorf("eastmoney request failed across all hosts: %w", lastErr)
|
||
}
|
||
|
||
// fetch 执行 HTTP GET 并解析 JSON。
|
||
func (c *Client) fetch(ctx context.Context, reqURL string, out any) error {
|
||
c.throttle()
|
||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
// 模拟浏览器 UA,降低被风控概率。
|
||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36")
|
||
req.Header.Set("Referer", "https://quote.eastmoney.com/")
|
||
|
||
resp, err := c.http.Do(req)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer resp.Body.Close()
|
||
if resp.StatusCode != http.StatusOK {
|
||
return fmt.Errorf("http status %d", resp.StatusCode)
|
||
}
|
||
body, err := io.ReadAll(resp.Body)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
return json.Unmarshal(body, out)
|
||
}
|
||
|
||
// throttle 保证请求间隔不小于 requestInterval。
|
||
func (c *Client) throttle() {
|
||
elapsed := time.Since(c.lastReq)
|
||
if wait := requestInterval - elapsed; wait > 0 {
|
||
time.Sleep(wait)
|
||
}
|
||
c.lastReq = time.Now()
|
||
}
|