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