dev 4
This commit is contained in:
@@ -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)
|
||||
// 6 获取行情tick
|
||||
ticks, err := client.FullTick(ctx, allCodes)
|
||||
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)
|
||||
|
||||
// 7 执行开仓:有开仓信号 && 大盘指数允许开仓
|
||||
if len(allowOpen) > 0 && IsAllow {
|
||||
openSignal(client, books, ticks, allowOpen)
|
||||
}
|
||||
|
||||
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 {
|
||||
return
|
||||
}
|
||||
|
||||
hold := positionCodes(positions)
|
||||
openSignals := map[string]libs.SignalItem{}
|
||||
for code, signal := range signals {
|
||||
if _, held := hold[code]; held {
|
||||
continue
|
||||
}
|
||||
openSignals[code] = signal
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
55
go-client/apps/zt/logic/dip.go
Normal file
55
go-client/apps/zt/logic/dip.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user