Checks / verify (push) Failing after 9m52s
统一单入口 Go 工程与前端依赖目录,增加后台供应商配置、候选审核、计划预算、成交冲正、持仓复盘和站内通知。接入策略版本、Python 回测、可配置 A 股公开行情及 zap 文件轮转日志,补充迁移、契约与验证。
165 lines
5.6 KiB
Go
165 lines
5.6 KiB
Go
package market
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"git.sundynix.cn/Blizzard/Veyra/internal/model"
|
|
"golang.org/x/text/encoding/simplifiedchinese"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
_ "time/tzdata"
|
|
)
|
|
|
|
func exactPublicPrice(s string) (int64, error) {
|
|
if strings.Contains(s, ".") {
|
|
s = strings.TrimRight(s, "0")
|
|
s = strings.TrimSuffix(s, ".")
|
|
}
|
|
return model.ParseMoney(s)
|
|
}
|
|
func (c *Client) sinaSnapshot(ctx context.Context, config model.ProviderConfig) ([]model.Instrument, error) {
|
|
all := make([]model.Instrument, 0)
|
|
seen := map[string]bool{}
|
|
zone, _ := time.LoadLocation("Asia/Shanghai")
|
|
for page := 1; page <= 150; page++ {
|
|
var rows []struct{ Symbol, Code, Name string }
|
|
p := url.Values{"page": {strconv.Itoa(page)}, "num": {"80"}, "sort": {"symbol"}, "asc": {"1"}, "node": {"hs_a"}, "symbol": {""}}
|
|
p.Set("symbol", "")
|
|
if e := c.get(ctx, config.BaseURL, "/quotes_service/api/json_v2.php/Market_Center.getHQNodeData", p, &rows, config.TimeoutSeconds); e != nil {
|
|
return nil, e
|
|
}
|
|
if len(rows) == 0 {
|
|
if len(all) == 0 {
|
|
return nil, errors.New("empty market universe")
|
|
}
|
|
return all, nil
|
|
}
|
|
symbols := make([]string, 0)
|
|
names := map[string]string{}
|
|
for _, row := range rows {
|
|
if !(strings.HasPrefix(row.Symbol, "sh") || strings.HasPrefix(row.Symbol, "sz")) || len(row.Code) != 6 {
|
|
continue
|
|
}
|
|
if seen[row.Symbol] {
|
|
return nil, errors.New("duplicate market page")
|
|
}
|
|
seen[row.Symbol] = true
|
|
symbols = append(symbols, row.Symbol)
|
|
names[row.Symbol] = row.Name
|
|
}
|
|
if len(symbols) > 0 {
|
|
u, e := url.Parse(config.QuoteBaseURL)
|
|
if e != nil {
|
|
return nil, errors.New("invalid quote endpoint")
|
|
}
|
|
u.Path = strings.TrimRight(u.Path, "/") + "/list=" + strings.Join(symbols, ",")
|
|
req, e := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
|
|
if e != nil {
|
|
return nil, errors.New("invalid quote request")
|
|
}
|
|
req.Header.Set("Referer", "https://finance.sina.com.cn/")
|
|
req.Header.Set("User-Agent", "Mozilla/5.0")
|
|
seconds := config.TimeoutSeconds
|
|
if seconds < 1 || seconds > 30 {
|
|
seconds = 4
|
|
}
|
|
quoteCtx, cancel := context.WithTimeout(ctx, time.Duration(seconds)*time.Second)
|
|
req = req.WithContext(quoteCtx)
|
|
resp, e := c.http.Do(req)
|
|
if e != nil {
|
|
cancel()
|
|
return nil, errors.New("quote provider unavailable")
|
|
}
|
|
data, readError := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
|
resp.Body.Close()
|
|
cancel()
|
|
if readError != nil || resp.StatusCode != 200 {
|
|
return nil, errors.New("quote provider rejected request")
|
|
}
|
|
decoded, e := simplifiedchinese.GBK.NewDecoder().Bytes(data)
|
|
if e != nil {
|
|
return nil, errors.New("invalid quote text")
|
|
}
|
|
for _, line := range strings.Split(string(decoded), ";") {
|
|
left, right, ok := strings.Cut(strings.TrimSpace(line), "=")
|
|
if !ok {
|
|
continue
|
|
}
|
|
symbol := strings.TrimPrefix(left, "var hq_str_")
|
|
name, exists := names[symbol]
|
|
if !exists {
|
|
continue
|
|
}
|
|
parts := strings.Split(strings.Trim(right, "\"\r\n "), ",")
|
|
if len(parts) < 32 {
|
|
continue
|
|
}
|
|
price, e := exactPublicPrice(parts[3])
|
|
when, dateError := time.ParseInLocation("2006-01-02 15:04:05", parts[30]+" "+parts[31], zone)
|
|
if e != nil || dateError != nil || price <= 0 || price > 100000000 || when.After(time.Now().Add(time.Minute)) {
|
|
continue
|
|
}
|
|
exchange := "SZSE"
|
|
if strings.HasPrefix(symbol, "sh") {
|
|
exchange = "SSE"
|
|
}
|
|
all = append(all, model.Instrument{Symbol: symbol[2:], Exchange: exchange, Name: name, Price: model.FormatMoney(price), PriceCents: price, ObservedAt: when.UTC(), SourceID: config.ID, SourceVersion: config.Version})
|
|
}
|
|
}
|
|
if len(rows) < 80 {
|
|
if len(all) == 0 {
|
|
return nil, errors.New("no valid market quotes")
|
|
}
|
|
return all, nil
|
|
}
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil, ctx.Err()
|
|
case <-time.After(200 * time.Millisecond):
|
|
}
|
|
}
|
|
return nil, errors.New("market universe exceeds limit")
|
|
}
|
|
func (c *Client) sinaHistory(ctx context.Context, config model.ProviderConfig, symbol, exchange, start, end string) (model.MarketHistory, error) {
|
|
ticker := "sz" + symbol
|
|
if exchange == "SSE" {
|
|
ticker = "sh" + symbol
|
|
}
|
|
var envelope struct {
|
|
Result struct {
|
|
Status struct{ Code int }
|
|
Data []struct{ Day, Open, High, Low, Close string }
|
|
}
|
|
}
|
|
p := url.Values{"symbol": {ticker}, "scale": {"240"}, "ma": {"no"}, "datalen": {"1000"}}
|
|
result := model.MarketHistory{Symbol: symbol, Exchange: exchange, Source: "sina_public", SourceVersion: config.Version, Adjustment: "unadjusted", ReceivedAt: time.Now().UTC(), Bars: make([]model.HistoricalBar, 0)}
|
|
if e := c.get(ctx, config.HistoryBaseURL, "/cn/api/openapi.php/CN_MarketDataService.getKLineData", p, &envelope, config.TimeoutSeconds); e != nil {
|
|
return result, e
|
|
}
|
|
if envelope.Result.Status.Code != 0 || len(envelope.Result.Data) == 0 {
|
|
return result, errors.New("historical provider unavailable")
|
|
}
|
|
previous := ""
|
|
for _, b := range envelope.Result.Data {
|
|
date := strings.Split(b.Day, " ")[0]
|
|
if date < start || date > end {
|
|
continue
|
|
}
|
|
_, e := time.Parse("2006-01-02", date)
|
|
o, e1 := exactPublicPrice(b.Open)
|
|
h, e2 := exactPublicPrice(b.High)
|
|
l, e3 := exactPublicPrice(b.Low)
|
|
v, e4 := exactPublicPrice(b.Close)
|
|
if e != nil || date <= previous || e1 != nil || e2 != nil || e3 != nil || e4 != nil || l <= 0 || l > o || l > v || h < o || h < v || h > 100000000 {
|
|
return result, errors.New("invalid provider OHLC")
|
|
}
|
|
previous = date
|
|
result.Bars = append(result.Bars, model.HistoricalBar{Date: date, Open: model.FormatMoney(o), High: model.FormatMoney(h), Low: model.FormatMoney(l), Close: model.FormatMoney(v)})
|
|
}
|
|
return result, nil
|
|
}
|