// ============================================================================ // stockquote.go — 股票行情 // // 拉取腾讯行情、分时与日 K 数据;区分交易时段 / 休市刷新间隔。 // ============================================================================ package main import ( "encoding/json" "fmt" "io" "net/http" "strconv" "strings" "time" "golang.org/x/text/encoding/simplifiedchinese" // 腾讯行情接口返回 GBK,需要转码 "golang.org/x/text/transform" ) // 行情接口地址 const ( quoteAPIURL = "https://qt.gtimg.cn/q=" // 实时行情 minuteAPIURL = "https://web.ifzq.gtimg.cn/appstock/app/minute/query?code=" // 分时数据 klineAPIURL = "https://web.ifzq.gtimg.cn/appstock/app/fqkline/get?param=" // 日 K 数据 ) // StockQuote 单只股票的行情快照,供前端「股市」页展示 type StockQuote struct { Code string `json:"code"` // 带市场前缀的代码,如 sh600519 Name string `json:"name"` // 股票名称 Price float64 `json:"price"` // 当前价 Change float64 `json:"change"` // 涨跌额 ChangePercent float64 `json:"changePercent"` // 涨跌幅(%) Amount float64 `json:"amount"` // 成交额(万元) Time string `json:"time"` // 行情时间 HH:MM:SS } // MinutePoint 分时图上的一个点 type MinutePoint struct { Time string `json:"time"` // HH:MM Price float64 `json:"price"` // 当前价 Avg float64 `json:"avg"` // 均价 } // MinuteChart 分时图数据 type MinuteChart struct { Code string `json:"code"` // 股票代码 PrevClose float64 `json:"prevClose"` // 昨收,作为分时基准线 Points []MinutePoint `json:"points"` // 分时点序列 } // KLineBar 日 K 的一根柱子 type KLineBar struct { Date string `json:"date"` // 交易日 Open float64 `json:"open"` // 开盘价 Close float64 `json:"close"` // 收盘价 High float64 `json:"high"` // 最高价 Low float64 `json:"low"` // 最低价 Volume float64 `json:"volume"` // 成交量(手) } // normalizeStockCode 规范化股票代码:纯数字自动补全市场前缀 func normalizeStockCode(raw string) string { code := strings.ToLower(strings.TrimSpace(raw)) if code == "" { return "" } if strings.HasPrefix(code, "sh") || strings.HasPrefix(code, "sz") || strings.HasPrefix(code, "bj") { return code // 已带前缀,直接使用 } switch { case strings.HasPrefix(code, "6"), strings.HasPrefix(code, "5"), strings.HasPrefix(code, "9"): return "sh" + code // 沪市:60/68 等,也包含 5 开头基金、9 开头 B 股 case strings.HasPrefix(code, "0"), strings.HasPrefix(code, "3"): return "sz" + code // 深市:00 主板、30 创业板 case strings.HasPrefix(code, "4"), strings.HasPrefix(code, "8"): return "bj" + code // 北交所:43/83/87 等 } return code } // fetchStockQuotes 从腾讯行情接口批量拉取股票行情 func fetchStockQuotes(codes []string) ([]StockQuote, error) { if len(codes) == 0 { return []StockQuote{}, nil // 未配置股票时返回空列表 } url := quoteAPIURL + strings.Join(codes, ",") // 批量查询,逗号分隔 client := &http.Client{Timeout: 8 * time.Second} req, err := http.NewRequest(http.MethodGet, url, nil) if err != nil { return nil, err } req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64)") // 部分接口会校验 UA req.Header.Set("Referer", "https://gu.qq.com/") resp, err := client.Do(req) if err != nil { return nil, err } defer resp.Body.Close() // 腾讯接口为 GBK 编码,先整体转成 UTF-8 再解析 body, err := io.ReadAll(transform.NewReader(resp.Body, simplifiedchinese.GBK.NewDecoder())) if err != nil { return nil, err } quotes := make([]StockQuote, 0, len(codes)) for _, line := range strings.Split(string(body), ";") { // 每只股票一行,以分号分隔 quote, ok := parseQuoteLine(line) if ok { quotes = append(quotes, quote) } } return quotes, nil } // parseQuoteLine 解析单行行情:v_sh600519="1~贵州茅台~600519~价格~..."; func parseQuoteLine(line string) (StockQuote, bool) { line = strings.TrimSpace(line) idx := strings.Index(line, "=\"") if idx < 0 { return StockQuote{}, false } payload := strings.Trim(strings.TrimSpace(line[idx+2:]), `";`) // 去掉包裹的引号与分号 fields := strings.Split(payload, "~") if len(fields) < 38 { return StockQuote{}, false // 字段不足,视为无效行 } return StockQuote{ Code: fields[2], Name: fields[1], Price: parseQuoteFloat(fields[3]), Change: parseQuoteFloat(fields[31]), ChangePercent: parseQuoteFloat(fields[32]), Amount: parseQuoteFloat(fields[37]), // 成交额,单位:万元 Time: formatQuoteTime(fields[30]), }, true } // fetchMinuteChart 拉取分时数据:time 分值 price avg func fetchMinuteChart(code string) (MinuteChart, error) { chart := MinuteChart{Code: code, Points: []MinutePoint{}} // 先取报价,用「现价 - 涨跌额」得到昨收,作为分时基准线 quote, err := fetchSingleQuote(code) if err != nil { return chart, err } chart.PrevClose = quote.Price - quote.Change var payload minutePayload if err := httpGetJSON(minuteAPIURL+code, &payload); err != nil { return chart, err } item, ok := payload.Data[code] if !ok { return chart, fmt.Errorf("未获取到 %s 的分时数据", code) } var totalVolume, totalAmount float64 for _, line := range item.Data.Data { fields := strings.Fields(line) // 形如:0930 1349.99 1486 200608514.00 if len(fields) < 4 { continue } price := parseQuoteFloat(fields[1]) volume := parseQuoteFloat(fields[2]) // 成交量(手) amount := parseQuoteFloat(fields[3]) // 成交额(元) totalVolume += volume * 100 // 手 → 股 totalAmount += amount // 累计成交额 avg := 0.0 if totalVolume > 0 { avg = totalAmount / totalVolume // 均价 = 累计成交额 / 累计成交量 } chart.Points = append(chart.Points, MinutePoint{ Time: fields[0][0:2] + ":" + fields[0][2:4], // HHMM → HH:MM Price: price, Avg: avg, }) } return chart, nil } // fetchKLine 拉取日 K 数据(前复权),返回最近 count 根 func fetchKLine(code string, count int) ([]KLineBar, error) { if count <= 0 { count = 120 // 默认 120 个交易日 } url := fmt.Sprintf("%s%s,day,,,%d,qfq", klineAPIURL, code, count) var payload klinePayload if err := httpGetJSON(url, &payload); err != nil { return nil, err } item, ok := payload.Data[code] if !ok { return nil, fmt.Errorf("未获取到 %s 的日K数据", code) } rows := item.QfqDay // 前复权数据优先,没有则退回原始日线 if len(rows) == 0 { rows = item.Day } bars := make([]KLineBar, 0, len(rows)) for _, row := range rows { if len(row) < 6 { continue // 数据不完整则跳过 } // 前 6 项为 [日期, 开, 收, 高, 低, 量] 字符串;除权信息等附加字段忽略 vals := make([]string, 6) for i := 0; i < 6; i++ { if err := json.Unmarshal(row[i], &vals[i]); err != nil { vals[i] = "" // 字段非字符串时置空,交给 parseQuoteFloat 归零 } } bars = append(bars, KLineBar{ Date: vals[0], Open: parseQuoteFloat(vals[1]), Close: parseQuoteFloat(vals[2]), High: parseQuoteFloat(vals[3]), Low: parseQuoteFloat(vals[4]), Volume: parseQuoteFloat(vals[5]), }) } return bars, nil } // fetchSingleQuote 取单只股票报价(用于分时基准线) func fetchSingleQuote(code string) (StockQuote, error) { quotes, err := fetchStockQuotes([]string{code}) if err != nil { return StockQuote{}, err } if len(quotes) == 0 { return StockQuote{}, fmt.Errorf("未获取到 %s 的报价", code) } return quotes[0], nil } // httpGetJSON 发起 GET 请求并解析 JSON(腾讯 web 接口为 UTF-8) func httpGetJSON(url string, target any) error { client := &http.Client{Timeout: 8 * time.Second} req, err := http.NewRequest(http.MethodGet, url, nil) if err != nil { return err } req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64)") req.Header.Set("Referer", "https://gu.qq.com/") resp, err := client.Do(req) if err != nil { return err } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return err } return json.Unmarshal(body, target) } // minutePayload 分时接口的响应结构(按股票代码动态嵌套) type minutePayload struct { Data map[string]struct { Data struct { Data []string `json:"data"` // "HHMM 价格 成交量 成交额" } `json:"data"` } `json:"data"` } // klinePayload 日 K 接口的响应结构 type klinePayload struct { Data map[string]struct { // 前复权 / 原始日线。每行前 6 项固定为 [日期, 开, 收, 高, 低, 量] 字符串, // 除权除息日会追加第 7 个对象字段(除权信息),故用 RawMessage 以兼容任意类型。 QfqDay [][]json.RawMessage `json:"qfqday"` Day [][]json.RawMessage `json:"day"` } `json:"data"` } // parseQuoteFloat 解析浮点字段,失败时返回 0 func parseQuoteFloat(raw string) float64 { value, err := strconv.ParseFloat(strings.TrimSpace(raw), 64) if err != nil { return 0 } return value } // formatQuoteTime 将 20231028160001 格式化为 16:00:01 func formatQuoteTime(raw string) string { if len(raw) < 14 { return raw } return raw[8:10] + ":" + raw[10:12] + ":" + raw[12:14] }