Files
Blizzard 92329ddce2
Checks / verify (push) Failing after 9m52s
feat: 完成模拟交易闭环、App 与 A 股行情接入
统一单入口 Go 工程与前端依赖目录,增加后台供应商配置、候选审核、计划预算、成交冲正、持仓复盘和站内通知。接入策略版本、Python 回测、可配置 A 股公开行情及 zap 文件轮转日志,补充迁移、契约与验证。
2026-10-08 22:58:52 +08:00

173 lines
6.5 KiB
Go

// 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
}