// 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(`
(.*?)
`)
cellRe = regexp.MustCompile(`]*>(.*?) | `)
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 = ""
const endMark = ""
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 解析一行 中所有 | 的文本(去标签去空白)。
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"
}
|