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,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()
}

View 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]
}

View 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
}

View 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
}

View 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
}