Initial commit: 可转债轮动分析系统
- 实时行情:东财push2主源 + 腾讯批量报价备用源(自动切换) - 历史K线:东财push2his主源 + 腾讯web子域备用源(含熔断机制) - 增量同步:支持 -incr/-since 参数按存档日期增量入库 - 策略回测:净值曲线/10指标/基准对比,含基本面过滤 - 用户认证:用户名密码 + 邮箱验证码双登录方式 - 前端:三Tab SPA(实时行情/策略回测/数据管理)
This commit is contained in:
17
internal/datasource/datasource.go
Normal file
17
internal/datasource/datasource.go
Normal file
@@ -0,0 +1,17 @@
|
||||
// Package datasource 定义市场数据访问接口(MVC Model 层),
|
||||
// 具体实现位于子包(如 eastmoney),便于替换数据源或添加缓存。
|
||||
package datasource
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"stock-analysis/internal/model"
|
||||
)
|
||||
|
||||
// MarketDataSource 行情数据源接口。
|
||||
type MarketDataSource interface {
|
||||
// FetchConvertibles 返回全量可转债实时行情。
|
||||
FetchConvertibles(ctx context.Context) ([]model.ConvertibleBond, error)
|
||||
// FetchKline 返回指定标的的历史K线,按时间升序排列。
|
||||
FetchKline(ctx context.Context, code, klt, beg, end string) ([]model.Kline, error)
|
||||
}
|
||||
114
internal/datasource/eastmoney/client.go
Normal file
114
internal/datasource/eastmoney/client.go
Normal file
@@ -0,0 +1,114 @@
|
||||
// Package eastmoney 实现 datasource.MarketDataSource 接口。
|
||||
// 数据来源:push2.eastmoney.com(实时行情)、push2his.eastmoney.com(历史K线)。
|
||||
// 注意:该接口为非官方免费接口,无 SLA 保障,使用需限速并做好容错。
|
||||
package eastmoney
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// requestInterval 请求间隔,避免高频调用被封 IP(东财对短时高频请求限流较严格)。
|
||||
requestInterval = 2 * time.Second
|
||||
// maxRetry 每个域名上的失败重试次数。
|
||||
maxRetry = 2
|
||||
)
|
||||
|
||||
// realTimeHosts 实时行情域名列表,主域名被限流时轮询备用(东财数字前缀节点)。
|
||||
var realTimeHosts = []string{
|
||||
"https://push2.eastmoney.com",
|
||||
"https://16.push2.eastmoney.com",
|
||||
"https://20.push2.eastmoney.com",
|
||||
"https://30.push2.eastmoney.com",
|
||||
"https://41.push2.eastmoney.com",
|
||||
"https://54.push2.eastmoney.com",
|
||||
"https://69.push2.eastmoney.com",
|
||||
"https://82.push2.eastmoney.com",
|
||||
}
|
||||
|
||||
// historyHosts 历史K线域名列表(主域名被限流时轮询备用节点)。
|
||||
var historyHosts = []string{
|
||||
"https://push2his.eastmoney.com",
|
||||
"https://16.push2his.eastmoney.com",
|
||||
"https://20.push2his.eastmoney.com",
|
||||
"https://30.push2his.eastmoney.com",
|
||||
"https://41.push2his.eastmoney.com",
|
||||
"https://54.push2his.eastmoney.com",
|
||||
"https://69.push2his.eastmoney.com",
|
||||
"https://82.push2his.eastmoney.com",
|
||||
}
|
||||
|
||||
// Client 是东方财富行情接口客户端,实现 datasource.MarketDataSource。
|
||||
type Client struct {
|
||||
http *http.Client
|
||||
lastReq time.Time
|
||||
}
|
||||
|
||||
// NewClient 创建东财行情客户端。
|
||||
func NewClient() *Client {
|
||||
return &Client{
|
||||
http: &http.Client{Timeout: 15 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// getJSON 带限速与多域名轮询重试的 GET 请求,解析 JSON 到 out。
|
||||
// hostIdx 标识域名列表(0 实时 / 1 历史),path 为接口路径(如 /api/qt/clist/get)。
|
||||
func (c *Client) getJSON(ctx context.Context, hosts []string, path string, params url.Values, out any) error {
|
||||
var lastErr error
|
||||
for _, base := range hosts {
|
||||
reqURL := base + path + "?" + params.Encode()
|
||||
for attempt := 0; attempt < maxRetry; attempt++ {
|
||||
if attempt > 0 {
|
||||
// 退避:3s、6s
|
||||
time.Sleep(time.Duration(3*(attempt+1)) * time.Second)
|
||||
}
|
||||
if err := c.fetch(ctx, reqURL, out); err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("eastmoney request failed across all hosts: %w", lastErr)
|
||||
}
|
||||
|
||||
// fetch 执行 HTTP GET 并解析 JSON。
|
||||
func (c *Client) fetch(ctx context.Context, reqURL string, out any) error {
|
||||
c.throttle()
|
||||
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")
|
||||
req.Header.Set("Referer", "https://quote.eastmoney.com/")
|
||||
|
||||
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)
|
||||
}
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return json.Unmarshal(body, out)
|
||||
}
|
||||
|
||||
// throttle 保证请求间隔不小于 requestInterval。
|
||||
func (c *Client) throttle() {
|
||||
elapsed := time.Since(c.lastReq)
|
||||
if wait := requestInterval - elapsed; wait > 0 {
|
||||
time.Sleep(wait)
|
||||
}
|
||||
c.lastReq = time.Now()
|
||||
}
|
||||
139
internal/datasource/eastmoney/convertible.go
Normal file
139
internal/datasource/eastmoney/convertible.go
Normal file
@@ -0,0 +1,139 @@
|
||||
package eastmoney
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"stock-analysis/internal/model"
|
||||
)
|
||||
|
||||
// clistResp 是 clist 接口响应结构。
|
||||
type clistResp struct {
|
||||
Data struct {
|
||||
Total int `json:"total"`
|
||||
Diff json.RawMessage `json:"diff"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
// rawBond 是 clist 接口返回的可转债原始字段。
|
||||
// 东财的值可能是数字或 "-" 字符串,数字字段统一用 json.RawMessage 容错。
|
||||
type rawBond struct {
|
||||
F2 json.RawMessage `json:"f2"` // 转债最新价
|
||||
F3 json.RawMessage `json:"f3"` // 转债涨跌幅
|
||||
F12 string `json:"f12"` // 转债代码
|
||||
F14 string `json:"f14"` // 转债名称
|
||||
F26 json.RawMessage `json:"f26"` // 上市日期
|
||||
F227 json.RawMessage `json:"f227"` // 纯债价值
|
||||
F228 json.RawMessage `json:"f228"` // 正股最新价
|
||||
F230 json.RawMessage `json:"f230"` // 正股涨跌幅
|
||||
F232 string `json:"f232"` // 正股代码
|
||||
F234 string `json:"f234"` // 正股名称
|
||||
F235 json.RawMessage `json:"f235"` // 转股价
|
||||
F236 json.RawMessage `json:"f236"` // 转股价值
|
||||
F237 json.RawMessage `json:"f237"` // 转股溢价率
|
||||
F238 json.RawMessage `json:"f238"` // 纯债溢价率
|
||||
F239 json.RawMessage `json:"f239"` // 回售触发价
|
||||
F240 json.RawMessage `json:"f240"` // 强赎触发价
|
||||
}
|
||||
|
||||
// FetchConvertibles 分页拉取全量可转债实时行情。
|
||||
func (c *Client) FetchConvertibles(ctx context.Context) ([]model.ConvertibleBond, error) {
|
||||
const pageSize = 100 // 东财 clist 单页上限
|
||||
bonds := make([]model.ConvertibleBond, 0, 400)
|
||||
|
||||
for page := 1; ; page++ {
|
||||
params := url.Values{}
|
||||
params.Set("pn", strconv.Itoa(page))
|
||||
params.Set("pz", strconv.Itoa(pageSize))
|
||||
params.Set("po", "1")
|
||||
params.Set("np", "1")
|
||||
params.Set("fltt", "2")
|
||||
params.Set("invt", "2")
|
||||
params.Set("fid", "f3")
|
||||
params.Set("fs", "b:MK0354") // 可转债市场
|
||||
params.Set("fields", "f2,f3,f12,f14,f26,f227,f228,f230,f232,f234,f235,f236,f237,f238,f239,f240")
|
||||
|
||||
var resp clistResp
|
||||
if err := c.getJSON(ctx, realTimeHosts, "/api/qt/clist/get", params, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(resp.Data.Diff) == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
// diff 可能是数组,也可能(仅一条时)是对象。
|
||||
var items []json.RawMessage
|
||||
if isJSONArray(resp.Data.Diff) {
|
||||
if err := json.Unmarshal(resp.Data.Diff, &items); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
items = []json.RawMessage{resp.Data.Diff}
|
||||
}
|
||||
|
||||
for _, raw := range items {
|
||||
var b rawBond
|
||||
if err := json.Unmarshal(raw, &b); err != nil {
|
||||
continue
|
||||
}
|
||||
if b.F12 == "" {
|
||||
continue
|
||||
}
|
||||
bonds = append(bonds, model.ConvertibleBond{
|
||||
Code: b.F12,
|
||||
Name: b.F14,
|
||||
Price: parseFloat(b.F2),
|
||||
ChangePct: parseFloat(b.F3),
|
||||
ListDate: parseDate(b.F26),
|
||||
StockCode: b.F232,
|
||||
StockName: b.F234,
|
||||
StockPrice: parseFloat(b.F228),
|
||||
StockChangePct: parseFloat(b.F230),
|
||||
ConvertPrice: parseFloat(b.F235),
|
||||
ConvertValue: parseFloat(b.F236),
|
||||
PremiumRate: parseFloat(b.F237),
|
||||
PureBondVal: parseFloat(b.F227),
|
||||
PureBondPremium: parseFloat(b.F238),
|
||||
ResaleTrigPrice: parseFloat(b.F239),
|
||||
RedeemTrigPrice: parseFloat(b.F240),
|
||||
})
|
||||
}
|
||||
|
||||
// 拉满即停止。
|
||||
if len(bonds) >= resp.Data.Total {
|
||||
break
|
||||
}
|
||||
}
|
||||
return bonds, nil
|
||||
}
|
||||
|
||||
// isJSONArray 判断原始 JSON 是否为数组。
|
||||
func isJSONArray(raw json.RawMessage) bool {
|
||||
s := strings.TrimSpace(string(raw))
|
||||
return strings.HasPrefix(s, "[")
|
||||
}
|
||||
|
||||
// parseFloat 容错解析东财字段:数字、"-"、空串、null 均处理。
|
||||
func parseFloat(raw json.RawMessage) float64 {
|
||||
if len(raw) == 0 {
|
||||
return 0
|
||||
}
|
||||
s := strings.Trim(string(raw), `"`)
|
||||
if s == "" || s == "-" || s == "null" || s == "None" {
|
||||
return 0
|
||||
}
|
||||
v, _ := strconv.ParseFloat(s, 64)
|
||||
return v
|
||||
}
|
||||
|
||||
// parseDate 解析东财日期字段(如 20260812 -> 2006-01-02 格式)。
|
||||
func parseDate(raw json.RawMessage) string {
|
||||
s := strings.Trim(string(raw), `"`)
|
||||
if len(s) != 8 {
|
||||
return s
|
||||
}
|
||||
return s[:4] + "-" + s[4:6] + "-" + s[6:8]
|
||||
}
|
||||
140
internal/datasource/eastmoney/datacenter.go
Normal file
140
internal/datasource/eastmoney/datacenter.go
Normal file
@@ -0,0 +1,140 @@
|
||||
// Package eastmoney 东财数据源:datacenter(列表/基础信息)与 push2(实时/历史行情)。
|
||||
package eastmoney
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"stock-analysis/internal/model"
|
||||
)
|
||||
|
||||
// datacenterHost 东财数据中心接口域名(与 push2 分属不同集群,限流相对宽松)。
|
||||
const datacenterHost = "https://datacenter-web.eastmoney.com"
|
||||
|
||||
// datacenterResp 是 datacenter 列表接口响应结构。
|
||||
type datacenterResp struct {
|
||||
Result struct {
|
||||
Data []struct {
|
||||
SecurityCode string `json:"SECURITY_CODE"` // 转债代码
|
||||
SecurityName string `json:"SECURITY_NAME_ABBR"` // 转债简称
|
||||
BondStartDate string `json:"BOND_START_DATE"` // 发行日期
|
||||
ListingDate string `json:"LISTING_DATE"` // 上市日期(部分记录缺失)
|
||||
DelistDate string `json:"DELIST_DATE"` // 退市日期(未退市为空)
|
||||
ConvertStockCode string `json:"CONVERT_STOCK_CODE"` // 正股代码
|
||||
InitialTransferPice float64 `json:"INITIAL_TRANSFER_PRICE"` // 初始转股价
|
||||
} `json:"data"`
|
||||
} `json:"result"`
|
||||
Success bool `json:"success"`
|
||||
}
|
||||
|
||||
// FetchAllBondCodes 从 datacenter 分页拉取全量转债代码列表(含已退市,约 1000 只)。
|
||||
// 相比 push2 clist 接口不受实时行情集群限流影响,适合批量抓取场景。
|
||||
// 返回的 ConvertibleBond 含正股代码/初始转股价/上市退市日期。
|
||||
func (c *Client) FetchAllBondCodes(ctx context.Context) ([]model.ConvertibleBond, error) {
|
||||
const pageSize = 100
|
||||
var bonds []model.ConvertibleBond
|
||||
|
||||
for page := 1; ; page++ {
|
||||
params := url.Values{}
|
||||
params.Set("reportName", "RPT_BOND_CB_LIST")
|
||||
params.Set("columns", "SECURITY_CODE,SECURITY_NAME_ABBR,BOND_START_DATE,LISTING_DATE,DELIST_DATE,CONVERT_STOCK_CODE,INITIAL_TRANSFER_PRICE")
|
||||
params.Set("pageNumber", strconv.Itoa(page))
|
||||
params.Set("pageSize", strconv.Itoa(pageSize))
|
||||
|
||||
var resp datacenterResp
|
||||
if err := c.getJSON(ctx, []string{datacenterHost}, "/api/data/v1/get", params, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(resp.Result.Data) == 0 {
|
||||
break
|
||||
}
|
||||
for _, d := range resp.Result.Data {
|
||||
if d.SecurityCode == "" {
|
||||
continue
|
||||
}
|
||||
listDate := d.ListingDate
|
||||
if len(listDate) < 10 {
|
||||
listDate = d.BondStartDate // 兜底:部分老债缺上市日期
|
||||
}
|
||||
delistDate := ""
|
||||
if len(d.DelistDate) >= 10 {
|
||||
delistDate = d.DelistDate[:10]
|
||||
}
|
||||
bonds = append(bonds, model.ConvertibleBond{
|
||||
Code: d.SecurityCode,
|
||||
Name: d.SecurityName,
|
||||
StockCode: d.ConvertStockCode,
|
||||
InitialConvertPrice: d.InitialTransferPice,
|
||||
ListDate: listDate[:10],
|
||||
DelistDate: delistDate,
|
||||
})
|
||||
}
|
||||
if len(resp.Result.Data) < pageSize {
|
||||
break // 最后一页
|
||||
}
|
||||
}
|
||||
return bonds, nil
|
||||
}
|
||||
|
||||
// FetchLatestBonds 获取最近上市(已上市且有行情数据)的 n 只可转债。
|
||||
// 走 push2 clist 接口按上市日期(f26)倒序,过滤上市日期缺失及尚未上市(未来日期)的记录。
|
||||
func (c *Client) FetchLatestBonds(ctx context.Context, n int) ([]model.ConvertibleBond, error) {
|
||||
const fetchSize = 50 // 放宽单页数量,确保过滤后仍有足够样本
|
||||
params := url.Values{}
|
||||
params.Set("pn", "1")
|
||||
params.Set("pz", strconv.Itoa(fetchSize))
|
||||
params.Set("po", "1")
|
||||
params.Set("np", "1")
|
||||
params.Set("fltt", "2")
|
||||
params.Set("invt", "2")
|
||||
params.Set("fid", "f26") // 按上市日期排序
|
||||
params.Set("fs", "b:MK0354")
|
||||
params.Set("fields", "f12,f14,f26")
|
||||
|
||||
var resp clistResp
|
||||
if err := c.getJSON(ctx, realTimeHosts, "/api/qt/clist/get", params, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(resp.Data.Diff) == 0 {
|
||||
return nil, errors.New("eastmoney: empty clist")
|
||||
}
|
||||
|
||||
var items []json.RawMessage
|
||||
if isJSONArray(resp.Data.Diff) {
|
||||
if err := json.Unmarshal(resp.Data.Diff, &items); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
items = []json.RawMessage{resp.Data.Diff}
|
||||
}
|
||||
|
||||
today := time.Now().Format("2006-01-02")
|
||||
bonds := make([]model.ConvertibleBond, 0, n)
|
||||
for _, raw := range items {
|
||||
var b rawBond
|
||||
if err := json.Unmarshal(raw, &b); err != nil {
|
||||
continue
|
||||
}
|
||||
if b.F12 == "" {
|
||||
continue
|
||||
}
|
||||
listDate := parseDate(b.F26)
|
||||
// 跳过上市日期缺失或尚未上市(日期在未来)的转债。
|
||||
if listDate == "" || listDate > today {
|
||||
continue
|
||||
}
|
||||
bonds = append(bonds, model.ConvertibleBond{
|
||||
Code: b.F12,
|
||||
Name: b.F14,
|
||||
ListDate: listDate,
|
||||
})
|
||||
if len(bonds) >= n {
|
||||
break
|
||||
}
|
||||
}
|
||||
return bonds, nil
|
||||
}
|
||||
116
internal/datasource/eastmoney/fundamental.go
Normal file
116
internal/datasource/eastmoney/fundamental.go
Normal file
@@ -0,0 +1,116 @@
|
||||
package eastmoney
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"stock-analysis/internal/model"
|
||||
)
|
||||
|
||||
// datacenterFundamentalResp 是 datacenter 财务数据接口响应。
|
||||
type datacenterFundamentalResp struct {
|
||||
Result struct {
|
||||
Pages int `json:"pages"`
|
||||
Data []datacenterFundamentalItem `json:"data"`
|
||||
} `json:"result"`
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// datacenterFundamentalItem 是单条财务数据记录。
|
||||
type datacenterFundamentalItem struct {
|
||||
SecurityCode string `json:"SECURITY_CODE"`
|
||||
SecurityName string `json:"SECURITY_NAME_ABBR"`
|
||||
ROE *float64 `json:"ROEJQ"` // 加权净资产收益率(%)
|
||||
DebtRatio *float64 `json:"ZCFZL"` // 资产负债率(%)
|
||||
NetProfit *float64 `json:"PARENTNETPROFIT"` // 归属净利润(元)
|
||||
IsBZ string `json:"IS_BZ"` // ST状态:0=正常, 1=ST, 2=*ST
|
||||
ReportDate string `json:"REPORT_DATE"`
|
||||
}
|
||||
|
||||
// FetchStockFundamentals 批量拉取指定正股的最新年度基本面数据。
|
||||
// 使用 datacenter API(非 push2),按 REPORT_DATE 倒序取每只股票的最新一条年报。
|
||||
func (c *Client) FetchStockFundamentals(ctx context.Context, codes []string) ([]model.StockFundamental, error) {
|
||||
if len(codes) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
codeSet := make(map[string]bool, len(codes))
|
||||
for _, code := range codes {
|
||||
codeSet[code] = true
|
||||
}
|
||||
|
||||
const pageSize = 5000
|
||||
seen := make(map[string]bool, len(codes))
|
||||
var funds []model.StockFundamental
|
||||
|
||||
// 按 REPORT_DATE 倒序拉取,每只股票取第一条(最新年报)。
|
||||
for page := 1; page <= 3; page++ {
|
||||
params := url.Values{}
|
||||
params.Set("reportName", "RPT_F10_FINANCE_MAINFINADATA")
|
||||
params.Set("columns", "SECURITY_CODE,SECURITY_NAME_ABBR,ROEJQ,ZCFZL,PARENTNETPROFIT,IS_BZ,REPORT_DATE")
|
||||
params.Set("filter", `(REPORT_TYPE="年报")`)
|
||||
params.Set("pageNumber", strconv.Itoa(page))
|
||||
params.Set("pageSize", strconv.Itoa(pageSize))
|
||||
params.Set("sortColumns", "REPORT_DATE")
|
||||
params.Set("sortTypes", "-1")
|
||||
|
||||
var resp datacenterFundamentalResp
|
||||
if err := c.getJSON(ctx, []string{datacenterHost}, "/api/data/v1/get", params, &resp); err != nil {
|
||||
return nil, fmt.Errorf("datacenter page %d: %w", page, err)
|
||||
}
|
||||
if !resp.Success {
|
||||
return nil, fmt.Errorf("datacenter API error: %s", resp.Message)
|
||||
}
|
||||
if len(resp.Result.Data) == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
for _, d := range resp.Result.Data {
|
||||
code := d.SecurityCode
|
||||
if code == "" || seen[code] || !codeSet[code] {
|
||||
continue
|
||||
}
|
||||
seen[code] = true
|
||||
|
||||
roe := 0.0
|
||||
if d.ROE != nil {
|
||||
roe = *d.ROE
|
||||
}
|
||||
debtRatio := 0.0
|
||||
if d.DebtRatio != nil {
|
||||
debtRatio = *d.DebtRatio
|
||||
}
|
||||
netProfit := 0.0
|
||||
if d.NetProfit != nil {
|
||||
netProfit = *d.NetProfit
|
||||
}
|
||||
status := 1
|
||||
if d.IsBZ == "1" || d.IsBZ == "2" {
|
||||
status = 2
|
||||
}
|
||||
|
||||
updateDate := strings.Split(d.ReportDate, " ")[0]
|
||||
|
||||
funds = append(funds, model.StockFundamental{
|
||||
Code: code,
|
||||
Name: d.SecurityName,
|
||||
ROE: roe,
|
||||
DebtRatio: debtRatio,
|
||||
NetProfit: netProfit,
|
||||
Status: status,
|
||||
UpdateDate: updateDate,
|
||||
})
|
||||
}
|
||||
|
||||
// 已找到全部目标股票,停止翻页。
|
||||
if len(seen) >= len(codes) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return funds, nil
|
||||
}
|
||||
99
internal/datasource/eastmoney/kline.go
Normal file
99
internal/datasource/eastmoney/kline.go
Normal file
@@ -0,0 +1,99 @@
|
||||
package eastmoney
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"stock-analysis/internal/model"
|
||||
)
|
||||
|
||||
// Kline 周期常量(东财 klt 参数)。
|
||||
const (
|
||||
KltDay = "101" // 日K
|
||||
KltWeek = "102" // 周K
|
||||
KltMonth = "103" // 月K
|
||||
)
|
||||
|
||||
// klineResp 是 K线接口响应结构。
|
||||
type klineResp struct {
|
||||
Data *struct {
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
Klines []string `json:"klines"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
// FetchKline 拉取指定标的历史K线。
|
||||
// code 为标的代码(如 113667),klt 为周期,beg/end 形如 20200101。
|
||||
// 返回按时间升序排列的K线。
|
||||
func (c *Client) FetchKline(ctx context.Context, code, klt, beg, end string) ([]model.Kline, error) {
|
||||
secid := secidOf(code)
|
||||
params := url.Values{}
|
||||
params.Set("secid", secid)
|
||||
params.Set("klt", klt)
|
||||
params.Set("fqt", "0") // 不复权(转债回测用不复权价)
|
||||
params.Set("beg", beg)
|
||||
params.Set("end", end)
|
||||
params.Set("fields1", "f1,f2,f3,f4,f5,f6")
|
||||
params.Set("fields2", "f51,f52,f53,f54,f55,f56,f57")
|
||||
|
||||
var resp klineResp
|
||||
if err := c.getJSON(ctx, historyHosts, "/api/qt/stock/kline/get", params, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.Data == nil || len(resp.Data.Klines) == 0 {
|
||||
return nil, fmt.Errorf("kline empty: secid=%s", secid)
|
||||
}
|
||||
|
||||
klines := make([]model.Kline, 0, len(resp.Data.Klines))
|
||||
for _, line := range resp.Data.Klines {
|
||||
// 格式:日期,开,收,高,低,成交量,成交额
|
||||
parts := strings.Split(line, ",")
|
||||
if len(parts) < 7 {
|
||||
continue
|
||||
}
|
||||
k, err := parseKline(parts)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
klines = append(klines, k)
|
||||
}
|
||||
return klines, nil
|
||||
}
|
||||
|
||||
// secidOf 根据代码生成东财 secid。
|
||||
// 沪市(11/118 开头)前缀 1,深市(12 开头)前缀 0。
|
||||
func secidOf(code string) string {
|
||||
if strings.HasPrefix(code, "12") {
|
||||
return "0." + code
|
||||
}
|
||||
return "1." + code
|
||||
}
|
||||
|
||||
// parseKline 解析一行K线数据。
|
||||
func parseKline(parts []string) (model.Kline, error) {
|
||||
open, err1 := strconv.ParseFloat(parts[1], 64)
|
||||
close_, err2 := strconv.ParseFloat(parts[2], 64)
|
||||
high, err3 := strconv.ParseFloat(parts[3], 64)
|
||||
low, err4 := strconv.ParseFloat(parts[4], 64)
|
||||
if err1 != nil || err2 != nil || err3 != nil || err4 != nil {
|
||||
return model.Kline{}, fmt.Errorf("bad kline numbers")
|
||||
}
|
||||
volume, err := strconv.ParseInt(parts[5], 10, 64)
|
||||
if err != nil {
|
||||
volume = 0
|
||||
}
|
||||
amount, _ := strconv.ParseFloat(parts[6], 64)
|
||||
return model.Kline{
|
||||
Date: parts[0],
|
||||
Open: open,
|
||||
Close: close_,
|
||||
High: high,
|
||||
Low: low,
|
||||
Volume: volume,
|
||||
Amount: amount,
|
||||
}, nil
|
||||
}
|
||||
166
internal/datasource/fallback/fallback.go
Normal file
166
internal/datasource/fallback/fallback.go
Normal file
@@ -0,0 +1,166 @@
|
||||
// Package fallback 实现 datasource.MarketDataSource 接口,提供数据源主备切换能力。
|
||||
//
|
||||
// 主数据源(如东财)用于实时行情和历史K线;当主数据源历史K线请求失败时,
|
||||
// 自动切换到备用数据源(如腾讯),确保数据入库和回测的可用性。
|
||||
// 实时行情(FetchConvertibles)支持主备切换:主数据源失败时,
|
||||
// 走"东财 datacenter 列表 + 腾讯实时报价"的组合策略补充实时价格。
|
||||
//
|
||||
// 为提高数据源故障时的整体效率,fallback 内置熔断机制:主数据源K线连续
|
||||
// 失败达到阈值后,进入熔断窗口,期间直接使用备用数据源,不再尝试主数据源;
|
||||
// 熔断窗口结束后自动放行一次探测,成功则解除熔断,恢复主备切换。
|
||||
package fallback
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"stock-analysis/internal/datasource"
|
||||
"stock-analysis/internal/datasource/eastmoney"
|
||||
"stock-analysis/internal/datasource/tencent"
|
||||
"stock-analysis/internal/model"
|
||||
)
|
||||
|
||||
const (
|
||||
// circuitThreshold 主数据源连续失败达到该次数后触发熔断。
|
||||
circuitThreshold = 3
|
||||
// circuitCooldown 熔断持续时间,之后放行一次探测请求。
|
||||
circuitCooldown = 10 * time.Minute
|
||||
)
|
||||
|
||||
// New 创建主备切换数据源。
|
||||
// primary 为主数据源(如东财),secondary 为备用数据源(如腾讯)。
|
||||
func New(primary, secondary datasource.MarketDataSource) datasource.MarketDataSource {
|
||||
return &dataSource{
|
||||
primary: primary,
|
||||
secondary: secondary,
|
||||
}
|
||||
}
|
||||
|
||||
// dataSource 包装主备两个数据源,实现 MarketDataSource 接口。
|
||||
type dataSource struct {
|
||||
primary datasource.MarketDataSource
|
||||
secondary datasource.MarketDataSource
|
||||
|
||||
mu sync.Mutex
|
||||
failCount int // 主数据源连续失败次数
|
||||
openSince time.Time // 熔断开启时间(open=true 时有效)
|
||||
open bool // 熔断是否开启
|
||||
}
|
||||
|
||||
// FetchConvertibles 拉取全量可转债实时行情:先尝试主数据源 push2 接口,
|
||||
// 失败时走"东财 datacenter 列表 + 腾讯批量实时报价"的组合策略补充实时价格。
|
||||
//
|
||||
// 主数据源 push2 故障是常见场景(东财对该集群限流较严),datacenter 与 push2 分属
|
||||
// 不同集群,通常仍可用,故组合策略能保证实时行情可用性。
|
||||
func (d *dataSource) FetchConvertibles(ctx context.Context) ([]model.ConvertibleBond, error) {
|
||||
bonds, err := d.primary.FetchConvertibles(ctx)
|
||||
if err == nil {
|
||||
return bonds, nil
|
||||
}
|
||||
|
||||
log.Printf("[fallback] 主数据源实时行情失败: %v,切换到组合策略(东财列表 + 腾讯报价)", err)
|
||||
return d.fetchConvertiblesViaCombo(ctx)
|
||||
}
|
||||
|
||||
// fetchConvertiblesViaCombo 组合策略实时行情:
|
||||
// 1. 东财 datacenter 拉转债列表(含正股代码、初始转股价、上市日期)
|
||||
// 2. 腾讯批量实时接口(qt.gtimg.cn)补充实时价格和涨跌幅
|
||||
// 3. 过滤已退市转债(实时价格为 0)
|
||||
func (d *dataSource) fetchConvertiblesViaCombo(ctx context.Context) ([]model.ConvertibleBond, error) {
|
||||
em := eastmoney.NewClient()
|
||||
bonds, err := em.FetchAllBondCodes(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("datacenter 拉取列表失败: %w", err)
|
||||
}
|
||||
if len(bonds) == 0 {
|
||||
return nil, fmt.Errorf("datacenter 返回空列表")
|
||||
}
|
||||
log.Printf("[fallback] datacenter 拉取到 %d 只转债,开始腾讯实时报价", len(bonds))
|
||||
|
||||
if err := tencent.FetchQuotes(ctx, bonds); err != nil {
|
||||
// 腾讯报价失败不致命,已退市转债会被 price=0 过滤
|
||||
log.Printf("[fallback] 腾讯实时报价异常: %v", err)
|
||||
}
|
||||
|
||||
// 过滤已退市转债(实时价格为 0),与项目硬约束一致
|
||||
out := make([]model.ConvertibleBond, 0, len(bonds))
|
||||
for _, b := range bonds {
|
||||
if b.Price > 0 {
|
||||
out = append(out, b)
|
||||
}
|
||||
}
|
||||
log.Printf("[fallback] 组合策略完成:有效转债 %d 只(已过滤退市 %d 只)", len(out), len(bonds)-len(out))
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// FetchKline 获取历史K线:先尝试主数据源,失败后自动切换到备用数据源。
|
||||
// 熔断开启时直接使用备用数据源。
|
||||
func (d *dataSource) FetchKline(ctx context.Context, code, klt, beg, end string) ([]model.Kline, error) {
|
||||
// 熔断开启:直接走备用数据源
|
||||
if d.circuitOpen() {
|
||||
return d.fetchSecondary(ctx, code, klt, beg, end)
|
||||
}
|
||||
|
||||
klines, err := d.primary.FetchKline(ctx, code, klt, beg, end)
|
||||
if err == nil {
|
||||
// 主数据源恢复,重置失败计数
|
||||
d.recordResult(true)
|
||||
return klines, nil
|
||||
}
|
||||
|
||||
log.Printf("[fallback] 主数据源 K线失败 (%s %s %s~%s): %v,切换到备用数据源", code, klt, beg, end, err)
|
||||
d.recordResult(false)
|
||||
return d.fetchSecondary(ctx, code, klt, beg, end)
|
||||
}
|
||||
|
||||
// fetchSecondary 从备用数据源获取K线并记录错误。
|
||||
func (d *dataSource) fetchSecondary(ctx context.Context, code, klt, beg, end string) ([]model.Kline, error) {
|
||||
klines, err := d.secondary.FetchKline(ctx, code, klt, beg, end)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("备用数据源: %w", err)
|
||||
}
|
||||
return klines, nil
|
||||
}
|
||||
|
||||
// circuitOpen 判断是否处于熔断开启状态;熔断超时后自动放行探测。
|
||||
func (d *dataSource) circuitOpen() bool {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
if !d.open {
|
||||
return false
|
||||
}
|
||||
if time.Since(d.openSince) >= circuitCooldown {
|
||||
// 熔断窗口结束,放行一次探测请求
|
||||
d.open = false
|
||||
d.failCount = 0
|
||||
log.Printf("[fallback] 熔断窗口结束,放行主数据源探测")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// recordResult 记录主数据源请求结果:失败达到阈值时开启熔断。
|
||||
func (d *dataSource) recordResult(ok bool) {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
if ok {
|
||||
d.failCount = 0
|
||||
if d.open {
|
||||
d.open = false
|
||||
log.Printf("[fallback] 主数据源恢复,解除熔断")
|
||||
}
|
||||
return
|
||||
}
|
||||
d.failCount++
|
||||
if d.open {
|
||||
return
|
||||
}
|
||||
if d.failCount >= circuitThreshold {
|
||||
d.open = true
|
||||
d.openSince = time.Now()
|
||||
log.Printf("[fallback] 主数据源连续失败 %d 次,开启熔断 %v,直接使用备用数据源", d.failCount, circuitCooldown)
|
||||
}
|
||||
}
|
||||
125
internal/datasource/sina/convert.go
Normal file
125
internal/datasource/sina/convert.go
Normal file
@@ -0,0 +1,125 @@
|
||||
// Package sina 新浪财经数据源:提供可转债转股价变动历史。
|
||||
// 数据来源:money.finance.sina.com.cn/bond/conversion/{market}{code}.html(转股价变动表),
|
||||
// 含初始转股价与历次调整/修正记录,字段:生效日期 + 转股价。
|
||||
package sina
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/text/encoding/simplifiedchinese"
|
||||
"golang.org/x/text/transform"
|
||||
|
||||
"stock-analysis/internal/model"
|
||||
)
|
||||
|
||||
// conversionBase 新浪转债行权详情页(含转股价变动历史)。
|
||||
const conversionBase = "http://money.finance.sina.com.cn/bond/conversion/%s%s.html"
|
||||
|
||||
var (
|
||||
bluecntRowRe = regexp.MustCompile(`<tr class="bluecnt">(.*?)</tr>`)
|
||||
cellRe = regexp.MustCompile(`<td[^>]*>(.*?)</td>`)
|
||||
stripTagRe = regexp.MustCompile(`<[^>]+>`)
|
||||
spaceRe = regexp.MustCompile(`\s+`)
|
||||
)
|
||||
|
||||
// Client 是新浪财经客户端。
|
||||
type Client struct {
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
// NewClient 创建新浪财经客户端。
|
||||
func NewClient() *Client {
|
||||
return &Client{http: &http.Client{Timeout: 15 * time.Second}}
|
||||
}
|
||||
|
||||
// FetchConvertPrices 拉取指定转债的转股价变动历史(含初始转股价)。
|
||||
// 返回按生效日期升序的记录;页面缺失时返回空切片(由调用方用初始转股价兜底)。
|
||||
func (c *Client) FetchConvertPrices(ctx context.Context, code string) ([]model.ConvertPrice, error) {
|
||||
reqURL := fmt.Sprintf(conversionBase, symbolOf(code), code)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
|
||||
if err != nil {
|
||||
return nil, 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 := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("http status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// 新浪页面为 GBK 编码,转成 UTF-8 后解析。
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
utf8Body, _, err := transform.Bytes(simplifiedchinese.GBK.NewDecoder(), body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode gbk: %w", err)
|
||||
}
|
||||
|
||||
return parseConvertPrices(string(utf8Body)), nil
|
||||
}
|
||||
|
||||
// parseConvertPrices 从 HTML 中提取转股价变动表的记录。
|
||||
// 仅解析"转股价变动 begin/end"注释区间内的数据行,避免误取行权/回售表。
|
||||
func parseConvertPrices(html string) []model.ConvertPrice {
|
||||
const beginMark = "<!--转股价变动 begin-->"
|
||||
const endMark = "<!--转股价变动 end-->"
|
||||
beginIdx := strings.Index(html, beginMark)
|
||||
if beginIdx < 0 {
|
||||
return nil
|
||||
}
|
||||
seg := html[beginIdx+len(beginMark):]
|
||||
if endIdx := strings.Index(seg, endMark); endIdx >= 0 {
|
||||
seg = seg[:endIdx]
|
||||
}
|
||||
|
||||
var prices []model.ConvertPrice
|
||||
for _, row := range bluecntRowRe.FindAllString(seg, -1) {
|
||||
cells := cellsOf(row)
|
||||
// 列:序号 | 价格变动类型 | 公告日期 | 生效日期 | 执行日期 | 转股价 | 转股比例
|
||||
if len(cells) < 6 {
|
||||
continue
|
||||
}
|
||||
effective := strings.TrimSpace(cells[3])
|
||||
price, err := strconv.ParseFloat(strings.TrimSpace(cells[5]), 64)
|
||||
if err != nil || effective == "" || price <= 0 {
|
||||
continue
|
||||
}
|
||||
prices = append(prices, model.ConvertPrice{
|
||||
EffectiveDate: effective,
|
||||
Price: price,
|
||||
})
|
||||
}
|
||||
return prices
|
||||
}
|
||||
|
||||
// cellsOf 解析一行 <tr> 中所有 <td> 的文本(去标签去空白)。
|
||||
func cellsOf(row string) []string {
|
||||
var cells []string
|
||||
for _, m := range cellRe.FindAllStringSubmatch(row, -1) {
|
||||
text := stripTagRe.ReplaceAllString(m[1], "")
|
||||
text = spaceRe.ReplaceAllString(text, "")
|
||||
cells = append(cells, text)
|
||||
}
|
||||
return cells
|
||||
}
|
||||
|
||||
// symbolOf 生成新浪标的符号(沪市 sh / 深市 sz),与腾讯规则一致。
|
||||
func symbolOf(code string) string {
|
||||
if strings.HasPrefix(code, "12") {
|
||||
return "sz"
|
||||
}
|
||||
return "sh"
|
||||
}
|
||||
217
internal/datasource/tencent/client.go
Normal file
217
internal/datasource/tencent/client.go
Normal 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)
|
||||
}
|
||||
137
internal/datasource/tencent/realtime.go
Normal file
137
internal/datasource/tencent/realtime.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user