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