统一单入口 Go 工程与前端依赖目录,增加后台供应商配置、候选审核、计划预算、成交冲正、持仓复盘和站内通知。接入策略版本、Python 回测、可配置 A 股公开行情及 zap 文件轮转日志,补充迁移、契约与验证。
This commit is contained in:
@@ -0,0 +1,172 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package market
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"git.sundynix.cn/Blizzard/Veyra/internal/model"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMarketPrecisionAndTimestamp(t *testing.T) {
|
||||
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
fmt.Fprint(w, `{"rc":0,"data":{"total":1,"diff":{"0":{"f12":"600000","f13":1,"f14":"fixture","f2":1001,"f124":1700000000,"f152":2}}}}`)
|
||||
}))
|
||||
defer server.Close()
|
||||
client := &Client{http: server.Client()}
|
||||
rows, e := client.Snapshot(context.Background(), model.ProviderConfig{BaseURL: server.URL, ID: "source", Version: 3})
|
||||
if e != nil || len(rows) != 1 {
|
||||
t.Fatalf("snapshot: %v", e)
|
||||
}
|
||||
if rows[0].Price != "10.01" || rows[0].ObservedAt.Unix() != 1700000000 || rows[0].SourceVersion != 3 {
|
||||
t.Fatal("lost integer precision or provider timestamp")
|
||||
}
|
||||
}
|
||||
func TestHistoryOHLCAndDates(t *testing.T) {
|
||||
response := `{"rc":0,"data":{"code":"600000","name":"fixture","klines":["2025-01-02,10.01,10.11,10.21,9.99,100,1"]}}`
|
||||
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Query().Get("fqt") != "0" {
|
||||
t.Error("adjustment changed")
|
||||
}
|
||||
fmt.Fprint(w, response)
|
||||
}))
|
||||
defer server.Close()
|
||||
client := &Client{http: server.Client()}
|
||||
input := model.ProviderConfig{HistoryBaseURL: server.URL, Version: 4}
|
||||
data, e := client.History(context.Background(), input, "600000", "SSE", "2025-01-01", "2025-01-03")
|
||||
if e != nil || len(data.Bars) != 1 || data.Bars[0].Close != "10.11" || data.Adjustment != "unadjusted" {
|
||||
t.Fatalf("history: %v", e)
|
||||
}
|
||||
response = `{"rc":0,"data":{"code":"600000","name":"fixture","klines":["2025-01-02,10.01,10.11,10.00,9.99,100,1"]}}`
|
||||
if _, e := client.History(context.Background(), input, "600000", "SSE", "2025-01-01", "2025-01-03"); e == nil {
|
||||
t.Fatal("invalid high accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSinaQuotesKeepVendorDate(t *testing.T) {
|
||||
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/quotes_service/api/json_v2.php/Market_Center.getHQNodeData":
|
||||
fmt.Fprint(w, `[{"symbol":"sh600000","code":"600000","name":"fixture"}]`)
|
||||
case "/list=sh600000":
|
||||
fmt.Fprint(w, `var hq_str_sh600000="fixture,10.000,10.000,10.010,10.100,9.900,10.000,10.010,100,1000,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,2025-01-02,15:00:00,00";`)
|
||||
default:
|
||||
t.Errorf("unexpected route %s", r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
client := &Client{http: server.Client()}
|
||||
rows, e := client.Snapshot(context.Background(), model.ProviderConfig{MarketAdapter: "sina_public", BaseURL: server.URL, QuoteBaseURL: server.URL})
|
||||
if e != nil || len(rows) != 1 || rows[0].Price != "10.01" || rows[0].ObservedAt.Format("2006-01-02T15:04:05Z") != "2025-01-02T07:00:00Z" {
|
||||
t.Fatalf("quote validation: %+v, %v", rows, e)
|
||||
}
|
||||
}
|
||||
func TestPublicPricesRejectRounding(t *testing.T) {
|
||||
for _, v := range []string{"10.001", "1e2", "NaN"} {
|
||||
if _, e := exactPublicPrice(v); e == nil {
|
||||
t.Fatalf("rounded or invalid price %s", v)
|
||||
}
|
||||
}
|
||||
if v, e := exactPublicPrice("10.010"); e != nil || v != 1001 {
|
||||
t.Fatal("exact extra zero rejected")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package market
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"git.sundynix.cn/Blizzard/Veyra/internal/model"
|
||||
"golang.org/x/text/encoding/simplifiedchinese"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
_ "time/tzdata"
|
||||
)
|
||||
|
||||
func exactPublicPrice(s string) (int64, error) {
|
||||
if strings.Contains(s, ".") {
|
||||
s = strings.TrimRight(s, "0")
|
||||
s = strings.TrimSuffix(s, ".")
|
||||
}
|
||||
return model.ParseMoney(s)
|
||||
}
|
||||
func (c *Client) sinaSnapshot(ctx context.Context, config model.ProviderConfig) ([]model.Instrument, error) {
|
||||
all := make([]model.Instrument, 0)
|
||||
seen := map[string]bool{}
|
||||
zone, _ := time.LoadLocation("Asia/Shanghai")
|
||||
for page := 1; page <= 150; page++ {
|
||||
var rows []struct{ Symbol, Code, Name string }
|
||||
p := url.Values{"page": {strconv.Itoa(page)}, "num": {"80"}, "sort": {"symbol"}, "asc": {"1"}, "node": {"hs_a"}, "symbol": {""}}
|
||||
p.Set("symbol", "")
|
||||
if e := c.get(ctx, config.BaseURL, "/quotes_service/api/json_v2.php/Market_Center.getHQNodeData", p, &rows, config.TimeoutSeconds); e != nil {
|
||||
return nil, e
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
if len(all) == 0 {
|
||||
return nil, errors.New("empty market universe")
|
||||
}
|
||||
return all, nil
|
||||
}
|
||||
symbols := make([]string, 0)
|
||||
names := map[string]string{}
|
||||
for _, row := range rows {
|
||||
if !(strings.HasPrefix(row.Symbol, "sh") || strings.HasPrefix(row.Symbol, "sz")) || len(row.Code) != 6 {
|
||||
continue
|
||||
}
|
||||
if seen[row.Symbol] {
|
||||
return nil, errors.New("duplicate market page")
|
||||
}
|
||||
seen[row.Symbol] = true
|
||||
symbols = append(symbols, row.Symbol)
|
||||
names[row.Symbol] = row.Name
|
||||
}
|
||||
if len(symbols) > 0 {
|
||||
u, e := url.Parse(config.QuoteBaseURL)
|
||||
if e != nil {
|
||||
return nil, errors.New("invalid quote endpoint")
|
||||
}
|
||||
u.Path = strings.TrimRight(u.Path, "/") + "/list=" + strings.Join(symbols, ",")
|
||||
req, e := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
|
||||
if e != nil {
|
||||
return nil, errors.New("invalid quote request")
|
||||
}
|
||||
req.Header.Set("Referer", "https://finance.sina.com.cn/")
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0")
|
||||
seconds := config.TimeoutSeconds
|
||||
if seconds < 1 || seconds > 30 {
|
||||
seconds = 4
|
||||
}
|
||||
quoteCtx, cancel := context.WithTimeout(ctx, time.Duration(seconds)*time.Second)
|
||||
req = req.WithContext(quoteCtx)
|
||||
resp, e := c.http.Do(req)
|
||||
if e != nil {
|
||||
cancel()
|
||||
return nil, errors.New("quote provider unavailable")
|
||||
}
|
||||
data, readError := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
resp.Body.Close()
|
||||
cancel()
|
||||
if readError != nil || resp.StatusCode != 200 {
|
||||
return nil, errors.New("quote provider rejected request")
|
||||
}
|
||||
decoded, e := simplifiedchinese.GBK.NewDecoder().Bytes(data)
|
||||
if e != nil {
|
||||
return nil, errors.New("invalid quote text")
|
||||
}
|
||||
for _, line := range strings.Split(string(decoded), ";") {
|
||||
left, right, ok := strings.Cut(strings.TrimSpace(line), "=")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
symbol := strings.TrimPrefix(left, "var hq_str_")
|
||||
name, exists := names[symbol]
|
||||
if !exists {
|
||||
continue
|
||||
}
|
||||
parts := strings.Split(strings.Trim(right, "\"\r\n "), ",")
|
||||
if len(parts) < 32 {
|
||||
continue
|
||||
}
|
||||
price, e := exactPublicPrice(parts[3])
|
||||
when, dateError := time.ParseInLocation("2006-01-02 15:04:05", parts[30]+" "+parts[31], zone)
|
||||
if e != nil || dateError != nil || price <= 0 || price > 100000000 || when.After(time.Now().Add(time.Minute)) {
|
||||
continue
|
||||
}
|
||||
exchange := "SZSE"
|
||||
if strings.HasPrefix(symbol, "sh") {
|
||||
exchange = "SSE"
|
||||
}
|
||||
all = append(all, model.Instrument{Symbol: symbol[2:], Exchange: exchange, Name: name, Price: model.FormatMoney(price), PriceCents: price, ObservedAt: when.UTC(), SourceID: config.ID, SourceVersion: config.Version})
|
||||
}
|
||||
}
|
||||
if len(rows) < 80 {
|
||||
if len(all) == 0 {
|
||||
return nil, errors.New("no valid market quotes")
|
||||
}
|
||||
return all, nil
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(200 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
return nil, errors.New("market universe exceeds limit")
|
||||
}
|
||||
func (c *Client) sinaHistory(ctx context.Context, config model.ProviderConfig, symbol, exchange, start, end string) (model.MarketHistory, error) {
|
||||
ticker := "sz" + symbol
|
||||
if exchange == "SSE" {
|
||||
ticker = "sh" + symbol
|
||||
}
|
||||
var envelope struct {
|
||||
Result struct {
|
||||
Status struct{ Code int }
|
||||
Data []struct{ Day, Open, High, Low, Close string }
|
||||
}
|
||||
}
|
||||
p := url.Values{"symbol": {ticker}, "scale": {"240"}, "ma": {"no"}, "datalen": {"1000"}}
|
||||
result := model.MarketHistory{Symbol: symbol, Exchange: exchange, Source: "sina_public", SourceVersion: config.Version, Adjustment: "unadjusted", ReceivedAt: time.Now().UTC(), Bars: make([]model.HistoricalBar, 0)}
|
||||
if e := c.get(ctx, config.HistoryBaseURL, "/cn/api/openapi.php/CN_MarketDataService.getKLineData", p, &envelope, config.TimeoutSeconds); e != nil {
|
||||
return result, e
|
||||
}
|
||||
if envelope.Result.Status.Code != 0 || len(envelope.Result.Data) == 0 {
|
||||
return result, errors.New("historical provider unavailable")
|
||||
}
|
||||
previous := ""
|
||||
for _, b := range envelope.Result.Data {
|
||||
date := strings.Split(b.Day, " ")[0]
|
||||
if date < start || date > end {
|
||||
continue
|
||||
}
|
||||
_, e := time.Parse("2006-01-02", date)
|
||||
o, e1 := exactPublicPrice(b.Open)
|
||||
h, e2 := exactPublicPrice(b.High)
|
||||
l, e3 := exactPublicPrice(b.Low)
|
||||
v, e4 := exactPublicPrice(b.Close)
|
||||
if e != nil || date <= previous || e1 != nil || e2 != nil || e3 != nil || e4 != nil || l <= 0 || l > o || l > v || h < o || h < v || h > 100000000 {
|
||||
return result, errors.New("invalid provider OHLC")
|
||||
}
|
||||
previous = date
|
||||
result.Bars = append(result.Bars, model.HistoricalBar{Date: date, Open: model.FormatMoney(o), High: model.FormatMoney(h), Low: model.FormatMoney(l), Close: model.FormatMoney(v)})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
Reference in New Issue
Block a user