diff --git a/go-client/apps/zt/logic/boot.go b/go-client/apps/zt/logic/boot.go index dde78a1..0849019 100644 --- a/go-client/apps/zt/logic/boot.go +++ b/go-client/apps/zt/logic/boot.go @@ -3,17 +3,23 @@ package logic import ( "context" "fmt" + "log" "strings" "time" + "big-qmt/go-client/config" "big-qmt/go-client/libs" "big-qmt/go-client/sdk" ) -func Overview(cfg Config, assets *sdk.Assets, positions []sdk.Position) { +func logf(level, format string, args ...any) { + log.Printf("[%s] %s", level, fmt.Sprintf(format, args...)) +} + +func Overview(assets *sdk.Assets, positions []sdk.Position) { fmt.Println("\n" + strings.Repeat("=", 80)) fmt.Printf("【时间】%s\n", time.Now().Format("2006-01-02 15:04:05")) - fmt.Printf("【配置】account_id: %s host_key: %s open_money: %.0f\n", cfg.AccountID, cfg.HostKey, cfg.OpenMoney) + fmt.Printf("【配置】account_id: %s host_key: %s buy_value: %.0f\n", config.Account.AccountID, config.Account.HostKey, config.Account.BuyValue) if assets != nil { fmt.Printf("【资金】总资产:%.2f元,可用资金:%.2f元\n", assets.Total, assets.Available) } else { @@ -25,18 +31,20 @@ func Overview(cfg Config, assets *sdk.Assets, positions []sdk.Position) { if p.Volume <= 0 { continue } - code := normalizeCode(p.StockCode, "") + code := p.StockCode fmt.Printf("【持仓】%s %s 持仓=%d 可用=%d 冻结=%d 在途=%d 昨仓=%d 成本=%.3f 现价=%.3f 市值=%.2f 浮盈=%.2f 盈亏比例=%.2f%%\n", code, p.StockName, p.Volume, p.CanUseVolume, p.FrozenVolume, p.OnRoadVolume, p.YesterdayVolume, p.OpenPrice, p.LastPrice, p.MarketValue, p.FloatProfit, p.ProfitRate*100) } } -func RunOnce(ctx context.Context, client *sdk.Client, books *OrderBook, cfg Config) { - if !tradingTime(time.Now()) { +func RunOnce(ctx context.Context, client *sdk.Client, books *OrderBook, signals *libs.SignalResult) { + if !libs.TradingTime(time.Now()) { return } - roundCtx, cancel := context.WithTimeout(ctx, cfg.HTTPTimeout*4) + // 每轮先消费 QMT 回写,终态订单会立即释放本地委托锁。 + books.readReceipts() + roundCtx, cancel := context.WithTimeout(ctx, config.HttpTimeOut*4) defer cancel() assets, err := client.Assets(roundCtx) @@ -50,24 +58,19 @@ func RunOnce(ctx context.Context, client *sdk.Client, books *OrderBook, cfg Conf return } - signals := fetchSignal(cfg, "dcm_signal") seen := map[string]struct{}{} - stockList := make([]string, 0, len(signals)+len(positions)) + stockList := make([]string, 0, len(signals.Data)+len(positions)) addCode := func(code string) { - n := normalizeCode(code, "") - if n == "" { - n = strings.ToUpper(strings.TrimSpace(code)) - } - if n == "" { + if code == "" { return } - if _, ok := seen[n]; ok { + if _, ok := seen[code]; ok { return } - seen[n] = struct{}{} - stockList = append(stockList, n) + seen[code] = struct{}{} + stockList = append(stockList, code) } - for code := range signals { + for code := range signals.Data { addCode(code) } for _, p := range positions { @@ -82,46 +85,40 @@ func RunOnce(ctx context.Context, client *sdk.Client, books *OrderBook, cfg Conf return } for code, tick := range raw { - ticks[normalizeCode(code, "")] = tick ticks[code] = tick } } - runRound(roundCtx, client, books, cfg, assets, ticks, positions, signals) + runRound(roundCtx, client, books, assets, ticks, positions, signals.Data) } -func runRound(ctx context.Context, client *sdk.Client, books *OrderBook, cfg Config, assets *sdk.Assets, ticks map[string]sdk.Tick, positions []sdk.Position, signals map[string]map[string]any) { - books.cancelExpired(ctx, client, cfg) +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]map[string]any{} + openSignals := map[string]libs.SignalItem{} for code, signal := range signals { - norm := normalizeCode(code, "") - if norm == "" { - norm = code - } - if _, held := hold[norm]; held { + if _, held := hold[code]; held { continue } - openSignals[norm] = signal + 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 { - if books.refresh(ctx, client, cfg) { - buys, _, ok := books.activeSets(ctx, client, cfg) - if ok { - filtered := map[string]map[string]any{} - for code, signal := range openSignals { - if _, buying := buys[code]; buying { - continue - } - filtered[code] = signal - } - openSignals = filtered - } - } + openSignal(ctx, client, books, ticks, openSignals, marketOK, &buyBudget) } - marketOK := libs.AllowOpen(cfg.APIHost, cfg.HTTPTimeout) - if len(openSignals) > 0 { - openSignal(ctx, client, books, cfg, assets, ticks, openSignals, marketOK) - } - managePositions(ctx, client, books, cfg, ticks, positions, marketOK) + managePositions(ctx, client, books, ticks, positions, buys, sells, marketOK, &buyBudget) } diff --git a/go-client/apps/zt/logic/config.go b/go-client/apps/zt/logic/config.go deleted file mode 100644 index d947d2d..0000000 --- a/go-client/apps/zt/logic/config.go +++ /dev/null @@ -1,114 +0,0 @@ -package logic - -import ( - "os" - "strconv" - "strings" - "time" -) - -type Config struct { - QMTBaseURL string - QMTToken string - AccountType string - AccountID string - HostKey string - APIHost string - DataDir string - HTTPTimeout time.Duration - OrderTimeout time.Duration - LoopInterval time.Duration - OpenMoney float64 - MinCashRatio float64 - LossTriggerPct float64 - GridStepPct float64 - MinProfitPct float64 - AdoptExisting bool - ReadyCacheStart int - WatchTimeout time.Duration - ReboundThreshold float64 -} - -func LoadConfig() Config { - cfg := Config{ - QMTBaseURL: env("QMT_BASE_URL", "http://127.0.0.1:10086"), - QMTToken: env("QMT_TOKEN", "QMTbyYanweidong"), - AccountType: env("QMT_ACCOUNT", "stock"), - AccountID: env("ACCOUNT_ID", ""), - HostKey: env("HOST_KEY", ""), - APIHost: strings.TrimRight(env("API_HOST", "http://go.apinb.com"), "/"), - DataDir: env("DATA_DIR", "D:/qmt_strategy_state"), - HTTPTimeout: durationEnv("HTTP_TIMEOUT_SEC", 5) * time.Second, - OrderTimeout: durationEnv("ORDER_TIMEOUT_SEC", 60) * time.Second, - LoopInterval: durationEnv("LOOP_INTERVAL_SEC", 30) * time.Second, - OpenMoney: floatEnv("OPEN_MONEY", 5000), - MinCashRatio: floatEnv("MIN_CASH_RATIO", 0.1), - LossTriggerPct: floatEnv("LOSS_TRIGGER_PCT", -30), - GridStepPct: floatEnv("GRID_STEP_PCT", 1), - MinProfitPct: floatEnv("MIN_PROFIT_PCT", 2), - AdoptExisting: boolEnv("ADOPT_EXISTING_POSITIONS", true), - ReadyCacheStart: intEnv("READY_CACHE_START", 925), - WatchTimeout: durationEnv("WATCH_TIMEOUT_SEC", 300) * time.Second, - ReboundThreshold: floatEnv("REBOUND_THRESHOLD", 0.61), - } - if strings.TrimSpace(cfg.AccountID) == "" { - logf("ERROR", "ACCOUNT_ID 为空") - os.Exit(1) - } - if cfg.MinCashRatio < 0 || cfg.MinCashRatio >= 1 { - logf("ERROR", "MIN_CASH_RATIO 必须在 [0, 1)") - os.Exit(1) - } - if cfg.OpenMoney <= 0 { - logf("ERROR", "OPEN_MONEY 必须大于 0") - os.Exit(1) - } - if err := os.MkdirAll(cfg.DataDir, 0o755); err != nil { - logf("ERROR", "创建 DATA_DIR 失败: %v", err) - os.Exit(1) - } - return cfg -} - -func env(key, fallback string) string { - if v := strings.TrimSpace(os.Getenv(key)); v != "" { - return v - } - return fallback -} - -func intEnv(key string, fallback int) int { - v := strings.TrimSpace(os.Getenv(key)) - if v == "" { - return fallback - } - n, err := strconv.Atoi(v) - if err != nil { - return fallback - } - return n -} - -func floatEnv(key string, fallback float64) float64 { - v := strings.TrimSpace(os.Getenv(key)) - if v == "" { - return fallback - } - f, err := strconv.ParseFloat(v, 64) - if err != nil { - return fallback - } - return f -} - -func durationEnv(key string, fallbackSec int) time.Duration { - return time.Duration(intEnv(key, fallbackSec)) -} - -func boolEnv(key string, fallback bool) bool { - v := strings.ToLower(strings.TrimSpace(os.Getenv(key))) - if v == "" { - return fallback - } - return v == "1" || v == "true" || v == "yes" -} diff --git a/go-client/apps/zt/logic/log.go b/go-client/apps/zt/logic/log.go deleted file mode 100644 index d716260..0000000 --- a/go-client/apps/zt/logic/log.go +++ /dev/null @@ -1,10 +0,0 @@ -package logic - -import ( - "fmt" - "log" -) - -func logf(level, format string, args ...any) { - log.Printf("[%s] %s", level, fmt.Sprintf(format, args...)) -} diff --git a/go-client/apps/zt/logic/open.go b/go-client/apps/zt/logic/open.go index 3e11041..e06e1af 100644 --- a/go-client/apps/zt/logic/open.go +++ b/go-client/apps/zt/logic/open.go @@ -6,6 +6,8 @@ import ( "sync" "time" + "big-qmt/go-client/config" + "big-qmt/go-client/libs" "big-qmt/go-client/sdk" ) @@ -19,30 +21,21 @@ var openDip = struct { store map[string]dipWatch }{store: map[string]dipWatch{}} -func openSignal(ctx context.Context, client *sdk.Client, books *OrderBook, cfg Config, assets *sdk.Assets, ticks map[string]sdk.Tick, openSignals map[string]map[string]any, marketOK bool) { +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 assets == nil { + if buyBudget == nil || *buyBudget <= 0 { return } - if assets.Available < assets.Total*cfg.MinCashRatio { - return - } - state := getState(cfg) + state := getState() if state.LoadError != "" { logf("ERROR", "[ZT][开仓] 状态文件异常,禁止新开仓: %s", state.LoadError) return } - for signalCode, signal := range openSignals { - code := normalizeCode(signalCode, "") + for code := range openSignals { if code == "" { - if c, ok := signal["code"].(string); ok { - code = normalizeCode(c, "") - } - } - if code == "" { - logf("ERROR", "[ZT][开仓] 无效股票代码=%s", signalCode) + logf("ERROR", "[ZT][开仓] 无效股票代码") continue } if state.Get(code) != nil { @@ -52,35 +45,42 @@ func openSignal(ctx context.Context, client *sdk.Client, books *OrderBook, cfg C if price <= 0 { continue } - if !dipTriggered(&openDip.mu, openDip.store, cfg, "开仓", code, price) { + if !dipTriggered(&openDip.mu, openDip.store, "开仓", code, price) { continue } - volume := calcOpenVolume(price, cfg.OpenMoney) + volume := calcBuyVolume(price, config.Account.BuyValue) if volume <= 0 { continue } - if !books.place(ctx, client, cfg, "buy", code, volume, newOrderTag("base")) { + estimated := price * float64(volume) + if estimated > *buyBudget { + logf("INFO", "[ZT][开仓] %s 可用买入预算不足,需要=%.2f 剩余=%.2f", code, estimated, *buyBudget) continue } - state.Ensure(code).Pending = "base_opening" + orderID := newOrderTag("base") + if !books.place(ctx, client, sideBuy, code, volume, orderID) { + continue + } + setPending(state.Ensure(code), pendingBaseOpening, orderID) + *buyBudget -= estimated state.Save() logf("INFO", "[ZT][开仓] %s 买入 %d 股", code, volume) } - state.Save() } -func calcOpenVolume(price, openMoney float64) int { - if price <= 0 || openMoney <= 0 { +func calcBuyVolume(price, buyValue float64) int { + if price <= 0 || buyValue <= 0 { return 0 } - hands := int(math.Floor(openMoney / (price * 100))) + // 不足一手时仍按最低一手委托。 + hands := int(math.Floor(buyValue / (price * 100))) if hands == 0 { hands = 1 } return hands * 100 } -func dipTriggered(mu *sync.Mutex, store map[string]dipWatch, cfg Config, tag, code string, price float64) bool { +func dipTriggered(mu *sync.Mutex, store map[string]dipWatch, tag, code string, price float64) bool { if price <= 0 { return false } @@ -89,13 +89,13 @@ func dipTriggered(mu *sync.Mutex, store map[string]dipWatch, cfg Config, tag, co 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(cfg.WatchTimeout)} + 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(cfg.WatchTimeout) + watch.ExpiresAt = now.Add(time.Duration(config.Account.WatchTimeoutSec) * time.Second) store[code] = watch logf("INFO", "[%s-下跌] %s 刷新低点=%.2f", tag, code, price) return false @@ -104,8 +104,8 @@ func dipTriggered(mu *sync.Mutex, store map[string]dipWatch, cfg Config, tag, co if rebound <= 0 { return false } - if rebound < cfg.ReboundThreshold { - logf("INFO", "[%s-等待] %s 反弹=%.2f%% 阈值=%.2f%%", tag, code, rebound, cfg.ReboundThreshold) + if rebound < config.Account.ReboundThreshold { + logf("INFO", "[%s-等待] %s 反弹=%.2f%% 阈值=%.2f%%", tag, code, rebound, config.Account.ReboundThreshold) return false } delete(store, code) diff --git a/go-client/apps/zt/logic/order.go b/go-client/apps/zt/logic/order.go index b515e20..3a8c60f 100644 --- a/go-client/apps/zt/logic/order.go +++ b/go-client/apps/zt/logic/order.go @@ -4,18 +4,34 @@ import ( "context" "crypto/rand" "encoding/hex" + "encoding/json" "fmt" + "os" + "path/filepath" "strconv" "strings" "sync" "time" + "big-qmt/go-client/config" "big-qmt/go-client/sdk" ) +type orderReceipt struct { + OrderID string `json:"order_id"` + QMTOrderID string `json:"qmt_order_id"` + StockCode string `json:"stock_code"` + Side string `json:"side"` + Status string `json:"status"` + RequestedVolume int `json:"requested_volume"` + TradedVolume int `json:"traded_volume"` +} + const ( opBuyStock = 23 opBuyAlt = 48 + sideBuy = "buy" + sideSell = "sell" ) var activeStatuses = map[int]struct{}{ @@ -44,11 +60,8 @@ func (o parsedOrder) cancelVolume() int { } type submission struct { - Code string - Side string - Volume int - At time.Time - Tag string + Code string + Side string } type OrderBook struct { @@ -58,15 +71,63 @@ type OrderBook struct { buyLocks map[string]time.Time sellLocks map[string]time.Time subs []submission + receipts map[string]time.Time } func NewOrderBook() *OrderBook { return &OrderBook{ buyLocks: map[string]time.Time{}, sellLocks: map[string]time.Time{}, + receipts: map[string]time.Time{}, } } +// readReceipts 读取 QMT 回写并同步委托状态。 +func (o *OrderBook) readReceipts() { + paths, _ := filepath.Glob(filepath.Join(config.Global.QMTDataDir, "order_*.json")) + for _, path := range paths { + info, err := os.Stat(path) + if err != nil { + continue + } + o.mu.Lock() + last := o.receipts[path] + o.mu.Unlock() + if !info.ModTime().After(last) { + continue + } + receipt, err := loadReceipt(path) + if err != nil || !strings.HasPrefix(receipt.OrderID, "zt-") || receipt.StockCode == "" || receipt.Status == "" { + continue + } + o.mu.Lock() + o.receipts[path] = info.ModTime() + o.mu.Unlock() + + status := strings.ToLower(receipt.Status) + state := getState() + if item := state.Get(receipt.StockCode); item != nil && item.PendingOrderID == receipt.OrderID { + item.OrderStatus = status + state.Save() + } + if (status == "filled" || status == "cancelled" || status == "rejected") && (receipt.Side == sideBuy || receipt.Side == sideSell) { + o.unlockSide(receipt.StockCode, receipt.Side) + o.invalidate() + } + logf("INFO", "[ZT][回写] %s status=%s traded=%d/%d", receipt.OrderID, status, receipt.TradedVolume, receipt.RequestedVolume) + } +} + +func loadReceipt(path string) (*orderReceipt, error) { + raw, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var receipt orderReceipt + err = json.Unmarshal(raw, &receipt) + return &receipt, err +} + func (o *OrderBook) invalidate() { o.mu.Lock() defer o.mu.Unlock() @@ -74,7 +135,7 @@ func (o *OrderBook) invalidate() { o.cached = nil } -func (o *OrderBook) query(ctx context.Context, client *sdk.Client, cfg Config) ([]parsedOrder, error) { +func (o *OrderBook) query(ctx context.Context, client *sdk.Client) ([]parsedOrder, error) { o.mu.Lock() if o.hasCache { out := append([]parsedOrder(nil), o.cached...) @@ -98,14 +159,8 @@ func (o *OrderBook) query(ctx context.Context, client *sdk.Client, cfg Config) ( return orders, nil } -func (o *OrderBook) refresh(ctx context.Context, client *sdk.Client, cfg Config) bool { - o.invalidate() - _, err := o.query(ctx, client, cfg) - return err == nil -} - -func (o *OrderBook) activeSets(ctx context.Context, client *sdk.Client, cfg Config) (buys, sells map[string]struct{}, ok bool) { - orders, err := o.query(ctx, client, cfg) +func (o *OrderBook) activeSets(ctx context.Context, client *sdk.Client) (buys, sells map[string]struct{}, ok bool) { + orders, err := o.query(ctx, client) if err != nil { return nil, nil, false } @@ -114,7 +169,7 @@ func (o *OrderBook) activeSets(ctx context.Context, client *sdk.Client, cfg Conf if !item.Active || item.StockCode == "" { continue } - if item.Side == "buy" { + if item.Side == sideBuy { buys[item.StockCode] = struct{}{} } else { sells[item.StockCode] = struct{}{} @@ -123,16 +178,17 @@ func (o *OrderBook) activeSets(ctx context.Context, client *sdk.Client, cfg Conf return buys, sells, true } -func (o *OrderBook) cancelExpired(ctx context.Context, client *sdk.Client, cfg Config) bool { +func (o *OrderBook) cancelExpired(ctx context.Context, client *sdk.Client) bool { o.invalidate() - orders, err := o.query(ctx, client, cfg) + orders, err := o.query(ctx, client) if err != nil { return false } - state := getState(cfg) + state := getState() now := time.Now() - timeout := cfg.OrderTimeout + timeout := time.Duration(config.Account.OrderTimeoutSec) * time.Second seen := map[string]struct{}{} + cancelled := false for _, order := range orders { if !order.Active || order.StockCode == "" { continue @@ -178,8 +234,12 @@ func (o *OrderBook) cancelExpired(ctx context.Context, client *sdk.Client, cfg C continue } o.unlockSide(order.StockCode, order.Side) + cancelled = true logf("INFO", "[ZT][委托] 撤销超时单 %s %s %s volume=%d", order.OrderID, order.StockCode, order.Side, vol) } + if cancelled { + o.invalidate() + } return true } @@ -203,10 +263,10 @@ func (o *OrderBook) claimed(state *ZTState, order parsedOrder) bool { return false } switch item.Pending { - case "base_opening", "add": - return order.Side == "buy" - case "sell_add", "sell_base": - return order.Side == "sell" + case pendingBaseOpening, pendingAdd: + return order.Side == sideBuy + case pendingSellAdd, pendingSellBase: + return order.Side == sideSell default: return false } @@ -227,29 +287,29 @@ func (o *OrderBook) unlockSide(code, side string) { o.subs = o.subs[:n] } -func (o *OrderBook) sideBusy(cfg Config, code, side string, active map[string]struct{}) bool { +func (o *OrderBook) sideBusy(code, side string, active map[string]struct{}) bool { if _, ok := active[code]; ok { return true } - return o.locked(cfg, code, side) + return o.locked(code, side) } -func (o *OrderBook) locked(cfg Config, code, side string) bool { +func (o *OrderBook) locked(code, side string) bool { o.mu.Lock() defer o.mu.Unlock() ts, ok := o.locks(side)[code] - return ok && time.Since(ts) < cfg.OrderTimeout + return ok && time.Since(ts) < time.Duration(config.Account.OrderTimeoutSec)*time.Second } func (o *OrderBook) locks(side string) map[string]time.Time { - if side == "buy" { + if side == sideBuy { return o.buyLocks } return o.sellLocks } -func (o *OrderBook) hasActive(ctx context.Context, client *sdk.Client, cfg Config, code, side string) bool { - orders, err := o.query(ctx, client, cfg) +func (o *OrderBook) hasActive(ctx context.Context, client *sdk.Client, code, side string) bool { + orders, err := o.query(ctx, client) if err != nil { return true } @@ -261,43 +321,43 @@ func (o *OrderBook) hasActive(ctx context.Context, client *sdk.Client, cfg Confi return false } -func (o *OrderBook) place(ctx context.Context, client *sdk.Client, cfg Config, side, code string, volume int, tag string) bool { +func (o *OrderBook) place(ctx context.Context, client *sdk.Client, side, code string, volume int, tag string) bool { if volume <= 0 || volume%100 != 0 { logf("ERROR", "[ZT][委托] %s 拒绝非整手数量=%d", code, volume) return false } - if o.locked(cfg, code, side) { + if o.locked(code, side) { logf("INFO", "[ZT][委托] %s %s锁定中", code, side) return false } - if o.hasActive(ctx, client, cfg, code, side) { + if o.hasActive(ctx, client, code, side) { logf("INFO", "[ZT][委托] %s 已有%s在途委托", code, side) return false } - _, err := client.PassorderLatest(ctx, side == "buy", code, volume) + _, err := client.PassorderLatestTagged(ctx, side == sideBuy, code, volume, tag) if err != nil { logf("ERROR", "[ZT][委托] %s 异常: %v", code, err) return false } o.mu.Lock() o.locks(side)[code] = time.Now() - o.subs = append(o.subs, submission{Code: code, Side: side, Volume: volume, At: time.Now(), Tag: tag}) + o.subs = append(o.subs, submission{Code: code, Side: side}) o.mu.Unlock() logf("INFO", "[ZT][委托] 已提交 %s %s %d股 tag=%s", side, code, volume, tag) return true } func parseOrder(item map[string]string) parsedOrder { - operation := asIntS(mapGet(item, "m_nOffsetFlag", "m_nOrderType", "order_type")) - status := asIntS(mapGet(item, "m_nOrderStatus", "order_status", "status")) - tag := mapGet(item, "m_strRemark", "m_strUserOrderId", "order_remark") - orderTime := int64(asIntS(mapGet(item, "m_nOrderTime", "order_time"))) + operation, _ := strconv.Atoi(item["m_nOffsetFlag"]) + status, _ := strconv.Atoi(item["m_nOrderStatus"]) + tag := item["m_strRemark"] + orderTime, _ := strconv.ParseInt(item["m_nOrderTime"], 10, 64) if orderTime > 1e11 { orderTime /= 1000 } if orderTime <= 0 { - date := mapGet(item, "m_strInsertDate") - clock := strings.ReplaceAll(mapGet(item, "m_strInsertTime"), ":", "") + date := item["m_strInsertDate"] + clock := strings.ReplaceAll(item["m_strInsertTime"], ":", "") if date != "" { if len(clock) < 6 { clock = strings.Repeat("0", 6-len(clock)) + clock @@ -307,21 +367,21 @@ func parseOrder(item map[string]string) parsedOrder { } } } - side := "sell" + side := sideSell if operation == opBuyStock || operation == opBuyAlt { - side = "buy" + side = sideBuy } - left := asIntS(mapGet(item, "m_nVolumeTotal", "volume_left")) - traded := asIntS(mapGet(item, "m_nVolumeTraded", "volume_traded")) - orig := asIntS(mapGet(item, "m_nVolumeTotalOriginal", "volume")) + left, _ := strconv.Atoi(item["m_nVolumeTotal"]) + traded, _ := strconv.Atoi(item["m_nVolumeTraded"]) + orig, _ := strconv.Atoi(item["m_nVolumeTotalOriginal"]) _, active := activeStatuses[status] return parsedOrder{ - OrderID: mapGet(item, "m_strOrderSysID", "m_nOrderID", "order_id"), - StockCode: stockCodeFromMap(item), + OrderID: item["m_strOrderSysID"], + StockCode: item["m_strInstrumentID"], Side: side, Active: active, OrderTime: orderTime, - RemarkOwned: strings.HasPrefix(tag, "zt:"), + RemarkOwned: strings.HasPrefix(tag, "zt-"), VolumeOrig: orig, VolumeLeft: left, VolumeTraded: traded, @@ -356,7 +416,8 @@ func newOrderTag(leg string) string { } var buf [6]byte _, _ = rand.Read(buf[:]) - tag := fmt.Sprintf("zt:%s:%s", legCode, hex.EncodeToString(buf[:])) + // 订单号同时用于 Windows 回写文件名,因此只使用文件名安全字符。 + tag := fmt.Sprintf("zt-%s-%s", legCode, hex.EncodeToString(buf[:])) if len(tag) > 24 { return tag[:24] } @@ -367,8 +428,3 @@ func parseHM(now time.Time) int { n, _ := strconv.Atoi(now.Format("1504")) return n } - -func tradingTime(now time.Time) bool { - hm := parseHM(now) - return (hm >= 930 && hm <= 1130) || (hm >= 1300 && hm <= 1500) -} diff --git a/go-client/apps/zt/logic/positions.go b/go-client/apps/zt/logic/positions.go index 10cc1c2..67d900b 100644 --- a/go-client/apps/zt/logic/positions.go +++ b/go-client/apps/zt/logic/positions.go @@ -5,6 +5,7 @@ import ( "math" "sync" + "big-qmt/go-client/config" "big-qmt/go-client/sdk" ) @@ -24,7 +25,7 @@ func positionCodes(positions []sdk.Position) map[string]struct{} { if p.Volume <= 0 { continue } - code := normalizeCode(p.StockCode, "") + code := p.StockCode if code != "" { out[code] = struct{}{} } @@ -32,18 +33,14 @@ func positionCodes(positions []sdk.Position) map[string]struct{} { return out } -func managePositions(ctx context.Context, client *sdk.Client, books *OrderBook, cfg Config, ticks map[string]sdk.Tick, positions []sdk.Position, marketOK bool) { +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) { if positions == nil { logf("ERROR", "[ZT][持仓] 持仓查询失败,本轮跳过") return } - state := getState(cfg) - if !books.cancelExpired(ctx, client, cfg) { - logf("ERROR", "[ZT][持仓] 委托查询失败,本轮跳过") - return - } - buys, sells, ok := books.activeSets(ctx, client, cfg) - if !ok { + state := getState() + if state.LoadError != "" { + logf("ERROR", "[ZT][持仓] 状态文件异常,本轮停止交易: %s", state.LoadError) return } before := map[string]struct{}{} @@ -63,12 +60,12 @@ func managePositions(ctx context.Context, client *sdk.Client, books *OrderBook, rows := make([]row, 0, len(positions)) seen := map[string]struct{}{} for _, pos := range positions { - code := normalizeCode(pos.StockCode, "") + code := pos.StockCode if code == "" { continue } seen[code] = struct{}{} - item := syncItem(cfg, state, code, pos.Volume, pos.OpenPrice, buys, sells, books) + item := syncItem(state, code, pos.Volume, pos.OpenPrice, buys, sells, books) if pos.Volume <= 0 { continue } @@ -77,7 +74,7 @@ func managePositions(ctx context.Context, client *sdk.Client, books *OrderBook, } for _, code := range state.Codes() { if _, ok := seen[code]; !ok { - syncItem(cfg, state, code, 0, 0, buys, sells, books) + syncItem(state, code, 0, 0, buys, sells, books) } } after := map[string]struct{}{} @@ -111,8 +108,8 @@ func managePositions(ctx context.Context, client *sdk.Client, books *OrderBook, if r.item.AddCost > 0 { addPnL = (r.price - r.item.AddCost) / r.item.AddCost * 100 } - if retreated(cfg, r.item, "add", addPnL) { - sellLeg(ctx, client, books, cfg, r.item, r.usable, r.item.AddQty, "add", addPnL) + if retreated(r.item, "add", addPnL) { + sellLeg(ctx, client, books, r.item, r.usable, r.item.AddQty, "add", addPnL) } continue } @@ -120,38 +117,34 @@ func managePositions(ctx context.Context, client *sdk.Client, books *OrderBook, if r.item.BaseCost > 0 { basePnL = (r.price - r.item.BaseCost) / r.item.BaseCost * 100 } - if retreated(cfg, r.item, "base", basePnL) { - sellLeg(ctx, client, books, cfg, r.item, r.usable, r.item.BaseQty, "base", basePnL) - } else if r.item.AddQty <= 0 && r.item.AddCost <= 0 && basePnL <= cfg.LossTriggerPct { - addOnRebound(ctx, client, books, cfg, r.item, r.price, marketOK) + if retreated(r.item, "base", basePnL) { + sellLeg(ctx, client, books, r.item, r.usable, r.item.BaseQty, "base", basePnL) + } else if r.item.AddQty <= 0 && r.item.AddCost <= 0 && basePnL <= config.Account.LossTriggerPct { + addOnRebound(ctx, client, books, r.item, r.price, marketOK, buyBudget) } } + // 首次没有状态文件时,本轮已将启动前持仓全部接管为底仓。 + state.completeBootstrap() state.Save() } -func syncItem(cfg Config, state *ZTState, code string, volume int, avgPrice float64, buys, sells map[string]struct{}, books *OrderBook) *SymbolState { +func syncItem(state *ZTState, code string, volume int, avgPrice float64, buys, sells map[string]struct{}, books *OrderBook) *SymbolState { item := state.Get(code) if item == nil { if volume > 0 { - if cfg.AdoptExisting && avgPrice > 0 { - item = state.Ensure(code) - item.BaseQty, item.BaseCost, item.Pending = volume, avgPrice, "" - logf("WARNING", "[ZT][持仓] %s 接管为底仓", code) - return item - } logf("ERROR", "[ZT][持仓] %s 无本地状态,跳过", code) } return nil } switch item.Pending { - case "base_opening": - syncOpen(cfg, state, item, volume, avgPrice, buys, books) - case "add": - syncAdd(cfg, state, item, volume, avgPrice, buys, books) - case "sell_add": - syncSellAdd(cfg, state, item, volume, avgPrice, sells, books) - case "sell_base": - syncSellBase(cfg, state, item, volume, avgPrice, sells, books) + case pendingBaseOpening: + syncOpen(state, item, volume, avgPrice, buys, books) + case pendingAdd: + syncAdd(state, item, volume, avgPrice, buys, books) + case pendingSellAdd: + syncSellAdd(state, item, volume, avgPrice, sells, books) + case pendingSellBase: + syncSellBase(state, item, volume, avgPrice, sells, books) default: if volume <= 0 { state.Remove(code) @@ -162,11 +155,11 @@ func syncItem(cfg Config, state *ZTState, code string, volume int, avgPrice floa return state.Get(code) } -func syncOpen(cfg Config, state *ZTState, item *SymbolState, volume int, avgPrice float64, buys map[string]struct{}, books *OrderBook) { +func syncOpen(state *ZTState, item *SymbolState, volume int, avgPrice float64, buys map[string]struct{}, books *OrderBook) { if volume > 0 { item.BaseQty, item.BaseCost = volume, avgPrice } - if books.sideBusy(cfg, item.Code, "buy", buys) { + if books.sideBusy(item.Code, sideBuy, buys) { return } if volume <= 0 { @@ -174,18 +167,18 @@ func syncOpen(cfg Config, state *ZTState, item *SymbolState, volume int, avgPric logf("INFO", "[ZT][委托] %s 开仓委托已失效,允许重新开仓", item.Code) return } - item.Pending = "" + clearPending(item) logf("INFO", "[ZT][持仓] %s 开仓确认 数量=%d 成本=%.2f", item.Code, item.BaseQty, item.BaseCost) } -func syncAdd(cfg Config, state *ZTState, item *SymbolState, volume int, avgPrice float64, buys map[string]struct{}, books *OrderBook) { +func syncAdd(state *ZTState, item *SymbolState, volume int, avgPrice float64, buys map[string]struct{}, books *OrderBook) { if volume > item.BaseQty { item.AddQty = volume - item.BaseQty if item.AddQty > 0 { item.AddCost = math.Max(0, (avgPrice*float64(volume)-item.BaseCost*float64(item.BaseQty))/float64(item.AddQty)) } } - if books.sideBusy(cfg, item.Code, "buy", buys) { + if books.sideBusy(item.Code, sideBuy, buys) { return } if volume <= 0 { @@ -198,12 +191,12 @@ func syncAdd(cfg Config, state *ZTState, item *SymbolState, volume int, avgPrice item.AddCost = 0 logf("INFO", "[ZT][持仓] %s 补仓未成交,回退底仓", item.Code) } - item.Pending = "" + clearPending(item) } -func syncSellAdd(cfg Config, state *ZTState, item *SymbolState, volume int, avgPrice float64, sells map[string]struct{}, books *OrderBook) { +func syncSellAdd(state *ZTState, item *SymbolState, volume int, avgPrice float64, sells map[string]struct{}, books *OrderBook) { if volume <= 0 { - if !books.sideBusy(cfg, item.Code, "sell", sells) { + if !books.sideBusy(item.Code, sideSell, sells) { state.Remove(item.Code) logf("INFO", "[ZT][委托] %s 卖出后已无持仓,清除状态", item.Code) } @@ -218,42 +211,50 @@ func syncSellAdd(cfg Config, state *ZTState, item *SymbolState, volume int, avgP } else { item.AddQty = volume - item.BaseQty } - if !books.sideBusy(cfg, item.Code, "sell", sells) { - item.Pending = "" + if !books.sideBusy(item.Code, sideSell, sells) { + clearPending(item) } } -func syncSellBase(cfg Config, state *ZTState, item *SymbolState, volume int, avgPrice float64, sells map[string]struct{}, books *OrderBook) { +func syncSellBase(state *ZTState, item *SymbolState, volume int, avgPrice float64, sells map[string]struct{}, books *OrderBook) { if volume <= 0 { - if !books.sideBusy(cfg, item.Code, "sell", sells) { + if !books.sideBusy(item.Code, sideSell, sells) { state.Remove(item.Code) logf("INFO", "[ZT][委托] %s 卖出后已无持仓,清除状态", item.Code) } return } item.BaseQty, item.BaseCost = volume, avgPrice - if !books.sideBusy(cfg, item.Code, "sell", sells) { - item.Pending = "" + if !books.sideBusy(item.Code, sideSell, sells) { + clearPending(item) } } -func addOnRebound(ctx context.Context, client *sdk.Client, books *OrderBook, cfg Config, item *SymbolState, price float64, marketOK bool) { - if !marketOK || !dipTriggered(&posDip.mu, posDip.store, cfg, "补仓", item.Code, price) { +func addOnRebound(ctx context.Context, client *sdk.Client, books *OrderBook, item *SymbolState, price float64, marketOK bool, buyBudget *float64) { + if !marketOK || !dipTriggered(&posDip.mu, posDip.store, "补仓", item.Code, price) { return } - if books.place(ctx, client, cfg, "buy", item.Code, item.BaseQty, newOrderTag("add")) { + volume := calcBuyVolume(price, config.Account.BuyValue) + estimated := price * float64(volume) + if buyBudget == nil || estimated > *buyBudget { + logf("INFO", "[ZT][补仓] %s 可用买入预算不足,需要=%.2f", item.Code, estimated) + return + } + orderID := newOrderTag("add") + if books.place(ctx, client, sideBuy, item.Code, volume, orderID) { item.AddCost = price - item.Pending = "add" - getState(cfg).Save() - logf("INFO", "[ZT][补仓] %s 买入 %d 股", item.Code, item.BaseQty) + setPending(item, pendingAdd, orderID) + *buyBudget -= estimated + getState().Save() + logf("INFO", "[ZT][补仓] %s 买入 %d 股", item.Code, volume) } } -func retreated(cfg Config, item *SymbolState, leg string, pnl float64) bool { - if pnl < cfg.MinProfitPct { +func retreated(item *SymbolState, leg string, pnl float64) bool { + if pnl < config.Account.MinProfitPct { return false } - grid := int(math.Floor(pnl / cfg.GridStepPct)) + grid := int(math.Floor(pnl / config.Account.GridStepPct)) key := peakKey(item.Code, leg) peakMu.Lock() defer peakMu.Unlock() @@ -266,21 +267,22 @@ func retreated(cfg Config, item *SymbolState, leg string, pnl float64) bool { return grid < peak } -func sellLeg(ctx context.Context, client *sdk.Client, books *OrderBook, cfg Config, item *SymbolState, usable, volume int, leg string, pnl float64) { +func sellLeg(ctx context.Context, client *sdk.Client, books *OrderBook, item *SymbolState, usable, volume int, leg string, pnl float64) { volume -= volume % 100 if volume <= 0 || usable < volume { logf("INFO", "[ZT][止盈] %s 可用股数不足,需要=%d 可用=%d", item.Code, volume, usable) return } - if !books.place(ctx, client, cfg, "sell", item.Code, volume, newOrderTag(leg)) { + orderID := newOrderTag(leg) + if !books.place(ctx, client, sideSell, item.Code, volume, orderID) { return } if leg == "add" { - item.Pending = "sell_add" + setPending(item, pendingSellAdd, orderID) } else { - item.Pending = "sell_base" + setPending(item, pendingSellBase, orderID) } - getState(cfg).Save() + getState().Save() logf("INFO", "[ZT][止盈] %s 卖出 %d 股,%s腿盈利=%.2f%%", item.Code, volume, leg, pnl) } diff --git a/go-client/apps/zt/logic/remote.go b/go-client/apps/zt/logic/remote.go deleted file mode 100644 index c617b47..0000000 --- a/go-client/apps/zt/logic/remote.go +++ /dev/null @@ -1,184 +0,0 @@ -package logic - -import ( - "encoding/json" - "fmt" - "io" - "net/http" - "net/url" - "os" - "path/filepath" - "strings" - "sync" - "time" -) - -type dailyCache struct { - Date string `json:"date"` - FetchedAt string `json:"fetched_at"` - OK bool `json:"ok"` - Data any `json:"data"` -} - -var memCache sync.Map - -func getJSON(rawURL string, params url.Values, timeout time.Duration) (map[string]any, error) { - if params != nil { - if strings.Contains(rawURL, "?") { - rawURL += "&" + params.Encode() - } else { - rawURL += "?" + params.Encode() - } - } - req, err := http.NewRequest(http.MethodGet, rawURL, nil) - if err != nil { - return nil, err - } - req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", "big-qmt-go-zt/1") - client := &http.Client{Timeout: timeout} - resp, err := client.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - if resp.StatusCode >= 400 { - return nil, fmt.Errorf("http %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) - } - out := map[string]any{} - if err := json.Unmarshal(body, &out); err != nil { - return nil, err - } - return out, nil -} - -func daily(cfg Config, name, filename string, loader func() (any, error), now time.Time) *dailyCache { - if int(parseHM(now)) < cfg.ReadyCacheStart { - return nil - } - day := now.Format("20060102") - path := filepath.Join(cfg.DataDir, fmt.Sprintf(filename, day)) - if v, ok := memCache.Load(path); ok { - if c, ok := v.(*dailyCache); ok && c.Date == day { - return c - } - } - cached := loadDailyFile(path) - if cached != nil && cached.Date == day { - memCache.Store(path, cached) - return cached - } - data, err := loader() - ok := err == nil - if err != nil { - logf("ERROR", "%s 当日请求失败: %v", name, err) - data = map[string]any{} - } - cached = &dailyCache{ - Date: day, - FetchedAt: now.Format("2006-01-02 15:04:05"), - OK: ok, - Data: data, - } - raw, _ := json.MarshalIndent(map[string]any{"version": 1, "data": map[string]any{ - "date": cached.Date, "fetched_at": cached.FetchedAt, "ok": cached.OK, "data": cached.Data, - }}, "", " ") - if err := os.WriteFile(path+".tmp", raw, 0o644); err == nil { - _ = os.Rename(path+".tmp", path) - } - memCache.Store(path, cached) - return cached -} - -func loadDailyFile(path string) *dailyCache { - raw, err := os.ReadFile(path) - if err != nil { - return nil - } - var payload struct { - Version int `json:"version"` - Data map[string]any `json:"data"` - } - if json.Unmarshal(raw, &payload) != nil || payload.Version != 1 || payload.Data == nil { - return nil - } - c := &dailyCache{} - b, _ := json.Marshal(payload.Data) - if json.Unmarshal(b, c) != nil { - return nil - } - return c -} - -func fetchSignal(cfg Config, name string) map[string]map[string]any { - cached := daily(cfg, name, "open_%s.json", func() (any, error) { - var q url.Values - if strings.TrimSpace(cfg.HostKey) != "" { - q = url.Values{"host_key": {cfg.HostKey}} - } - payload, err := getJSON(cfg.APIHost+"/a/"+name, q, cfg.HTTPTimeout) - if err != nil { - return nil, err - } - return normalizeZT(payload), nil - }, time.Now()) - if cached == nil || !cached.OK { - return map[string]map[string]any{} - } - return asSignalMap(cached.Data) -} - -func asSignalMap(data any) map[string]map[string]any { - out := map[string]map[string]any{} - switch v := data.(type) { - case map[string]map[string]any: - return v - case map[string]any: - for code, val := range v { - if m, ok := val.(map[string]any); ok { - out[code] = m - } else { - out[code] = map[string]any{"code": code} - } - } - } - return out -} - -func normalizeZT(payload map[string]any) map[string]map[string]any { - data, _ := payload["data"] - out := map[string]map[string]any{} - switch v := data.(type) { - case []any: - for _, item := range v { - m, ok := item.(map[string]any) - if !ok { - continue - } - code, _ := m["code"].(string) - if code != "" { - out[code] = m - } - } - case map[string]any: - if code, _ := v["code"].(string); code != "" { - out[code] = v - return out - } - for code, val := range v { - if m, ok := val.(map[string]any); ok { - if _, has := m["code"]; !has { - m["code"] = code - } - out[code] = m - } else { - out[code] = map[string]any{"code": code} - } - } - } - return out -} diff --git a/go-client/apps/zt/logic/state.go b/go-client/apps/zt/logic/state.go index 05f98dc..13fd3e2 100644 --- a/go-client/apps/zt/logic/state.go +++ b/go-client/apps/zt/logic/state.go @@ -6,15 +6,40 @@ import ( "os" "path/filepath" "sync" + + "big-qmt/go-client/config" + "big-qmt/go-client/sdk" +) + +const ( + pendingNone = "" + pendingBaseOpening = "base_opening" + pendingAdd = "add" + pendingSellAdd = "sell_add" + pendingSellBase = "sell_base" ) type SymbolState struct { - Code string `json:"code"` - BaseQty int `json:"base_qty"` - BaseCost float64 `json:"base_cost"` - AddQty int `json:"add_qty"` - AddCost float64 `json:"add_cost"` - Pending string `json:"pending"` + Code string `json:"code"` + BaseQty int `json:"base_qty"` + BaseCost float64 `json:"base_cost"` + AddQty int `json:"add_qty"` + AddCost float64 `json:"add_cost"` + Pending string `json:"pending"` + PendingOrderID string `json:"pending_order_id,omitempty"` + OrderStatus string `json:"order_status,omitempty"` +} + +func setPending(item *SymbolState, pending, orderID string) { + item.Pending = pending + item.PendingOrderID = orderID + item.OrderStatus = "submitted" +} + +func clearPending(item *SymbolState) { + item.Pending = pendingNone + item.PendingOrderID = "" + item.OrderStatus = "" } type filePayload struct { @@ -26,6 +51,7 @@ type ZTState struct { path string Items map[string]*SymbolState LoadError string + fresh bool mu sync.Mutex } @@ -34,14 +60,34 @@ var ( states = map[string]*ZTState{} ) -func getState(cfg Config) *ZTState { +// BootstrapState 在状态文件首次不存在时,将启动前已有持仓登记为底仓。 +func BootstrapState(positions []sdk.Position) { + state := getState() + if !state.Fresh() { + return + } + for _, pos := range positions { + code := pos.StockCode + if code == "" || pos.Volume <= 0 || pos.OpenPrice <= 0 { + continue + } + item := state.Ensure(code) + item.BaseQty, item.BaseCost = pos.Volume, pos.OpenPrice + logf("WARNING", "[ZT][状态] %s 首次接管为底仓 数量=%d 成本=%.2f", code, pos.Volume, pos.OpenPrice) + } + state.completeBootstrap() + state.Save() +} + +func getState() *ZTState { statesMu.Lock() defer statesMu.Unlock() - if s, ok := states[cfg.AccountID]; ok { + accountID := config.Account.AccountID + if s, ok := states[accountID]; ok { return s } - s := loadZTState(cfg.DataDir, cfg.AccountID) - states[cfg.AccountID] = s + s := loadZTState(config.Global.QMTDataDir, accountID) + states[accountID] = s return s } @@ -53,19 +99,23 @@ func loadZTState(dataDir, accountID string) *ZTState { raw, err := os.ReadFile(st.path) if err != nil { if os.IsNotExist(err) { + st.fresh = true return st } - st.rebuild(err) + st.LoadError = err.Error() + logf("ERROR", "[ZT][状态] 读取状态文件失败: %v", err) return st } var payload filePayload if err := json.Unmarshal(raw, &payload); err != nil || payload.Version != 1 { - st.rebuild(fmt.Errorf("状态文件版本无效")) + st.LoadError = "状态文件版本无效" + logf("ERROR", "[ZT][状态] %s", st.LoadError) return st } data := payload.Data if data == nil { - st.rebuild(fmt.Errorf("状态文件内容无效")) + st.LoadError = "状态文件内容无效" + logf("ERROR", "[ZT][状态] %s", st.LoadError) return st } symbolsAny, _ := data["symbols"] @@ -91,19 +141,16 @@ func loadZTState(dataDir, accountID string) *ZTState { return st } -func (s *ZTState) rebuild(err error) { - if err := os.Remove(s.path); err != nil && !os.IsNotExist(err) { - s.LoadError = err.Error() - logf("ERROR", "[ZT][状态] 状态文件重建失败: %s", s.LoadError) - return - } - s.Items = map[string]*SymbolState{} - if saveErr := s.saveUnlocked(); saveErr != nil { - s.LoadError = fmt.Sprintf("%v;重建失败: %v", err, saveErr) - logf("ERROR", "[ZT][状态] 状态文件重建失败: %s", s.LoadError) - return - } - logf("WARNING", "[ZT][状态] 状态文件损坏,已删除并重建: %v", err) +func (s *ZTState) Fresh() bool { + s.mu.Lock() + defer s.mu.Unlock() + return s.fresh +} + +func (s *ZTState) completeBootstrap() { + s.mu.Lock() + defer s.mu.Unlock() + s.fresh = false } func (s *ZTState) Get(code string) *SymbolState { @@ -146,7 +193,9 @@ func (s *ZTState) Save() { return } if err := s.saveUnlocked(); err != nil { + s.LoadError = err.Error() logf("ERROR", "[ZT][状态] 保存失败: %v", err) + return } } diff --git a/go-client/apps/zt/logic/stock.go b/go-client/apps/zt/logic/stock.go deleted file mode 100644 index 4a3e56c..0000000 --- a/go-client/apps/zt/logic/stock.go +++ /dev/null @@ -1,94 +0,0 @@ -package logic - -import ( - "fmt" - "strings" - "unicode" -) - -var exchangeAlias = map[string]string{ - "SSE": "SH", "SHSE": "SH", "XSHG": "SH", - "SZSE": "SZ", "XSHE": "SZ", - "BSE": "BJ", "BJSE": "BJ", -} - -func stockCodeFromMap(item map[string]string) string { - code := strings.ToUpper(strings.TrimSpace(mapGet(item, "m_strInstrumentID", "StockCode", "stock_code", "code"))) - ex := mapGet(item, "m_strExchangeID", "exchange", "exchange_id") - return normalizeCode(code, ex) -} - -func normalizeCode(code, exchange string) string { - code = strings.ToUpper(strings.TrimSpace(code)) - if code == "" { - return "" - } - if i := strings.LastIndex(code, "."); i >= 0 { - symbol, ex := code[:i], code[i+1:] - ex = canonExchange(ex) - if ex == "SH" || ex == "SZ" || ex == "BJ" { - return symbol + "." + ex - } - return "" - } - ex := canonExchange(exchange) - if ex == "" && looksDigits(code, 6) { - switch { - case strings.HasPrefix(code, "92") || code[0] == '4' || code[0] == '8': - ex = "BJ" - case code[0] == '5' || code[0] == '6' || code[0] == '9' || strings.HasPrefix(code, "11"): - ex = "SH" - case code[0] == '0' || code[0] == '1' || code[0] == '2' || code[0] == '3': - ex = "SZ" - } - } - if ex == "SH" || ex == "SZ" || ex == "BJ" { - return code + "." + ex - } - return "" -} - -func canonExchange(ex string) string { - ex = strings.ToUpper(strings.TrimSpace(ex)) - if v, ok := exchangeAlias[ex]; ok { - return v - } - return ex -} - -func looksDigits(s string, n int) bool { - if len(s) != n { - return false - } - for _, r := range s { - if !unicode.IsDigit(r) { - return false - } - } - return true -} - -func mapGet(item map[string]string, names ...string) string { - for _, name := range names { - if v := strings.TrimSpace(item[name]); v != "" { - return v - } - } - return "" -} - -func asIntS(s string) int { - s = strings.TrimSpace(s) - if s == "" { - return 0 - } - var n int - _, _ = fmt.Sscanf(s, "%d", &n) - if n == 0 { - var f float64 - if _, err := fmt.Sscanf(s, "%f", &f); err == nil { - return int(f) - } - } - return n -} diff --git a/go-client/apps/zt/logic/strategy_test.go b/go-client/apps/zt/logic/strategy_test.go new file mode 100644 index 0000000..56223eb --- /dev/null +++ b/go-client/apps/zt/logic/strategy_test.go @@ -0,0 +1,39 @@ +package logic + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestCalcBuyVolume(t *testing.T) { + for _, tt := range []struct { + price, value float64 + want int + }{{10, 5000, 500}, {33, 5000, 100}, {100, 5000, 100}, {0, 5000, 0}} { + if got := calcBuyVolume(tt.price, tt.value); got != tt.want { + t.Fatalf("calcBuyVolume(%v,%v)=%d, want %d", tt.price, tt.value, got, tt.want) + } + } +} + +func TestNewOrderTagIsShortAndFileSafe(t *testing.T) { + tag := newOrderTag("base") + if len(tag) > 24 || !strings.HasPrefix(tag, "zt-") || strings.ContainsAny(tag, `<>:"/\\|?*`) { + t.Fatalf("订单号不符合约束: %q", tag) + } +} + +func TestLoadReceipt(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "order_zt.json") + raw := []byte(`{"order_id":"zt-b-123","qmt_order_id":"9","stock_code":"000001.SZ","side":"buy","requested_volume":500,"traded_volume":500,"status":"filled","updated_at":"2026-08-25T10:00:00+08:00"}`) + if err := os.WriteFile(path, raw, 0o644); err != nil { + t.Fatal(err) + } + got, err := loadReceipt(path) + if err != nil || got.Status != "filled" || got.TradedVolume != 500 { + t.Fatalf("loadReceipt()=%+v, %v", got, err) + } +} diff --git a/go-client/apps/zt/main.go b/go-client/apps/zt/main.go index 8bed68d..d48cc94 100644 --- a/go-client/apps/zt/main.go +++ b/go-client/apps/zt/main.go @@ -9,42 +9,74 @@ import ( "time" "big-qmt/go-client/apps/zt/logic" + "big-qmt/go-client/config" + "big-qmt/go-client/libs" "big-qmt/go-client/sdk" + + "github.com/robfig/cron/v3" ) func main() { log.SetFlags(log.LstdFlags | log.Lmicroseconds) - cfg := logic.LoadConfig() - client := sdk.New(cfg.QMTBaseURL, cfg.QMTToken, cfg.HTTPTimeout).SetAccountType(cfg.AccountType) - books := logic.NewOrderBook() - ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() - startup := context.Background() - assets, err := client.Assets(startup) + // 第一步:只从 YAML 文件加载系统配置和本机账户配置。 + err := config.Load("etc") if err != nil { - log.Printf("[ERROR] 启动获取资产失败: %v", err) + log.Fatalf("[ERROR] 加载配置失败: %v", err) } - positions, err := client.Positions(startup) - if err != nil { - log.Printf("[ERROR] 启动获取持仓失败: %v", err) - positions = []sdk.Position{} - } - logic.Overview(cfg, assets, positions) - log.Printf("[INFO] [ZT] host_key=%s interval=%s signal=%s/a/dcm_signal", cfg.HostKey, cfg.LoopInterval, cfg.APIHost) - log.Printf("[INFO] [ZT] Init Success, waiting trading session") + client := sdk.New(config.Global.QMTBaseURL, config.Global.QMTToken, config.HttpTimeOut) - ticker := time.NewTicker(cfg.LoopInterval) - defer ticker.Stop() - logic.RunOnce(ctx, client, books, cfg) + // 第二步:QMT 未就绪时持续重试,退出信号仍可立即终止等待。 + assets, positions, ok := waitForQMT(ctx, client) + if !ok { + return + } + + // 第三步:连接成功后接管首次持仓并打印账户概览。 + logic.BootstrapState(positions) + logic.Overview(assets, positions) + signals, err := libs.FetchSignal(libs.Dcm_Signal, config.Account.HostKey) + if err != nil { + log.Printf("[ERROR] [ZT] 获取开仓信号失败: %v", err) + signals = &libs.SignalResult{Data: map[string]libs.SignalItem{}} + } + log.Printf("[INFO] [ZT] 已加载 %d 个开仓信号", len(signals.Data)) + + // 第四步:工作日每 30 秒触发,交易时段由 RunOnce 统一判断。 + books := logic.NewOrderBook() + scheduler := cron.New( + cron.WithSeconds(), + cron.WithChain(cron.SkipIfStillRunning(cron.DefaultLogger)), + ) + if _, err := scheduler.AddFunc("0,30 * 9-15 * * 1-5", func() { + logic.RunOnce(ctx, client, books, signals) + }); err != nil { + log.Fatalf("[ERROR] 创建计划任务失败: %v", err) + } + scheduler.Start() + log.Printf("[INFO] [ZT] 计划任务已启动") + <-ctx.Done() + <-scheduler.Stop().Done() + log.Printf("[INFO] [ZT] 停止") +} + +func waitForQMT(ctx context.Context, client *sdk.Client) (*sdk.Assets, []sdk.Position, bool) { for { + attempt, cancel := context.WithTimeout(ctx, config.HttpTimeOut) + assets, assetsErr := client.Assets(attempt) + positions, positionsErr := client.Positions(attempt) + cancel() + if assetsErr == nil && positionsErr == nil { + log.Printf("[INFO] [ZT] QMT连接成功: %s", config.Global.QMTBaseURL) + return assets, positions, true + } + log.Printf("[WARNING] [ZT] QMT未就绪,5秒后重试: assets=%v positions=%v", assetsErr, positionsErr) select { case <-ctx.Done(): - log.Printf("[INFO] [ZT] 停止") - return - case <-ticker.C: - logic.RunOnce(ctx, client, books, cfg) + return nil, nil, false + case <-time.After(5 * time.Second): } } } diff --git a/go-client/apps/zt/策略说明.md b/go-client/apps/zt/策略说明.md new file mode 100644 index 0000000..1a5b63c --- /dev/null +++ b/go-client/apps/zt/策略说明.md @@ -0,0 +1,67 @@ +# 做 T 策略说明 + +## 启动准备 + +策略启动后,根据当前计算机选择对应的交易账户。账户连接成功后,展示总资产、可用资金和当前持仓,并读取一次当日开仓信号。运行期间一直使用这份内存信号,不再重复请求。 + +首次运行且没有历史策略状态时,账户中已有的全部持仓都作为底仓接管。后续运行以已保存的策略状态为准。 + +## 运行时间 + +策略仅在周一至周五运行,周末不执行交易计算。每天运行时段为: + +- 09:30 至 11:30; +- 13:00 至 15:00。 + +交易时段内每 30 秒计算一次。午间休市和收盘后不执行交易计算。 + +## 每轮计算流程 + +每轮读取账户资产、当前持仓、最新行情和委托情况,并处理已经超过等待时间的委托。 + +随后判断开仓信号中的股票是否已经持仓。未持仓信号和已有持仓可以在同一轮中分别处理,不会因为存在未开仓信号而停止管理已有持仓。 + +大盘信号只控制买入行为。大盘不允许开仓时,不新建底仓,也不补仓;止盈卖出、委托清理和持仓状态同步仍然正常进行。 + +## 底仓开仓 + +开仓信号对应的股票尚未持仓,且大盘允许开仓时,进入价格观察阶段。 + +观察期间持续记录最低价格。当价格从观察低点反弹达到设定幅度后,触发底仓买入。 + +买入数量根据配置的 `buy_value` 和当前股价计算,向下取整为整手。不足一手时按一手买入。 + +提交底仓买入后,策略记录正在开仓的状态,等待委托和持仓结果确认。 + +## 补仓 + +底仓亏损达到配置的补仓触发比例后,进入补仓价格观察阶段。 + +观察期间持续记录新的最低价格。当价格从低点反弹达到设定幅度,且大盘允许买入时,触发补仓。 + +补仓数量同样根据 `buy_value` 和补仓时的股价独立计算,因此补仓数量不要求与底仓数量相同。部分成交的数量按实际补仓数量接管。 + +## 网格止盈 + +底仓和补仓分别计算盈利比例,并分别记录本次运行期间达到的最高盈利网格。 + +盈利达到最低止盈比例后,策略开始跟踪最高网格。当盈利从最高网格回落时,触发对应仓位的卖出: + +- 补仓达到回撤条件时,只卖出补仓部分; +- 底仓达到回撤条件时,可以卖出全部底仓。 + +最高盈利网格只在本次程序运行期间保留,程序重新启动后重新开始记录。 + +## 委托确认 + +每笔委托生成一个不超过 24 个字符的唯一订单号。策略根据账户回写的委托结果确认订单状态,并将结果同步到策略状态。 + +回写状态包括已提交、部分成交、全部成交、已撤销和已拒绝。没有回写结果的订单不会直接重复下单。 + +委托超过等待时间后,策略先检查当前委托和持仓。如果委托已经不存在,则释放该股票的等待状态,允许后续交易轮次重新判断,但不会在释放状态的同一轮自动重复下单。 + +## 状态保护 + +策略持续保存底仓数量与成本、补仓数量与成本、当前待确认动作和最近委托状态。 + +状态文件不存在时,启动前持仓全部作为底仓接管。状态文件内容异常时,策略停止交易处理,不会自动删除或重建异常文件。 diff --git a/go-client/config/config.go b/go-client/config/config.go new file mode 100644 index 0000000..178a31d --- /dev/null +++ b/go-client/config/config.go @@ -0,0 +1,98 @@ +package config + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "gopkg.in/yaml.v3" +) + +var ( + Global *GlobalConfig + Account *AccountConfig + HttpTimeOut time.Duration = 5 * time.Second +) + +type GlobalConfig struct { + QMTBaseURL string `yaml:"qmt_base_url"` + QMTToken string `yaml:"qmt_token"` + APIHost string `yaml:"api_host"` + QMTDataDir string `yaml:"qmt_data_dir"` + Hosts map[string]string `yaml:"hosts"` +} + +type AccountConfig struct { + AccountID string `yaml:"account_id"` + HostKey string `yaml:"host_key"` + OrderTimeoutSec int `yaml:"order_timeout_seconds"` + BuyValue float64 `yaml:"buy_value"` + MinCashRatio float64 `yaml:"min_cash_ratio"` + LossTriggerPct float64 `yaml:"loss_trigger_pct"` + GridStepPct float64 `yaml:"grid_step_pct"` + MinProfitPct float64 `yaml:"min_profit_pct"` + WatchTimeoutSec int `yaml:"watch_timeout_seconds"` + ReboundThreshold float64 `yaml:"rebound_threshold"` +} + +// Load 根据 global.yaml 中的 hosts 映射加载当前计算机的账户配置。 +func Load(etcDir string) error { + var global GlobalConfig + if err := readYAML(filepath.Join(etcDir, "global.yaml"), &global); err != nil { + return err + } + hostname, err := os.Hostname() + if err != nil { + return fmt.Errorf("读取计算机名失败: %w", err) + } + if global.QMTBaseURL == "" || global.APIHost == "" || global.QMTDataDir == "." { + return fmt.Errorf("Global 配置缺少必要参数") + } + if err := os.MkdirAll(global.QMTDataDir, 0o755); err != nil { + return fmt.Errorf("创建目录 %s 失败: %w", global.QMTDataDir, err) + } + + accountFile := hostAccountFile(global.Hosts, hostname) + if accountFile == "" { + return fmt.Errorf("global.yaml 未配置计算机 %q", hostname) + } + if filepath.Ext(accountFile) == "" { + accountFile += ".yaml" + } + + var account AccountConfig + if err := readYAML(filepath.Join(etcDir, accountFile), &account); err != nil { + return err + } + + if account.BuyValue <= 0 || account.GridStepPct <= 0 { + return fmt.Errorf("buy_value、grid_step_pct 和超时时间必须大于 0") + } + + Global = &global + Account = &account + + return nil +} + +func readYAML(path string, dest any) error { + raw, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("读取配置 %s 失败: %w", path, err) + } + if err := yaml.Unmarshal(raw, dest); err != nil { + return fmt.Errorf("解析配置 %s 失败: %w", path, err) + } + return nil +} + +func hostAccountFile(hosts map[string]string, hostname string) string { + for host, file := range hosts { + if strings.EqualFold(strings.TrimSpace(host), strings.TrimSpace(hostname)) { + return strings.TrimSpace(file) + } + } + return "" +} diff --git a/go-client/etc/dev.yaml b/go-client/etc/dev.yaml new file mode 100644 index 0000000..da6c960 --- /dev/null +++ b/go-client/etc/dev.yaml @@ -0,0 +1,11 @@ +# 当前计算机使用的账户和策略参数。 +account_id: CHANGE_ME +host_key: "" +buy_value: 5000 +min_cash_ratio: 0.10 +loss_trigger_pct: -30 +grid_step_pct: 1 +min_profit_pct: 2 +rebound_threshold: 0.61 +order_timeout_seconds: 60 +watch_timeout_seconds: 300 diff --git a/go-client/etc/global.yaml b/go-client/etc/global.yaml new file mode 100644 index 0000000..c9698b1 --- /dev/null +++ b/go-client/etc/global.yaml @@ -0,0 +1,9 @@ +# 系统公共参数。hosts 将 Windows 计算机名映射到账户配置文件。 +qmt_base_url: http://127.0.0.1:10086 +qmt_token: QMTbyYanweidong +api_host: http://go.apinb.com +qmt_data_dir: D:/qmt_strategy_data +state_dir: D:/qmt_strategy_state + +hosts: + DESKTOP-39H91QV: dev.yaml diff --git a/go-client/go.mod b/go-client/go.mod index 8d6bbae..b456f0f 100644 --- a/go-client/go.mod +++ b/go-client/go.mod @@ -1,3 +1,8 @@ module big-qmt/go-client go 1.22 + +require ( + github.com/robfig/cron/v3 v3.0.1 + gopkg.in/yaml.v3 v3.0.1 +) diff --git a/go-client/go.sum b/go-client/go.sum new file mode 100644 index 0000000..d8beffd --- /dev/null +++ b/go-client/go.sum @@ -0,0 +1,6 @@ +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= +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= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/go-client/libs/calc.go b/go-client/libs/calc.go new file mode 100644 index 0000000..5c9b492 --- /dev/null +++ b/go-client/libs/calc.go @@ -0,0 +1,25 @@ +package libs + +import ( + "math/rand" + "time" +) + +var letters = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ") + +func randStr(n int) string { + b := make([]rune, n) + for i := range b { + b[i] = letters[rand.Intn(len(letters))] + } + return string(b) +} + +func TradingTime(t time.Time) bool { + if t.Weekday() == time.Saturday || t.Weekday() == time.Sunday { + return false + } + second := t.Hour()*3600 + t.Minute()*60 + t.Second() + return (second >= 9*3600+30*60 && second <= 11*3600+30*60) || + (second >= 13*3600 && second <= 15*3600) +} diff --git a/go-client/libs/const.go b/go-client/libs/const.go new file mode 100644 index 0000000..5ebf0f2 --- /dev/null +++ b/go-client/libs/const.go @@ -0,0 +1,8 @@ +package libs + +import "time" + +var ( + API_HOST = "http://139.224.247.176:13499" + HTTPTimeout = 5 * time.Second +) diff --git a/go-client/libs/http.go b/go-client/libs/http.go index 6654abd..cb62abd 100644 --- a/go-client/libs/http.go +++ b/go-client/libs/http.go @@ -1,17 +1,15 @@ package libs +import ( + "fmt" + "io" + "net/http" + "strings" + "time" +) -func getJSON(rawURL string, params url.Values, timeout time.Duration) (map[string]any, error) { - if timeout <= 0 { - timeout = 5 * time.Second - } - if params != nil { - if strings.Contains(rawURL, "?") { - rawURL += "&" + params.Encode() - } else { - rawURL += "?" + params.Encode() - } - } +// GetJSON 请求 JSON 接口并返回对象。 +func GetJSON(rawURL string, timeout time.Duration) ([]byte, error) { req, err := http.NewRequest(http.MethodGet, rawURL, nil) if err != nil { return nil, err @@ -31,9 +29,5 @@ func getJSON(rawURL string, params url.Values, timeout time.Duration) (map[strin if resp.StatusCode >= 400 { return nil, fmt.Errorf("http %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) } - out := map[string]any{} - if err := json.Unmarshal(body, &out); err != nil { - return nil, err - } - return out, nil + return body, nil } diff --git a/go-client/libs/market.go b/go-client/libs/market.go index aa394de..6a4ecca 100644 --- a/go-client/libs/market.go +++ b/go-client/libs/market.go @@ -3,24 +3,33 @@ package libs import ( "encoding/json" "fmt" - "io" "log" - "net/http" - "net/url" "strings" - "time" +) + +var ( + MarketUrl = "/a/market" + Period = "60m" ) // AllowOpen 每次开仓或补仓前取 60 分钟大盘信号,只有 UP 才放行。 -func AllowOpen(apiHost string, timeout time.Duration) bool { - rawURL := strings.TrimRight(apiHost, "/") + "/a/market" - payload, err := getJSON(rawURL, url.Values{"period": {"60m"}}, timeout) +func AllowOpen() bool { + // gen url. + fullUrl := fmt.Sprintf("%s%s?period=%s&t=%s", API_HOST, MarketUrl, Period, randStr(16)) + payload, err := GetJSON(fullUrl, HTTPTimeout) if err != nil { - log.Printf("[ERROR] 获取60m大盘信号失败: %s %v", rawURL, err) + log.Printf("[ERROR] 获取大盘指数失败: %s %v", fullUrl, err) return false } - status := Status(payload) - log.Printf("[INFO] 大盘信号: url=%s status=%s", rawURL, status) + var result map[string]any + err = json.Unmarshal(payload, &result) + if err != nil { + log.Printf("[ERROR] 获取大盘指数解析: %v", err) + return false + } + + status := Status(result) + log.Printf("[INFO] 大盘信号: url=%s status=%s", fullUrl, status) return status == "UP" } diff --git a/go-client/libs/signal.go b/go-client/libs/signal.go new file mode 100644 index 0000000..038d022 --- /dev/null +++ b/go-client/libs/signal.go @@ -0,0 +1,46 @@ +package libs + +import ( + "encoding/json" + "fmt" + "log" +) + +var ( + Dcm_Signal = "/a/dcm_signal" +) + +type SignalResult struct { + Code string `json:"code"` + Total int `json:"total"` + Updated string `json:"updated"` + Data map[string]SignalItem `json:"data"` + Message string `json:"message"` +} + +type SignalItem struct { + Code string `json:"code"` + Name string `json:"name"` + Desc string `json:"desc"` + LastClose float64 `json:"last_close"` + TechIndicator map[string]float64 `json:"tech_indicator"` +} + +// FetchSignals 启动时读取信号,运行期间直接使用内存数据。 +func FetchSignal(subUrl, host_key string) (*SignalResult, error) { + // gen url. + fullUrl := fmt.Sprintf("%s%s?host_key=%s&t=%s", API_HOST, subUrl, host_key, randStr(16)) + // doing + payload, err := GetJSON(fullUrl, HTTPTimeout) + if err != nil { + log.Printf("[ERROR] 获取60m大盘信号失败: %s %v", fullUrl, err) + return nil, err + } + + var result SignalResult + err = json.Unmarshal(payload, &result) + if err != nil { + return nil, err + } + return &result, nil +} diff --git a/go-client/sdk/trade.go b/go-client/sdk/trade.go index 788072b..b45bacb 100644 --- a/go-client/sdk/trade.go +++ b/go-client/sdk/trade.go @@ -11,13 +11,14 @@ const ( ) type PassorderRequest struct { - OpType int `json:"opType"` - OrderType int `json:"orderType,omitempty"` - Stock string `json:"stock"` - PrType int `json:"prType,omitempty"` - Price float64 `json:"price"` - Volume int `json:"volume"` - QuickTrade int `json:"quickTrade,omitempty"` + OpType int `json:"opType"` + OrderType int `json:"orderType,omitempty"` + Stock string `json:"stock"` + PrType int `json:"prType,omitempty"` + Price float64 `json:"price"` + Volume int `json:"volume"` + QuickTrade int `json:"quickTrade,omitempty"` + StrategyName string `json:"strategyName,omitempty"` } func (c *Client) Passorder(ctx context.Context, req PassorderRequest) (*OrderRefResult, error) { @@ -28,20 +29,26 @@ func (c *Client) Passorder(ctx context.Context, req PassorderRequest) (*OrderRef return &out, nil } -// PassorderLatest 按最新价下单。服务端策略名写死为 qmt,无法传投资备注。 +// PassorderLatest 按最新价下单,不附加策略订单号。 func (c *Client) PassorderLatest(ctx context.Context, buy bool, stock string, volume int) (*OrderRefResult, error) { + return c.PassorderLatestTagged(ctx, buy, stock, volume, "") +} + +// PassorderLatestTagged 使用 strategyName 将本地唯一订单号传给 QMT。 +func (c *Client) PassorderLatestTagged(ctx context.Context, buy bool, stock string, volume int, orderID string) (*OrderRefResult, error) { op := OpSell if buy { op = OpBuy } return c.Passorder(ctx, PassorderRequest{ - OpType: op, - OrderType: OrderTypeVolume, - Stock: stock, - PrType: PrTypeLatest, - Price: -1, - Volume: volume, - QuickTrade: QuickTradeNow, + OpType: op, + OrderType: OrderTypeVolume, + Stock: stock, + PrType: PrTypeLatest, + Price: -1, + Volume: volume, + QuickTrade: QuickTradeNow, + StrategyName: orderID, }) }