Files
stock-analysis/internal/datasource/tencent/client.go
WarrenZhou e28bfec957 Initial commit: 可转债轮动分析系统
- 实时行情:东财push2主源 + 腾讯批量报价备用源(自动切换)
- 历史K线:东财push2his主源 + 腾讯web子域备用源(含熔断机制)
- 增量同步:支持 -incr/-since 参数按存档日期增量入库
- 策略回测:净值曲线/10指标/基准对比,含基本面过滤
- 用户认证:用户名密码 + 邮箱验证码双登录方式
- 前端:三Tab SPA(实时行情/策略回测/数据管理)
2026-08-17 20:23:22 +08:00

218 lines
6.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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)
}