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