Initial commit: 可转债轮动分析系统

- 实时行情:东财push2主源 + 腾讯批量报价备用源(自动切换)
- 历史K线:东财push2his主源 + 腾讯web子域备用源(含熔断机制)
- 增量同步:支持 -incr/-since 参数按存档日期增量入库
- 策略回测:净值曲线/10指标/基准对比,含基本面过滤
- 用户认证:用户名密码 + 邮箱验证码双登录方式
- 前端:三Tab SPA(实时行情/策略回测/数据管理)
This commit is contained in:
2026-08-17 20:23:22 +08:00
commit e28bfec957
45 changed files with 7889 additions and 0 deletions

View File

@@ -0,0 +1,217 @@
// 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)
}

View File

@@ -0,0 +1,137 @@
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
}