package tencent import ( "context" "fmt" "io" "net/http" "strconv" "strings" "time" "stock-analysis/internal/model" ) // quoteHost 腾讯实时行情接口域名(qt.gtimg.cn)。 // 支持批量请求:q=sh113667,sh110062,sz123001;单次建议不超过 100 只。 const quoteHost = "https://qt.gtimg.cn" // quoteBatchSize 腾讯实时行情单次请求的最大标的数量。 // 实测 50 只稳定,超过 100 可能触发限流。 const quoteBatchSize = 50 // quoteClient 腾讯实时行情专用 HTTP 客户端(与 K 线客户端隔离,避免共享限流计数)。 var quoteClient = &http.Client{Timeout: 10 * time.Second} // FetchQuotes 批量拉取转债实时报价,回填到 bonds 中各标的的 Price 和 ChangePct 字段。 // // 用途:作为东财 push2 实时行情接口故障时的备用报价源。 // 调用方(如 fallback 包)先用东财 datacenter 拿到转债列表,再用本方法补充实时价格。 // // 接口:https://qt.gtimg.cn/q=sh113667,sh110062,...(GET) // 响应每行一只,字段以 `~` 分隔,0 起始索引:1=名称, 2=代码, 3=当前价, 4=昨收, 31=涨跌幅。 // // 已退市/停牌/未上市转债当前价为 0,调用方可据此过滤。 func FetchQuotes(ctx context.Context, bonds []model.ConvertibleBond) error { if len(bonds) == 0 { return nil } for i := 0; i < len(bonds); i += quoteBatchSize { end := i + quoteBatchSize if end > len(bonds) { end = len(bonds) } batch := bonds[i:end] symbols := make([]string, 0, len(batch)) for _, b := range batch { symbols = append(symbols, symbolOf(b.Code)) } reqURL := fmt.Sprintf("%s/q=%s", quoteHost, strings.Join(symbols, ",")) body, err := httpGet(ctx, quoteClient, reqURL) if err != nil { // 单批失败不中断整体流程,已退市标的会被调用方按 price=0 过滤。 continue } parseQuoteBatch(body, batch) } return nil } // parseQuoteBatch 解析腾讯批量实时行情响应,回填到 batch 中各转债的 Price/ChangePct 字段。 // // 响应格式(每行一只,以 `;` 分隔): // // v_sh113667="1~春23转债~113667~210.976~214.702~...~-3.726~-1.74~..."; // v_sz123001="51~蓝标转债~123001~135.998~135.998~...~0.000~0.00~..."; // // 字段以 `~` 分隔,0 起始索引: // // 0: 市场标识(1=沪, 51=深), 1: 名称, 2: 代码, 3: 当前价, 4: 昨收 // 30: 时间戳(YYYYMMDDHHmmss), 31: 涨跌额, 32: 涨跌幅(%) // 腾讯按 q 参数顺序返回,行数与 batch 对应(无数据标的返回 "v_=;")。 func parseQuoteBatch(body string, batch []model.ConvertibleBond) { // 腾讯响应以 ";\n" 或 ";" 分隔,统一去重空行 body = strings.ReplaceAll(body, "\r\n", "\n") body = strings.ReplaceAll(body, "\n", "") lines := strings.Split(body, ";") for i := range batch { if i >= len(lines) { break } price, chgPct := parseQuoteLine(lines[i]) if price > 0 { batch[i].Price = price batch[i].ChangePct = chgPct } } } // parseQuoteLine 解析单行腾讯实时行情,返回 (当前价, 涨跌幅%)。 // 无法解析或无数据返回 (0, 0)。 func parseQuoteLine(line string) (price, changePct float64) { // 行格式:v_sh113667="1~春23转债~113667~210.976~..."; start := strings.Index(line, "\"") if start < 0 { return 0, 0 } end := strings.LastIndex(line, "\"") if end <= start { return 0, 0 } content := line[start+1 : end] fields := strings.Split(content, "~") // 字段:0=市场标识, 1=名称, 2=代码, 3=当前价, 4=昨收, ..., 31=涨跌额, 32=涨跌幅 if len(fields) < 33 { return 0, 0 } price, _ = strconv.ParseFloat(fields[3], 64) changePct, _ = strconv.ParseFloat(fields[32], 64) return price, changePct } // httpGet 执行 GET 请求并返回响应体字符串。 func httpGet(ctx context.Context, client *http.Client, url string) (string, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return "", err } 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 := client.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 string(body), nil }