package datasource import ( "bytes" "encoding/json" "fmt" "io" "net/http" "strings" "time" "stock-user-system/internal/config" ) const ( defaultTimeout = 60 * time.Second maxRetries = 3 retryBaseDelay = 1 * time.Second ) // Quote 对应 tickflow /v1/quotes 返回的单条行情数据。 type Quote struct { Symbol string `json:"symbol"` Name string `json:"name"` Open float64 `json:"open"` High float64 `json:"high"` Low float64 `json:"low"` Close float64 `json:"last_price"` PrevClose float64 `json:"prev_close"` Volume int64 `json:"volume"` Amount float64 `json:"amount"` Timestamp int64 `json:"timestamp"` Region string `json:"region"` Ext struct { ChangePct float64 `json:"change_pct"` TurnoverRate float64 `json:"turnover_rate"` Name string `json:"name"` } `json:"ext"` } // ChangePct 优先使用 ext.change_pct,否则根据 close/prev_close 计算。 func (q *Quote) ChangePct() float64 { if q.Ext.ChangePct != 0 { return q.Ext.ChangePct } if q.PrevClose != 0 { return (q.Close - q.PrevClose) / q.PrevClose * 100 } return 0 } // TurnoverRate 返回 ext.turnover_rate(仅 A 股有效)。 func (q *Quote) TurnoverRate() float64 { return q.Ext.TurnoverRate } // DisplayName 优先使用 ext.name,否则使用 symbol。 func (q *Quote) DisplayName() string { if q.Ext.Name != "" { return q.Ext.Name } if q.Name != "" { return q.Name } return q.Symbol } // UniverseDetail 对应 /v1/universes/:id 返回的标的池详情。 type UniverseDetail struct { ID string `json:"id"` Name string `json:"name"` SymbolCount int `json:"symbol_count"` Symbols []string `json:"symbols"` } // Client 封装 tickflow HTTP API 调用。 type Client struct { baseURL string apiKey string client *http.Client } // NewClient 从配置创建 tickflow 客户端。 func NewClient(cfg *config.Config) *Client { return &Client{ baseURL: strings.TrimRight(cfg.TickFlowBaseURL, "/"), apiKey: cfg.TickFlowAPIKey, client: &http.Client{Timeout: defaultTimeout}, } } // GetUniverse 获取指定标的池的完整代码列表。 func (c *Client) GetUniverse(id string) (*UniverseDetail, error) { path := fmt.Sprintf("/v1/universes/%s", id) var detail UniverseDetail if err := c.request("GET", path, nil, nil, &detail); err != nil { return nil, err } return &detail, nil } // GetQuotesByUniverses 按标的池批量获取行情快照。 func (c *Client) GetQuotesByUniverses(universes []string) ([]Quote, error) { body := map[string]any{"universes": universes} var quotes []Quote if err := c.request("POST", "/v1/quotes", nil, body, "es); err != nil { return nil, err } return quotes, nil } // GetQuotesBySymbols 按代码列表批量获取行情快照。 func (c *Client) GetQuotesBySymbols(symbols []string) ([]Quote, error) { body := map[string]any{"symbols": symbols} var quotes []Quote if err := c.request("POST", "/v1/quotes", nil, body, "es); err != nil { return nil, err } return quotes, nil } func (c *Client) request(method, path string, params, body map[string]any, result any) error { url := c.baseURL + path var lastErr error for attempt := 0; attempt <= maxRetries; attempt++ { if attempt > 0 { time.Sleep(retryBaseDelay * time.Duration(1<<(attempt-1))) } var bodyReader io.Reader if len(body) > 0 && (method == "POST" || method == "PUT") { data, err := json.Marshal(body) if err != nil { return err } bodyReader = bytes.NewReader(data) } req, err := http.NewRequest(method, url, bodyReader) if err != nil { return err } if c.apiKey != "" { req.Header.Set("x-api-key", c.apiKey) } req.Header.Set("Accept", "application/json") req.Header.Set("Content-Type", "application/json") if len(params) > 0 { q := req.URL.Query() for k, v := range params { q.Set(k, fmt.Sprintf("%v", v)) } req.URL.RawQuery = q.Encode() } resp, err := c.client.Do(req) if err != nil { lastErr = err continue } respBody, err := io.ReadAll(resp.Body) resp.Body.Close() if err != nil { lastErr = err continue } if resp.StatusCode >= 500 || resp.StatusCode == 429 { lastErr = fmt.Errorf("tickflow %s %s returned %d: %s", method, path, resp.StatusCode, string(respBody)) continue } if resp.StatusCode >= 400 { return fmt.Errorf("tickflow %s %s returned %d: %s", method, path, resp.StatusCode, string(respBody)) } var wrapper struct { Data json.RawMessage `json:"data"` } if err := json.Unmarshal(respBody, &wrapper); err != nil { return fmt.Errorf("decode tickflow response: %w", err) } if result != nil { if err := json.Unmarshal(wrapper.Data, result); err != nil { return fmt.Errorf("decode tickflow data: %w", err) } } return nil } if lastErr != nil { return fmt.Errorf("tickflow request failed after %d retries: %w", maxRetries, lastErr) } return fmt.Errorf("tickflow request failed") }