This commit is contained in:
2026-08-25 22:35:44 +08:00
parent ec58641d09
commit f14423418a
23 changed files with 738 additions and 680 deletions

View File

@@ -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)
}

View File

@@ -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"
}

View File

@@ -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...))
}

View File

@@ -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)

View File

@@ -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)
}

View File

@@ -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)
}

View File

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

View File

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

View File

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

View File

@@ -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)
}
}

View File

@@ -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):
}
}
}

View File

@@ -0,0 +1,67 @@
# 做 T 策略说明
## 启动准备
策略启动后,根据当前计算机选择对应的交易账户。账户连接成功后,展示总资产、可用资金和当前持仓,并读取一次当日开仓信号。运行期间一直使用这份内存信号,不再重复请求。
首次运行且没有历史策略状态时,账户中已有的全部持仓都作为底仓接管。后续运行以已保存的策略状态为准。
## 运行时间
策略仅在周一至周五运行,周末不执行交易计算。每天运行时段为:
- 09:30 至 11:30
- 13:00 至 15:00。
交易时段内每 30 秒计算一次。午间休市和收盘后不执行交易计算。
## 每轮计算流程
每轮读取账户资产、当前持仓、最新行情和委托情况,并处理已经超过等待时间的委托。
随后判断开仓信号中的股票是否已经持仓。未持仓信号和已有持仓可以在同一轮中分别处理,不会因为存在未开仓信号而停止管理已有持仓。
大盘信号只控制买入行为。大盘不允许开仓时,不新建底仓,也不补仓;止盈卖出、委托清理和持仓状态同步仍然正常进行。
## 底仓开仓
开仓信号对应的股票尚未持仓,且大盘允许开仓时,进入价格观察阶段。
观察期间持续记录最低价格。当价格从观察低点反弹达到设定幅度后,触发底仓买入。
买入数量根据配置的 `buy_value` 和当前股价计算,向下取整为整手。不足一手时按一手买入。
提交底仓买入后,策略记录正在开仓的状态,等待委托和持仓结果确认。
## 补仓
底仓亏损达到配置的补仓触发比例后,进入补仓价格观察阶段。
观察期间持续记录新的最低价格。当价格从低点反弹达到设定幅度,且大盘允许买入时,触发补仓。
补仓数量同样根据 `buy_value` 和补仓时的股价独立计算,因此补仓数量不要求与底仓数量相同。部分成交的数量按实际补仓数量接管。
## 网格止盈
底仓和补仓分别计算盈利比例,并分别记录本次运行期间达到的最高盈利网格。
盈利达到最低止盈比例后,策略开始跟踪最高网格。当盈利从最高网格回落时,触发对应仓位的卖出:
- 补仓达到回撤条件时,只卖出补仓部分;
- 底仓达到回撤条件时,可以卖出全部底仓。
最高盈利网格只在本次程序运行期间保留,程序重新启动后重新开始记录。
## 委托确认
每笔委托生成一个不超过 24 个字符的唯一订单号。策略根据账户回写的委托结果确认订单状态,并将结果同步到策略状态。
回写状态包括已提交、部分成交、全部成交、已撤销和已拒绝。没有回写结果的订单不会直接重复下单。
委托超过等待时间后,策略先检查当前委托和持仓。如果委托已经不存在,则释放该股票的等待状态,允许后续交易轮次重新判断,但不会在释放状态的同一轮自动重复下单。
## 状态保护
策略持续保存底仓数量与成本、补仓数量与成本、当前待确认动作和最近委托状态。
状态文件不存在时,启动前持仓全部作为底仓接管。状态文件内容异常时,策略停止交易处理,不会自动删除或重建异常文件。

View File

@@ -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 ""
}

11
go-client/etc/dev.yaml Normal file
View File

@@ -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

View File

@@ -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

View File

@@ -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
)

6
go-client/go.sum Normal file
View File

@@ -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=

25
go-client/libs/calc.go Normal file
View File

@@ -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)
}

8
go-client/libs/const.go Normal file
View File

@@ -0,0 +1,8 @@
package libs
import "time"
var (
API_HOST = "http://139.224.247.176:13499"
HTTPTimeout = 5 * time.Second
)

View File

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

View File

@@ -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"
}

46
go-client/libs/signal.go Normal file
View File

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

View File

@@ -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,
})
}