// Package market adapts external market responses without floating point prices. package market import ( "context" "encoding/json" "errors" "fmt" "git.sundynix.cn/Blizzard/Veyra/internal/model" "io" "net/http" "net/url" "sort" "strconv" "strings" "time" ) type Client struct{ http *http.Client } func New() *Client { return &Client{http: &http.Client{Timeout: 30 * time.Second, CheckRedirect: func(req *http.Request, via []*http.Request) error { return http.ErrUseLastResponse }}} } func (c *Client) get(ctx context.Context, base, path string, params url.Values, target any, timeout ...int) error { seconds := 4 if len(timeout) > 0 && timeout[0] >= 1 && timeout[0] <= 30 { seconds = timeout[0] } ctx, cancel := context.WithTimeout(ctx, time.Duration(seconds)*time.Second) defer cancel() u, e := url.Parse(base) if e != nil || u.Scheme != "https" || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" { return errors.New("invalid market endpoint") } u.Path = strings.TrimRight(u.Path, "/") + path u.RawQuery = params.Encode() req, e := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) if e != nil { return errors.New("invalid market request") } req.Header.Set("User-Agent", "Mozilla/5.0") referer := "https://finance.sina.com.cn/" if strings.HasSuffix(u.Hostname(), "eastmoney.com") { referer = "https://quote.eastmoney.com/" } req.Header.Set("Referer", referer) response, e := c.http.Do(req) if e != nil { return errors.New("market provider unavailable") } defer response.Body.Close() if response.StatusCode != 200 { return errors.New("market provider rejected request") } data, e := io.ReadAll(io.LimitReader(response.Body, (4<<20)+1)) if e != nil || len(data) > 4<<20 { return errors.New("invalid market response size") } if json.Unmarshal(data, target) != nil { return errors.New("invalid market response") } return nil } func (c *Client) Snapshot(ctx context.Context, config model.ProviderConfig) ([]model.Instrument, error) { if config.MarketAdapter == "sina_public" { return c.sinaSnapshot(ctx, config) } list := make([]model.Instrument, 0) seen := map[string]bool{} for page := 1; page <= 120; page++ { var response struct { RC int `json:"rc"` Data *struct { Total int `json:"total"` Diff map[string]struct { Symbol string `json:"f12"` Market int `json:"f13"` Name string `json:"f14"` Price json.RawMessage `json:"f2"` Observed int64 `json:"f124"` Decimals int `json:"f152"` } `json:"diff"` } `json:"data"` } p := url.Values{"pn": {strconv.Itoa(page)}, "pz": {"100"}, "fs": {"m:0+t:6,m:0+t:80,m:1+t:2,m:1+t:23"}, "fields": {"f12,f13,f14,f2,f124,f152"}, "fid": {"f12"}, "po": {"0"}} if e := c.get(ctx, config.BaseURL, "/api/qt/clist/get", p, &response, config.TimeoutSeconds); e != nil { return nil, e } if response.RC != 0 || response.Data == nil || response.Data.Total < 1 || response.Data.Total > 12000 || len(response.Data.Diff) == 0 { return nil, errors.New("invalid market universe") } for _, row := range response.Data.Diff { if len(row.Symbol) != 6 || row.Name == "" || (row.Market != 0 && row.Market != 1) || row.Decimals != 2 { return nil, errors.New("unsupported market precision") } price, e := strconv.ParseInt(string(row.Price), 10, 64) if e != nil || price < 0 || price > 100000000 { continue } when := time.Unix(row.Observed, 0).UTC() if row.Observed <= 0 || when.After(time.Now().Add(time.Minute)) { continue } exchange := "SZSE" if row.Market == 1 { exchange = "SSE" } key := exchange + row.Symbol if seen[key] { return nil, errors.New("duplicate market page") } seen[key] = true list = append(list, model.Instrument{Symbol: row.Symbol, Exchange: exchange, Name: row.Name, PriceCents: price, Price: model.FormatMoney(price), ObservedAt: when, SourceID: config.ID, SourceVersion: config.Version}) } if page*100 >= response.Data.Total { sort.Slice(list, func(i, j int) bool { return list[i].Symbol < list[j].Symbol }) return list, nil } select { case <-ctx.Done(): return nil, ctx.Err() case <-time.After(150 * time.Millisecond): } } return nil, errors.New("market pagination limit exceeded") } func (c *Client) History(ctx context.Context, config model.ProviderConfig, symbol, exchange, start, end string) (model.MarketHistory, error) { if config.MarketAdapter == "sina_public" { return c.sinaHistory(ctx, config, symbol, exchange, start, end) } var response struct { RC int `json:"rc"` Data *struct { Code, Name string Klines []string `json:"klines"` } `json:"data"` } secid := "0." + symbol if exchange == "SSE" { secid = "1." + symbol } params := url.Values{"secid": {secid}, "klt": {"101"}, "fqt": {"0"}, "beg": {strings.ReplaceAll(start, "-", "")}, "end": {strings.ReplaceAll(end, "-", "")}, "fields1": {"f1,f2,f3,f4,f5,f6"}, "fields2": {"f51,f52,f53,f54,f55,f56,f57"}} result := model.MarketHistory{Symbol: symbol, Exchange: exchange, Source: "eastmoney_public", SourceVersion: config.Version, Adjustment: "unadjusted", ReceivedAt: time.Now().UTC(), Bars: make([]model.HistoricalBar, 0)} if e := c.get(ctx, config.HistoryBaseURL, "/api/qt/stock/kline/get", params, &response, config.TimeoutSeconds); e != nil { return result, e } if response.RC != 0 || response.Data == nil || response.Data.Code != symbol || len(response.Data.Klines) > 1000 { return result, errors.New("invalid market history") } result.Name = response.Data.Name previous := "" for _, line := range response.Data.Klines { parts := strings.Split(line, ",") if len(parts) < 5 { return result, errors.New("invalid historical bar") } date, e := time.Parse("2006-01-02", parts[0]) if e != nil || parts[0] <= previous || parts[0] < start || parts[0] > end || date.After(time.Now()) { return result, errors.New("invalid historical date") } previous = parts[0] o, e1 := model.ParseMoney(parts[1]) close, e2 := model.ParseMoney(parts[2]) high, e3 := model.ParseMoney(parts[3]) low, e4 := model.ParseMoney(parts[4]) if e1 != nil || e2 != nil || e3 != nil || e4 != nil || low <= 0 || high > 100000000 || low > o || low > close || high < o || high < close { return result, fmt.Errorf("invalid historical OHLC") } result.Bars = append(result.Bars, model.HistoricalBar{Date: parts[0], Open: model.FormatMoney(o), High: model.FormatMoney(high), Low: model.FormatMoney(low), Close: model.FormatMoney(close)}) } return result, nil }