Files
stock/backend/internal/datasource/tickflow.go
T

200 lines
4.9 KiB
Go

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, &quotes); 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, &quotes); 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")
}