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

201 lines
5.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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_pcttickflow 返回小数,如 0.30 = 30%),
// 否则根据 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
}
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")
}