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