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 }