From 550fdbf01641b5a6ef486e47810fe79f13afc8e7 Mon Sep 17 00:00:00 2001 From: yanweidong Date: Wed, 26 Aug 2026 02:10:05 +0800 Subject: [PATCH] dev 4 --- go-client/apps/zt/logic/boot.go | 95 ++++++++++------------------ go-client/apps/zt/logic/dip.go | 55 ++++++++++++++++ go-client/apps/zt/logic/open.go | 94 +++------------------------ go-client/apps/zt/logic/order.go | 2 +- go-client/apps/zt/logic/positions.go | 21 +----- go-client/apps/zt/main.go | 3 +- go-client/go.mod | 9 ++- go-client/go.sum | 9 +++ go-client/libs/calc.go | 13 ++++ go-client/libs/market.go | 2 +- go-client/sdk/account.go | 14 ++-- 11 files changed, 140 insertions(+), 177 deletions(-) create mode 100644 go-client/apps/zt/logic/dip.go diff --git a/go-client/apps/zt/logic/boot.go b/go-client/apps/zt/logic/boot.go index 0849019..468125c 100644 --- a/go-client/apps/zt/logic/boot.go +++ b/go-client/apps/zt/logic/boot.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "log" + "slices" "strings" "time" @@ -42,83 +43,53 @@ func RunOnce(ctx context.Context, client *sdk.Client, books *OrderBook, signals if !libs.TradingTime(time.Now()) { return } - // 每轮先消费 QMT 回写,终态订单会立即释放本地委托锁。 - books.readReceipts() - roundCtx, cancel := context.WithTimeout(ctx, config.HttpTimeOut*4) - defer cancel() - assets, err := client.Assets(roundCtx) + // 1 取消过期订单 + books.CancelExpired(ctx, client) + + // 2 验证可用资金 + assets, err := client.Assets(ctx) if err != nil { logf("ERROR", "获取资产失败: %v", err) return } - positions, err := client.Positions(roundCtx) + if assets.Available < assets.Total*config.Account.MinCashRatio { + logf("INFO", "资金总闸:可用金额太少,禁止开新仓") + return + } + + // 3 获取大盘状态 + IsAllow := libs.MarketAllowOpen() + + // 4 获取持仓 + var allCodes []string + pos_codes, positions, err := client.Positions(ctx) if err != nil { logf("ERROR", "获取持仓失败: %v", err) return } + allCodes = append(allCodes, pos_codes...) - seen := map[string]struct{}{} - stockList := make([]string, 0, len(signals.Data)+len(positions)) - addCode := func(code string) { - if code == "" { - return + // 5 验证有效开仓信号 + allowOpen := make([]libs.SignalItem, 0) + for code, item := range signals.Data { + if !slices.Contains(pos_codes, code) { + allowOpen = append(allowOpen, item) } - if _, ok := seen[code]; ok { - return - } - seen[code] = struct{}{} - stockList = append(stockList, code) - } - for code := range signals.Data { - addCode(code) - } - for _, p := range positions { - addCode(p.StockCode) } - ticks := map[string]sdk.Tick{} - if len(stockList) > 0 { - raw, err := client.FullTick(roundCtx, stockList) - if err != nil { - logf("ERROR", "获取行情失败: %v", err) - return - } - for code, tick := range raw { - ticks[code] = tick - } - } - runRound(roundCtx, client, books, assets, ticks, positions, signals.Data) -} - -func runRound(ctx context.Context, client *sdk.Client, books *OrderBook, assets *sdk.Assets, ticks map[string]sdk.Tick, positions []sdk.Position, signals map[string]libs.SignalItem) { - if !books.cancelExpired(ctx, client) { - logf("ERROR", "[ZT] 委托查询失败,本轮跳过") - return - } - buys, sells, ok := books.activeSets(ctx, client) - if !ok { + // 6 获取行情tick + ticks, err := client.FullTick(ctx, allCodes) + if err != nil { + logf("ERROR", "获取行情失败: %v", err) return } - hold := positionCodes(positions) - openSignals := map[string]libs.SignalItem{} - for code, signal := range signals { - if _, held := hold[code]; held { - continue - } - openSignals[code] = signal + // 7 执行开仓:有开仓信号 && 大盘指数允许开仓 + if len(allowOpen) > 0 && IsAllow { + openSignal(client, books, ticks, allowOpen) } - for code := range buys { - delete(openSignals, code) - } - marketOK := libs.AllowOpen() - buyBudget := 0.0 - if assets != nil { - buyBudget = assets.Available - assets.Total*config.Account.MinCashRatio - } - if len(openSignals) > 0 { - openSignal(ctx, client, books, ticks, openSignals, marketOK, &buyBudget) - } - managePositions(ctx, client, books, ticks, positions, buys, sells, marketOK, &buyBudget) + + // 8 持仓计算 + managePositions(client, books, ticks, positions, IsAllow) } diff --git a/go-client/apps/zt/logic/dip.go b/go-client/apps/zt/logic/dip.go new file mode 100644 index 0000000..b228e72 --- /dev/null +++ b/go-client/apps/zt/logic/dip.go @@ -0,0 +1,55 @@ +package logic + +import ( + "big-qmt/go-client/config" + "sync" + "time" +) + +type dipWatch struct { + LastClose float64 + ExpiresAt time.Time +} + +var openDip = struct { + mu sync.Mutex + store map[string]dipWatch +}{store: map[string]dipWatch{}} + +var posDip = struct { + mu sync.Mutex + store map[string]dipWatch +}{store: map[string]dipWatch{}} + +func dipTriggered(mu *sync.Mutex, store map[string]dipWatch, tag, code string, price float64) bool { + if price <= 0 { + return false + } + mu.Lock() + defer mu.Unlock() + now := time.Now() + watch, ok := store[code] + if !ok || now.After(watch.ExpiresAt) || now.Equal(watch.ExpiresAt) { + store[code] = dipWatch{LastClose: price, ExpiresAt: now.Add(time.Duration(config.Account.WatchTimeoutSec) * time.Second)} + logf("INFO", "[%s-观察] %s 现价=%.2f", tag, code, price) + return false + } + if price < watch.LastClose { + watch.LastClose = price + watch.ExpiresAt = now.Add(time.Duration(config.Account.WatchTimeoutSec) * time.Second) + store[code] = watch + logf("INFO", "[%s-下跌] %s 刷新低点=%.2f", tag, code, price) + return false + } + rebound := (price - watch.LastClose) / watch.LastClose * 100 + if rebound <= 0 { + return false + } + if rebound < config.Account.ReboundThreshold { + logf("INFO", "[%s-等待] %s 反弹=%.2f%% 阈值=%.2f%%", tag, code, rebound, config.Account.ReboundThreshold) + return false + } + delete(store, code) + logf("INFO", "[%s-触发] %s 反弹=%.2f%% 低点=%.2f", tag, code, rebound, watch.LastClose) + return true +} diff --git a/go-client/apps/zt/logic/open.go b/go-client/apps/zt/logic/open.go index e06e1af..2f0a3b1 100644 --- a/go-client/apps/zt/logic/open.go +++ b/go-client/apps/zt/logic/open.go @@ -1,114 +1,38 @@ package logic import ( - "context" - "math" - "sync" - "time" - "big-qmt/go-client/config" "big-qmt/go-client/libs" "big-qmt/go-client/sdk" ) -type dipWatch struct { - LastClose float64 - ExpiresAt time.Time -} - -var openDip = struct { - mu sync.Mutex - store map[string]dipWatch -}{store: map[string]dipWatch{}} - -func openSignal(ctx context.Context, client *sdk.Client, books *OrderBook, ticks map[string]sdk.Tick, openSignals map[string]libs.SignalItem, marketOK bool, buyBudget *float64) { - if !marketOK { - return - } - if buyBudget == nil || *buyBudget <= 0 { - return - } +func openSignal(client *sdk.Client, books *OrderBook, ticks map[string]sdk.Tick, openSignals []libs.SignalItem) { state := getState() if state.LoadError != "" { logf("ERROR", "[ZT][开仓] 状态文件异常,禁止新开仓: %s", state.LoadError) return } - for code := range openSignals { - if code == "" { - logf("ERROR", "[ZT][开仓] 无效股票代码") + for _, item := range openSignals { + if state.Get(item.Code) != nil { continue } - if state.Get(code) != nil { - continue - } - price := ticks[code].LastPrice + price := ticks[item.Code].LastPrice if price <= 0 { continue } - if !dipTriggered(&openDip.mu, openDip.store, "开仓", code, price) { + if !dipTriggered(&openDip.mu, openDip.store, "开仓", item.Code, price) { continue } - volume := calcBuyVolume(price, config.Account.BuyValue) + volume := libs.CalcBuyVolume(price, config.Account.BuyValue) if volume <= 0 { continue } - estimated := price * float64(volume) - if estimated > *buyBudget { - logf("INFO", "[ZT][开仓] %s 可用买入预算不足,需要=%.2f 剩余=%.2f", code, estimated, *buyBudget) - continue - } orderID := newOrderTag("base") - if !books.place(ctx, client, sideBuy, code, volume, orderID) { + if !books.place(ctx, client, sideBuy, item.Code, volume, orderID) { continue } - setPending(state.Ensure(code), pendingBaseOpening, orderID) - *buyBudget -= estimated + setPending(state.Ensure(item.Code), pendingBaseOpening, orderID) state.Save() - logf("INFO", "[ZT][开仓] %s 买入 %d 股", code, volume) + logf("INFO", "[ZT][开仓] %s 买入 %d 股", item.Code, volume) } } - -func calcBuyVolume(price, buyValue float64) int { - if price <= 0 || buyValue <= 0 { - return 0 - } - // 不足一手时仍按最低一手委托。 - hands := int(math.Floor(buyValue / (price * 100))) - if hands == 0 { - hands = 1 - } - return hands * 100 -} - -func dipTriggered(mu *sync.Mutex, store map[string]dipWatch, tag, code string, price float64) bool { - if price <= 0 { - return false - } - mu.Lock() - defer mu.Unlock() - now := time.Now() - watch, ok := store[code] - if !ok || now.After(watch.ExpiresAt) || now.Equal(watch.ExpiresAt) { - store[code] = dipWatch{LastClose: price, ExpiresAt: now.Add(time.Duration(config.Account.WatchTimeoutSec) * time.Second)} - logf("INFO", "[%s-观察] %s 现价=%.2f", tag, code, price) - return false - } - if price < watch.LastClose { - watch.LastClose = price - watch.ExpiresAt = now.Add(time.Duration(config.Account.WatchTimeoutSec) * time.Second) - store[code] = watch - logf("INFO", "[%s-下跌] %s 刷新低点=%.2f", tag, code, price) - return false - } - rebound := (price - watch.LastClose) / watch.LastClose * 100 - if rebound <= 0 { - return false - } - if rebound < config.Account.ReboundThreshold { - logf("INFO", "[%s-等待] %s 反弹=%.2f%% 阈值=%.2f%%", tag, code, rebound, config.Account.ReboundThreshold) - return false - } - delete(store, code) - logf("INFO", "[%s-触发] %s 反弹=%.2f%% 低点=%.2f", tag, code, rebound, watch.LastClose) - return true -} diff --git a/go-client/apps/zt/logic/order.go b/go-client/apps/zt/logic/order.go index 3a8c60f..a3fdc9e 100644 --- a/go-client/apps/zt/logic/order.go +++ b/go-client/apps/zt/logic/order.go @@ -178,7 +178,7 @@ func (o *OrderBook) activeSets(ctx context.Context, client *sdk.Client) (buys, s return buys, sells, true } -func (o *OrderBook) cancelExpired(ctx context.Context, client *sdk.Client) bool { +func (o *OrderBook) CancelExpired(ctx context.Context, client *sdk.Client) bool { o.invalidate() orders, err := o.query(ctx, client) if err != nil { diff --git a/go-client/apps/zt/logic/positions.go b/go-client/apps/zt/logic/positions.go index 67d900b..8ce3bef 100644 --- a/go-client/apps/zt/logic/positions.go +++ b/go-client/apps/zt/logic/positions.go @@ -9,31 +9,12 @@ import ( "big-qmt/go-client/sdk" ) -var posDip = struct { - mu sync.Mutex - store map[string]dipWatch -}{store: map[string]dipWatch{}} - var peakMu sync.Mutex var peakGrids = map[string]int{} func peakKey(code, leg string) string { return code + "|" + leg } -func positionCodes(positions []sdk.Position) map[string]struct{} { - out := map[string]struct{}{} - for _, p := range positions { - if p.Volume <= 0 { - continue - } - code := p.StockCode - if code != "" { - out[code] = struct{}{} - } - } - return out -} - -func managePositions(ctx context.Context, client *sdk.Client, books *OrderBook, ticks map[string]sdk.Tick, positions []sdk.Position, buys, sells map[string]struct{}, marketOK bool, buyBudget *float64) { +func managePositions(client *sdk.Client, books *OrderBook, ticks map[string]sdk.Tick, positions []sdk.Position, marketOK bool) { if positions == nil { logf("ERROR", "[ZT][持仓] 持仓查询失败,本轮跳过") return diff --git a/go-client/apps/zt/main.go b/go-client/apps/zt/main.go index d48cc94..a6de989 100644 --- a/go-client/apps/zt/main.go +++ b/go-client/apps/zt/main.go @@ -57,6 +57,7 @@ func main() { } scheduler.Start() log.Printf("[INFO] [ZT] 计划任务已启动") + <-ctx.Done() <-scheduler.Stop().Done() log.Printf("[INFO] [ZT] 停止") @@ -66,7 +67,7 @@ func waitForQMT(ctx context.Context, client *sdk.Client) (*sdk.Assets, []sdk.Pos for { attempt, cancel := context.WithTimeout(ctx, config.HttpTimeOut) assets, assetsErr := client.Assets(attempt) - positions, positionsErr := client.Positions(attempt) + _, positions, positionsErr := client.Positions(attempt) cancel() if assetsErr == nil && positionsErr == nil { log.Printf("[INFO] [ZT] QMT连接成功: %s", config.Global.QMTBaseURL) diff --git a/go-client/go.mod b/go-client/go.mod index b456f0f..3e33354 100644 --- a/go-client/go.mod +++ b/go-client/go.mod @@ -1,8 +1,15 @@ module big-qmt/go-client -go 1.22 +go 1.26.5 require ( + git.apinb.com/bsm-sdk/core v0.2.1 github.com/robfig/cron/v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1 ) + +require ( + github.com/google/uuid v1.6.0 // indirect + github.com/oklog/ulid/v2 v2.1.2 // indirect + github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect +) diff --git a/go-client/go.sum b/go-client/go.sum index d8beffd..fea000d 100644 --- a/go-client/go.sum +++ b/go-client/go.sum @@ -1,5 +1,14 @@ +git.apinb.com/bsm-sdk/core v0.2.1 h1:1kpbdij3qOlf1DmKTq3coIXSgLth5iJHJ3LvVZnjaXM= +git.apinb.com/bsm-sdk/core v0.2.1/go.mod h1:BL/aGHujCWdxrKZrWaiebmLx69J0OrTVv5XfugbbyhE= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/oklog/ulid/v2 v2.1.2 h1:IEclFb9JNvzYA6MW2SCxbLzcHTVsfqm3PrqGQJH5zec= +github.com/oklog/ulid/v2 v2.1.2/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= +github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/go-client/libs/calc.go b/go-client/libs/calc.go index 5c9b492..2764370 100644 --- a/go-client/libs/calc.go +++ b/go-client/libs/calc.go @@ -1,6 +1,7 @@ package libs import ( + "math" "math/rand" "time" ) @@ -23,3 +24,15 @@ func TradingTime(t time.Time) bool { return (second >= 9*3600+30*60 && second <= 11*3600+30*60) || (second >= 13*3600 && second <= 15*3600) } + +func CalcBuyVolume(price, buyValue float64) int { + if price <= 0 || buyValue <= 0 { + return 0 + } + // 不足一手时仍按最低一手委托。 + hands := int(math.Floor(buyValue / (price * 100))) + if hands == 0 { + hands = 1 + } + return hands * 100 +} diff --git a/go-client/libs/market.go b/go-client/libs/market.go index 6a4ecca..bdfb528 100644 --- a/go-client/libs/market.go +++ b/go-client/libs/market.go @@ -13,7 +13,7 @@ var ( ) // AllowOpen 每次开仓或补仓前取 60 分钟大盘信号,只有 UP 才放行。 -func AllowOpen() bool { +func MarketAllowOpen() bool { // gen url. fullUrl := fmt.Sprintf("%s%s?period=%s&t=%s", API_HOST, MarketUrl, Period, randStr(16)) payload, err := GetJSON(fullUrl, HTTPTimeout) diff --git a/go-client/sdk/account.go b/go-client/sdk/account.go index 2246e63..97b8ece 100644 --- a/go-client/sdk/account.go +++ b/go-client/sdk/account.go @@ -31,31 +31,33 @@ type Assets struct { Available float64 `json:"available"` } -func (c *Client) Positions(ctx context.Context) ([]Position, error) { +func (c *Client) Positions(ctx context.Context) ([]string, []Position, error) { return c.decodePositions(ctx, "/api/v2/positions") } -func (c *Client) Holding(ctx context.Context) ([]Position, error) { +func (c *Client) Holding(ctx context.Context) ([]string, []Position, error) { return c.decodePositions(ctx, "/api/holding") } -func (c *Client) decodePositions(ctx context.Context, path string) ([]Position, error) { +func (c *Client) decodePositions(ctx context.Context, path string) ([]string, []Position, error) { raw := map[string]json.RawMessage{} if err := c.post(ctx, path, map[string]any{"account": c.accountType}, &raw); err != nil { - return nil, err + return nil, nil, err } + codes := make([]string, 0, len(raw)) out := make([]Position, 0, len(raw)) for code, blob := range raw { var p Position if err := json.Unmarshal(blob, &p); err != nil { - return nil, fmt.Errorf("position %s: %w", code, err) + return nil, nil, fmt.Errorf("position %s: %w", code, err) } if p.StockCode == "" { p.StockCode = code } + codes = append(codes, code) out = append(out, p) } - return out, nil + return codes, out, nil } func (c *Client) Assets(ctx context.Context) (*Assets, error) {