// Package tencent 实现 datasource.MarketDataSource 接口(腾讯财经行情源)。 // 历史K线来源:web.ifzq.gtimg.cn(fqkline 接口),作为东财接口被限流时的备用数据源。 // 注意:该接口为非官方免费接口,仅支持日K线,不支持全量转债列表。 package tencent import ( "context" "encoding/json" "errors" "fmt" "net/http" "strconv" "strings" "time" "stock-analysis/internal/model" ) const ( // klineHosts 腾讯历史K线接口域名列表。 // web 域名对深市转债返回 501,proxy 域名可用;按顺序轮询,失败自动切换。 klineHosts = "https://web.ifzq.gtimg.cn/appstock/app/fqkline/get|https://proxy.finance.qq.com/ifzqgtimg/appstock/app/fqkline/get" // batchSize 单次请求最大K线条数(腾讯上限约 640)。 batchSize = 640 ) // ErrConvertiblesUnsupported 腾讯行情源不支持全量转债列表。 var ErrConvertiblesUnsupported = errors.New("tencent datasource: convertibles list not supported") // fqklineResp 是腾讯 fqkline 接口响应结构。 // 除权除息日的K线会在第7列附加除权信息对象(如 {"fh_sh":"1.2"}), // 因此用 json.RawMessage 承接整行,解析时只取前6列行情字段。 type fqklineResp struct { Code int `json:"code"` Data map[string]struct { Day [][]json.RawMessage `json:"day"` QfqDay [][]json.RawMessage `json:"qfqday"` } `json:"data"` } // Client 是腾讯行情客户端,实现 datasource.MarketDataSource 的 K线能力。 type Client struct { http *http.Client } // NewClient 创建腾讯行情客户端。 func NewClient() *Client { return &Client{ http: &http.Client{Timeout: 15 * time.Second}, } } // FetchConvertibles 腾讯行情源不支持全量转债列表。 func (c *Client) FetchConvertibles(ctx context.Context) ([]model.ConvertibleBond, error) { return nil, ErrConvertiblesUnsupported } // FetchKline 拉取指定标的历史K线。 // code 为标的代码(如 110062),klt 仅支持日K("101"),beg/end 形如 20200101。 // 腾讯单次最多返回 batchSize(640) 根,超过时按日期窗口循环翻页。 // 返回按时间升序排列的K线;成交额腾讯接口不提供,置 0。 func (c *Client) FetchKline(ctx context.Context, code, klt, beg, end string) ([]model.Kline, error) { symbol := symbolOf(code) start := formatDate(beg) endDate := formatDate(end) var all []model.Kline for { reqURLs := make([]string, 0, 2) for _, host := range strings.Split(klineHosts, "|") { reqURLs = append(reqURLs, fmt.Sprintf("%s?param=%s,day,%s,%s,%d,qfq", host, symbol, start, endDate, batchSize)) } raw, err := c.fetchBatch(ctx, reqURLs) if err != nil { return nil, err } if len(raw) == 0 { break } for _, row := range raw { // 格式:[日期, 开盘, 收盘, 最高, 最低, 成交量(, 除权信息对象)] if len(row) < 6 { continue } k, err := parseRow(row) if err != nil { continue } all = append(all, k) } // 未满一批说明已到结尾;否则从最后日期次日继续翻页。 if len(raw) < batchSize { break } last := all[len(all)-1].Date next, err := nextDay(last) if err != nil { return nil, err } if next >= endDate { break } start = next } return all, nil } // fetchBatch 依次尝试各域名拉取一批K线。 // 全部域名失败返回错误;有域名返回空数据(如已退市债)视为正常,返回 nil。 func (c *Client) fetchBatch(ctx context.Context, reqURLs []string) ([][]json.RawMessage, error) { var lastErr error for _, reqURL := range reqURLs { var resp fqklineResp if err := c.get(ctx, reqURL, &resp); err != nil { lastErr = err continue } if resp.Code != 0 { lastErr = fmt.Errorf("tencent kline code=%d", resp.Code) continue } if len(resp.Data) == 0 { lastErr = fmt.Errorf("tencent kline empty data") continue } for _, bond := range resp.Data { if raw := bond.Day; len(raw) > 0 { return raw, nil } if raw := bond.QfqDay; len(raw) > 0 { return raw, nil } } // 域名返回成功但无K线(已退市债/未上市债):视为正常空数据。 return nil, nil } return nil, lastErr } // nextDay 返回 2006-01-02 格式日期的下一天。 func nextDay(date string) (string, error) { t, err := time.Parse("2006-01-02", date) if err != nil { return "", err } return t.AddDate(0, 0, 1).Format("2006-01-02"), nil } // symbolOf 根据代码生成腾讯标的符号(沪市 sh / 深市 sz)。 // 深市:12(可转债)、00/002/003(主板)、30(创业板)、20(B股);其余为沪市。 func symbolOf(code string) string { if strings.HasPrefix(code, "12") || strings.HasPrefix(code, "00") || strings.HasPrefix(code, "30") || strings.HasPrefix(code, "20") { return "sz" + code } return "sh" + code } // formatDate 将 20060102 转为腾讯接口要求的 2006-01-02。 func formatDate(d string) string { if len(d) != 8 { return d } return d[:4] + "-" + d[4:6] + "-" + d[6:8] } // parseRow 解析腾讯K线行数据(前6列;第7列起的除权信息对象忽略)。 func parseRow(row []json.RawMessage) (model.Kline, error) { var fields [6]string for i := 0; i < 6; i++ { if err := json.Unmarshal(row[i], &fields[i]); err != nil { return model.Kline{}, fmt.Errorf("bad tencent kline field %d: %w", i, err) } } open, err1 := strconv.ParseFloat(fields[1], 64) close_, err2 := strconv.ParseFloat(fields[2], 64) high, err3 := strconv.ParseFloat(fields[3], 64) low, err4 := strconv.ParseFloat(fields[4], 64) if err1 != nil || err2 != nil || err3 != nil || err4 != nil { return model.Kline{}, fmt.Errorf("bad tencent kline numbers") } // 腾讯成交量为字符串格式(如 "206806.000"),需先转 float 再取整。 vol, _ := strconv.ParseFloat(fields[5], 64) return model.Kline{ Date: fields[0], Open: open, Close: close_, High: high, Low: low, Volume: int64(vol), Amount: 0, // 腾讯接口不提供成交额 }, nil } // get 执行 HTTP GET 并解析 JSON。 func (c *Client) get(ctx context.Context, reqURL string, out any) error { 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") 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) } return json.NewDecoder(resp.Body).Decode(out) }