dev 3
This commit is contained in:
@@ -3,17 +3,23 @@ package logic
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"big-qmt/go-client/config"
|
||||||
"big-qmt/go-client/libs"
|
"big-qmt/go-client/libs"
|
||||||
"big-qmt/go-client/sdk"
|
"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.Println("\n" + strings.Repeat("=", 80))
|
||||||
fmt.Printf("【时间】%s\n", time.Now().Format("2006-01-02 15:04:05"))
|
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 {
|
if assets != nil {
|
||||||
fmt.Printf("【资金】总资产:%.2f元,可用资金:%.2f元\n", assets.Total, assets.Available)
|
fmt.Printf("【资金】总资产:%.2f元,可用资金:%.2f元\n", assets.Total, assets.Available)
|
||||||
} else {
|
} else {
|
||||||
@@ -25,18 +31,20 @@ func Overview(cfg Config, assets *sdk.Assets, positions []sdk.Position) {
|
|||||||
if p.Volume <= 0 {
|
if p.Volume <= 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
code := normalizeCode(p.StockCode, "")
|
code := p.StockCode
|
||||||
fmt.Printf("【持仓】%s %s 持仓=%d 可用=%d 冻结=%d 在途=%d 昨仓=%d 成本=%.3f 现价=%.3f 市值=%.2f 浮盈=%.2f 盈亏比例=%.2f%%\n",
|
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,
|
code, p.StockName, p.Volume, p.CanUseVolume, p.FrozenVolume, p.OnRoadVolume, p.YesterdayVolume,
|
||||||
p.OpenPrice, p.LastPrice, p.MarketValue, p.FloatProfit, p.ProfitRate*100)
|
p.OpenPrice, p.LastPrice, p.MarketValue, p.FloatProfit, p.ProfitRate*100)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func RunOnce(ctx context.Context, client *sdk.Client, books *OrderBook, cfg Config) {
|
func RunOnce(ctx context.Context, client *sdk.Client, books *OrderBook, signals *libs.SignalResult) {
|
||||||
if !tradingTime(time.Now()) {
|
if !libs.TradingTime(time.Now()) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
roundCtx, cancel := context.WithTimeout(ctx, cfg.HTTPTimeout*4)
|
// 每轮先消费 QMT 回写,终态订单会立即释放本地委托锁。
|
||||||
|
books.readReceipts()
|
||||||
|
roundCtx, cancel := context.WithTimeout(ctx, config.HttpTimeOut*4)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
assets, err := client.Assets(roundCtx)
|
assets, err := client.Assets(roundCtx)
|
||||||
@@ -50,24 +58,19 @@ func RunOnce(ctx context.Context, client *sdk.Client, books *OrderBook, cfg Conf
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
signals := fetchSignal(cfg, "dcm_signal")
|
|
||||||
seen := map[string]struct{}{}
|
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) {
|
addCode := func(code string) {
|
||||||
n := normalizeCode(code, "")
|
if code == "" {
|
||||||
if n == "" {
|
|
||||||
n = strings.ToUpper(strings.TrimSpace(code))
|
|
||||||
}
|
|
||||||
if n == "" {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if _, ok := seen[n]; ok {
|
if _, ok := seen[code]; ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
seen[n] = struct{}{}
|
seen[code] = struct{}{}
|
||||||
stockList = append(stockList, n)
|
stockList = append(stockList, code)
|
||||||
}
|
}
|
||||||
for code := range signals {
|
for code := range signals.Data {
|
||||||
addCode(code)
|
addCode(code)
|
||||||
}
|
}
|
||||||
for _, p := range positions {
|
for _, p := range positions {
|
||||||
@@ -82,46 +85,40 @@ func RunOnce(ctx context.Context, client *sdk.Client, books *OrderBook, cfg Conf
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
for code, tick := range raw {
|
for code, tick := range raw {
|
||||||
ticks[normalizeCode(code, "")] = tick
|
|
||||||
ticks[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) {
|
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) {
|
||||||
books.cancelExpired(ctx, client, cfg)
|
if !books.cancelExpired(ctx, client) {
|
||||||
|
logf("ERROR", "[ZT] 委托查询失败,本轮跳过")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
buys, sells, ok := books.activeSets(ctx, client)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
hold := positionCodes(positions)
|
hold := positionCodes(positions)
|
||||||
openSignals := map[string]map[string]any{}
|
openSignals := map[string]libs.SignalItem{}
|
||||||
for code, signal := range signals {
|
for code, signal := range signals {
|
||||||
norm := normalizeCode(code, "")
|
if _, held := hold[code]; held {
|
||||||
if norm == "" {
|
|
||||||
norm = code
|
|
||||||
}
|
|
||||||
if _, held := hold[norm]; held {
|
|
||||||
continue
|
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 len(openSignals) > 0 {
|
||||||
if books.refresh(ctx, client, cfg) {
|
openSignal(ctx, client, books, ticks, openSignals, marketOK, &buyBudget)
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
marketOK := libs.AllowOpen(cfg.APIHost, cfg.HTTPTimeout)
|
managePositions(ctx, client, books, ticks, positions, buys, sells, marketOK, &buyBudget)
|
||||||
if len(openSignals) > 0 {
|
|
||||||
openSignal(ctx, client, books, cfg, assets, ticks, openSignals, marketOK)
|
|
||||||
}
|
|
||||||
managePositions(ctx, client, books, cfg, ticks, positions, marketOK)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"big-qmt/go-client/config"
|
||||||
|
"big-qmt/go-client/libs"
|
||||||
"big-qmt/go-client/sdk"
|
"big-qmt/go-client/sdk"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -19,30 +21,21 @@ var openDip = struct {
|
|||||||
store map[string]dipWatch
|
store map[string]dipWatch
|
||||||
}{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 {
|
if !marketOK {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if assets == nil {
|
if buyBudget == nil || *buyBudget <= 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if assets.Available < assets.Total*cfg.MinCashRatio {
|
state := getState()
|
||||||
return
|
|
||||||
}
|
|
||||||
state := getState(cfg)
|
|
||||||
if state.LoadError != "" {
|
if state.LoadError != "" {
|
||||||
logf("ERROR", "[ZT][开仓] 状态文件异常,禁止新开仓: %s", state.LoadError)
|
logf("ERROR", "[ZT][开仓] 状态文件异常,禁止新开仓: %s", state.LoadError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
for signalCode, signal := range openSignals {
|
for code := range openSignals {
|
||||||
code := normalizeCode(signalCode, "")
|
|
||||||
if code == "" {
|
if code == "" {
|
||||||
if c, ok := signal["code"].(string); ok {
|
logf("ERROR", "[ZT][开仓] 无效股票代码")
|
||||||
code = normalizeCode(c, "")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if code == "" {
|
|
||||||
logf("ERROR", "[ZT][开仓] 无效股票代码=%s", signalCode)
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if state.Get(code) != nil {
|
if state.Get(code) != nil {
|
||||||
@@ -52,35 +45,42 @@ func openSignal(ctx context.Context, client *sdk.Client, books *OrderBook, cfg C
|
|||||||
if price <= 0 {
|
if price <= 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if !dipTriggered(&openDip.mu, openDip.store, cfg, "开仓", code, price) {
|
if !dipTriggered(&openDip.mu, openDip.store, "开仓", code, price) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
volume := calcOpenVolume(price, cfg.OpenMoney)
|
volume := calcBuyVolume(price, config.Account.BuyValue)
|
||||||
if volume <= 0 {
|
if volume <= 0 {
|
||||||
continue
|
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
|
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()
|
state.Save()
|
||||||
logf("INFO", "[ZT][开仓] %s 买入 %d 股", code, volume)
|
logf("INFO", "[ZT][开仓] %s 买入 %d 股", code, volume)
|
||||||
}
|
}
|
||||||
state.Save()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func calcOpenVolume(price, openMoney float64) int {
|
func calcBuyVolume(price, buyValue float64) int {
|
||||||
if price <= 0 || openMoney <= 0 {
|
if price <= 0 || buyValue <= 0 {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
hands := int(math.Floor(openMoney / (price * 100)))
|
// 不足一手时仍按最低一手委托。
|
||||||
|
hands := int(math.Floor(buyValue / (price * 100)))
|
||||||
if hands == 0 {
|
if hands == 0 {
|
||||||
hands = 1
|
hands = 1
|
||||||
}
|
}
|
||||||
return hands * 100
|
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 {
|
if price <= 0 {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -89,13 +89,13 @@ func dipTriggered(mu *sync.Mutex, store map[string]dipWatch, cfg Config, tag, co
|
|||||||
now := time.Now()
|
now := time.Now()
|
||||||
watch, ok := store[code]
|
watch, ok := store[code]
|
||||||
if !ok || now.After(watch.ExpiresAt) || now.Equal(watch.ExpiresAt) {
|
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)
|
logf("INFO", "[%s-观察] %s 现价=%.2f", tag, code, price)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if price < watch.LastClose {
|
if price < watch.LastClose {
|
||||||
watch.LastClose = price
|
watch.LastClose = price
|
||||||
watch.ExpiresAt = now.Add(cfg.WatchTimeout)
|
watch.ExpiresAt = now.Add(time.Duration(config.Account.WatchTimeoutSec) * time.Second)
|
||||||
store[code] = watch
|
store[code] = watch
|
||||||
logf("INFO", "[%s-下跌] %s 刷新低点=%.2f", tag, code, price)
|
logf("INFO", "[%s-下跌] %s 刷新低点=%.2f", tag, code, price)
|
||||||
return false
|
return false
|
||||||
@@ -104,8 +104,8 @@ func dipTriggered(mu *sync.Mutex, store map[string]dipWatch, cfg Config, tag, co
|
|||||||
if rebound <= 0 {
|
if rebound <= 0 {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if rebound < cfg.ReboundThreshold {
|
if rebound < config.Account.ReboundThreshold {
|
||||||
logf("INFO", "[%s-等待] %s 反弹=%.2f%% 阈值=%.2f%%", tag, code, rebound, cfg.ReboundThreshold)
|
logf("INFO", "[%s-等待] %s 反弹=%.2f%% 阈值=%.2f%%", tag, code, rebound, config.Account.ReboundThreshold)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
delete(store, code)
|
delete(store, code)
|
||||||
|
|||||||
@@ -4,18 +4,34 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"big-qmt/go-client/config"
|
||||||
"big-qmt/go-client/sdk"
|
"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 (
|
const (
|
||||||
opBuyStock = 23
|
opBuyStock = 23
|
||||||
opBuyAlt = 48
|
opBuyAlt = 48
|
||||||
|
sideBuy = "buy"
|
||||||
|
sideSell = "sell"
|
||||||
)
|
)
|
||||||
|
|
||||||
var activeStatuses = map[int]struct{}{
|
var activeStatuses = map[int]struct{}{
|
||||||
@@ -44,11 +60,8 @@ func (o parsedOrder) cancelVolume() int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type submission struct {
|
type submission struct {
|
||||||
Code string
|
Code string
|
||||||
Side string
|
Side string
|
||||||
Volume int
|
|
||||||
At time.Time
|
|
||||||
Tag string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type OrderBook struct {
|
type OrderBook struct {
|
||||||
@@ -58,15 +71,63 @@ type OrderBook struct {
|
|||||||
buyLocks map[string]time.Time
|
buyLocks map[string]time.Time
|
||||||
sellLocks map[string]time.Time
|
sellLocks map[string]time.Time
|
||||||
subs []submission
|
subs []submission
|
||||||
|
receipts map[string]time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewOrderBook() *OrderBook {
|
func NewOrderBook() *OrderBook {
|
||||||
return &OrderBook{
|
return &OrderBook{
|
||||||
buyLocks: map[string]time.Time{},
|
buyLocks: map[string]time.Time{},
|
||||||
sellLocks: 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() {
|
func (o *OrderBook) invalidate() {
|
||||||
o.mu.Lock()
|
o.mu.Lock()
|
||||||
defer o.mu.Unlock()
|
defer o.mu.Unlock()
|
||||||
@@ -74,7 +135,7 @@ func (o *OrderBook) invalidate() {
|
|||||||
o.cached = nil
|
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()
|
o.mu.Lock()
|
||||||
if o.hasCache {
|
if o.hasCache {
|
||||||
out := append([]parsedOrder(nil), o.cached...)
|
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
|
return orders, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (o *OrderBook) refresh(ctx context.Context, client *sdk.Client, cfg Config) bool {
|
func (o *OrderBook) activeSets(ctx context.Context, client *sdk.Client) (buys, sells map[string]struct{}, ok bool) {
|
||||||
o.invalidate()
|
orders, err := o.query(ctx, client)
|
||||||
_, 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)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, false
|
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 == "" {
|
if !item.Active || item.StockCode == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if item.Side == "buy" {
|
if item.Side == sideBuy {
|
||||||
buys[item.StockCode] = struct{}{}
|
buys[item.StockCode] = struct{}{}
|
||||||
} else {
|
} else {
|
||||||
sells[item.StockCode] = struct{}{}
|
sells[item.StockCode] = struct{}{}
|
||||||
@@ -123,16 +178,17 @@ func (o *OrderBook) activeSets(ctx context.Context, client *sdk.Client, cfg Conf
|
|||||||
return buys, sells, true
|
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()
|
o.invalidate()
|
||||||
orders, err := o.query(ctx, client, cfg)
|
orders, err := o.query(ctx, client)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
state := getState(cfg)
|
state := getState()
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
timeout := cfg.OrderTimeout
|
timeout := time.Duration(config.Account.OrderTimeoutSec) * time.Second
|
||||||
seen := map[string]struct{}{}
|
seen := map[string]struct{}{}
|
||||||
|
cancelled := false
|
||||||
for _, order := range orders {
|
for _, order := range orders {
|
||||||
if !order.Active || order.StockCode == "" {
|
if !order.Active || order.StockCode == "" {
|
||||||
continue
|
continue
|
||||||
@@ -178,8 +234,12 @@ func (o *OrderBook) cancelExpired(ctx context.Context, client *sdk.Client, cfg C
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
o.unlockSide(order.StockCode, order.Side)
|
o.unlockSide(order.StockCode, order.Side)
|
||||||
|
cancelled = true
|
||||||
logf("INFO", "[ZT][委托] 撤销超时单 %s %s %s volume=%d", order.OrderID, order.StockCode, order.Side, vol)
|
logf("INFO", "[ZT][委托] 撤销超时单 %s %s %s volume=%d", order.OrderID, order.StockCode, order.Side, vol)
|
||||||
}
|
}
|
||||||
|
if cancelled {
|
||||||
|
o.invalidate()
|
||||||
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,10 +263,10 @@ func (o *OrderBook) claimed(state *ZTState, order parsedOrder) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
switch item.Pending {
|
switch item.Pending {
|
||||||
case "base_opening", "add":
|
case pendingBaseOpening, pendingAdd:
|
||||||
return order.Side == "buy"
|
return order.Side == sideBuy
|
||||||
case "sell_add", "sell_base":
|
case pendingSellAdd, pendingSellBase:
|
||||||
return order.Side == "sell"
|
return order.Side == sideSell
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -227,29 +287,29 @@ func (o *OrderBook) unlockSide(code, side string) {
|
|||||||
o.subs = o.subs[:n]
|
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 {
|
if _, ok := active[code]; ok {
|
||||||
return true
|
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()
|
o.mu.Lock()
|
||||||
defer o.mu.Unlock()
|
defer o.mu.Unlock()
|
||||||
ts, ok := o.locks(side)[code]
|
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 {
|
func (o *OrderBook) locks(side string) map[string]time.Time {
|
||||||
if side == "buy" {
|
if side == sideBuy {
|
||||||
return o.buyLocks
|
return o.buyLocks
|
||||||
}
|
}
|
||||||
return o.sellLocks
|
return o.sellLocks
|
||||||
}
|
}
|
||||||
|
|
||||||
func (o *OrderBook) hasActive(ctx context.Context, client *sdk.Client, cfg Config, code, side string) bool {
|
func (o *OrderBook) hasActive(ctx context.Context, client *sdk.Client, code, side string) bool {
|
||||||
orders, err := o.query(ctx, client, cfg)
|
orders, err := o.query(ctx, client)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -261,43 +321,43 @@ func (o *OrderBook) hasActive(ctx context.Context, client *sdk.Client, cfg Confi
|
|||||||
return false
|
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 {
|
if volume <= 0 || volume%100 != 0 {
|
||||||
logf("ERROR", "[ZT][委托] %s 拒绝非整手数量=%d", code, volume)
|
logf("ERROR", "[ZT][委托] %s 拒绝非整手数量=%d", code, volume)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if o.locked(cfg, code, side) {
|
if o.locked(code, side) {
|
||||||
logf("INFO", "[ZT][委托] %s %s锁定中", code, side)
|
logf("INFO", "[ZT][委托] %s %s锁定中", code, side)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if o.hasActive(ctx, client, cfg, code, side) {
|
if o.hasActive(ctx, client, code, side) {
|
||||||
logf("INFO", "[ZT][委托] %s 已有%s在途委托", code, side)
|
logf("INFO", "[ZT][委托] %s 已有%s在途委托", code, side)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
_, err := client.PassorderLatest(ctx, side == "buy", code, volume)
|
_, err := client.PassorderLatestTagged(ctx, side == sideBuy, code, volume, tag)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logf("ERROR", "[ZT][委托] %s 异常: %v", code, err)
|
logf("ERROR", "[ZT][委托] %s 异常: %v", code, err)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
o.mu.Lock()
|
o.mu.Lock()
|
||||||
o.locks(side)[code] = time.Now()
|
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()
|
o.mu.Unlock()
|
||||||
logf("INFO", "[ZT][委托] 已提交 %s %s %d股 tag=%s", side, code, volume, tag)
|
logf("INFO", "[ZT][委托] 已提交 %s %s %d股 tag=%s", side, code, volume, tag)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseOrder(item map[string]string) parsedOrder {
|
func parseOrder(item map[string]string) parsedOrder {
|
||||||
operation := asIntS(mapGet(item, "m_nOffsetFlag", "m_nOrderType", "order_type"))
|
operation, _ := strconv.Atoi(item["m_nOffsetFlag"])
|
||||||
status := asIntS(mapGet(item, "m_nOrderStatus", "order_status", "status"))
|
status, _ := strconv.Atoi(item["m_nOrderStatus"])
|
||||||
tag := mapGet(item, "m_strRemark", "m_strUserOrderId", "order_remark")
|
tag := item["m_strRemark"]
|
||||||
orderTime := int64(asIntS(mapGet(item, "m_nOrderTime", "order_time")))
|
orderTime, _ := strconv.ParseInt(item["m_nOrderTime"], 10, 64)
|
||||||
if orderTime > 1e11 {
|
if orderTime > 1e11 {
|
||||||
orderTime /= 1000
|
orderTime /= 1000
|
||||||
}
|
}
|
||||||
if orderTime <= 0 {
|
if orderTime <= 0 {
|
||||||
date := mapGet(item, "m_strInsertDate")
|
date := item["m_strInsertDate"]
|
||||||
clock := strings.ReplaceAll(mapGet(item, "m_strInsertTime"), ":", "")
|
clock := strings.ReplaceAll(item["m_strInsertTime"], ":", "")
|
||||||
if date != "" {
|
if date != "" {
|
||||||
if len(clock) < 6 {
|
if len(clock) < 6 {
|
||||||
clock = strings.Repeat("0", 6-len(clock)) + clock
|
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 {
|
if operation == opBuyStock || operation == opBuyAlt {
|
||||||
side = "buy"
|
side = sideBuy
|
||||||
}
|
}
|
||||||
left := asIntS(mapGet(item, "m_nVolumeTotal", "volume_left"))
|
left, _ := strconv.Atoi(item["m_nVolumeTotal"])
|
||||||
traded := asIntS(mapGet(item, "m_nVolumeTraded", "volume_traded"))
|
traded, _ := strconv.Atoi(item["m_nVolumeTraded"])
|
||||||
orig := asIntS(mapGet(item, "m_nVolumeTotalOriginal", "volume"))
|
orig, _ := strconv.Atoi(item["m_nVolumeTotalOriginal"])
|
||||||
_, active := activeStatuses[status]
|
_, active := activeStatuses[status]
|
||||||
return parsedOrder{
|
return parsedOrder{
|
||||||
OrderID: mapGet(item, "m_strOrderSysID", "m_nOrderID", "order_id"),
|
OrderID: item["m_strOrderSysID"],
|
||||||
StockCode: stockCodeFromMap(item),
|
StockCode: item["m_strInstrumentID"],
|
||||||
Side: side,
|
Side: side,
|
||||||
Active: active,
|
Active: active,
|
||||||
OrderTime: orderTime,
|
OrderTime: orderTime,
|
||||||
RemarkOwned: strings.HasPrefix(tag, "zt:"),
|
RemarkOwned: strings.HasPrefix(tag, "zt-"),
|
||||||
VolumeOrig: orig,
|
VolumeOrig: orig,
|
||||||
VolumeLeft: left,
|
VolumeLeft: left,
|
||||||
VolumeTraded: traded,
|
VolumeTraded: traded,
|
||||||
@@ -356,7 +416,8 @@ func newOrderTag(leg string) string {
|
|||||||
}
|
}
|
||||||
var buf [6]byte
|
var buf [6]byte
|
||||||
_, _ = rand.Read(buf[:])
|
_, _ = 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 {
|
if len(tag) > 24 {
|
||||||
return tag[:24]
|
return tag[:24]
|
||||||
}
|
}
|
||||||
@@ -367,8 +428,3 @@ func parseHM(now time.Time) int {
|
|||||||
n, _ := strconv.Atoi(now.Format("1504"))
|
n, _ := strconv.Atoi(now.Format("1504"))
|
||||||
return n
|
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"
|
"math"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
|
"big-qmt/go-client/config"
|
||||||
"big-qmt/go-client/sdk"
|
"big-qmt/go-client/sdk"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -24,7 +25,7 @@ func positionCodes(positions []sdk.Position) map[string]struct{} {
|
|||||||
if p.Volume <= 0 {
|
if p.Volume <= 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
code := normalizeCode(p.StockCode, "")
|
code := p.StockCode
|
||||||
if code != "" {
|
if code != "" {
|
||||||
out[code] = struct{}{}
|
out[code] = struct{}{}
|
||||||
}
|
}
|
||||||
@@ -32,18 +33,14 @@ func positionCodes(positions []sdk.Position) map[string]struct{} {
|
|||||||
return out
|
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 {
|
if positions == nil {
|
||||||
logf("ERROR", "[ZT][持仓] 持仓查询失败,本轮跳过")
|
logf("ERROR", "[ZT][持仓] 持仓查询失败,本轮跳过")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
state := getState(cfg)
|
state := getState()
|
||||||
if !books.cancelExpired(ctx, client, cfg) {
|
if state.LoadError != "" {
|
||||||
logf("ERROR", "[ZT][持仓] 委托查询失败,本轮跳过")
|
logf("ERROR", "[ZT][持仓] 状态文件异常,本轮停止交易: %s", state.LoadError)
|
||||||
return
|
|
||||||
}
|
|
||||||
buys, sells, ok := books.activeSets(ctx, client, cfg)
|
|
||||||
if !ok {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
before := map[string]struct{}{}
|
before := map[string]struct{}{}
|
||||||
@@ -63,12 +60,12 @@ func managePositions(ctx context.Context, client *sdk.Client, books *OrderBook,
|
|||||||
rows := make([]row, 0, len(positions))
|
rows := make([]row, 0, len(positions))
|
||||||
seen := map[string]struct{}{}
|
seen := map[string]struct{}{}
|
||||||
for _, pos := range positions {
|
for _, pos := range positions {
|
||||||
code := normalizeCode(pos.StockCode, "")
|
code := pos.StockCode
|
||||||
if code == "" {
|
if code == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
seen[code] = struct{}{}
|
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 {
|
if pos.Volume <= 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -77,7 +74,7 @@ func managePositions(ctx context.Context, client *sdk.Client, books *OrderBook,
|
|||||||
}
|
}
|
||||||
for _, code := range state.Codes() {
|
for _, code := range state.Codes() {
|
||||||
if _, ok := seen[code]; !ok {
|
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{}{}
|
after := map[string]struct{}{}
|
||||||
@@ -111,8 +108,8 @@ func managePositions(ctx context.Context, client *sdk.Client, books *OrderBook,
|
|||||||
if r.item.AddCost > 0 {
|
if r.item.AddCost > 0 {
|
||||||
addPnL = (r.price - r.item.AddCost) / r.item.AddCost * 100
|
addPnL = (r.price - r.item.AddCost) / r.item.AddCost * 100
|
||||||
}
|
}
|
||||||
if retreated(cfg, r.item, "add", addPnL) {
|
if retreated(r.item, "add", addPnL) {
|
||||||
sellLeg(ctx, client, books, cfg, r.item, r.usable, r.item.AddQty, "add", addPnL)
|
sellLeg(ctx, client, books, r.item, r.usable, r.item.AddQty, "add", addPnL)
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -120,38 +117,34 @@ func managePositions(ctx context.Context, client *sdk.Client, books *OrderBook,
|
|||||||
if r.item.BaseCost > 0 {
|
if r.item.BaseCost > 0 {
|
||||||
basePnL = (r.price - r.item.BaseCost) / r.item.BaseCost * 100
|
basePnL = (r.price - r.item.BaseCost) / r.item.BaseCost * 100
|
||||||
}
|
}
|
||||||
if retreated(cfg, r.item, "base", basePnL) {
|
if retreated(r.item, "base", basePnL) {
|
||||||
sellLeg(ctx, client, books, cfg, r.item, r.usable, r.item.BaseQty, "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 <= cfg.LossTriggerPct {
|
} else if r.item.AddQty <= 0 && r.item.AddCost <= 0 && basePnL <= config.Account.LossTriggerPct {
|
||||||
addOnRebound(ctx, client, books, cfg, r.item, r.price, marketOK)
|
addOnRebound(ctx, client, books, r.item, r.price, marketOK, buyBudget)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 首次没有状态文件时,本轮已将启动前持仓全部接管为底仓。
|
||||||
|
state.completeBootstrap()
|
||||||
state.Save()
|
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)
|
item := state.Get(code)
|
||||||
if item == nil {
|
if item == nil {
|
||||||
if volume > 0 {
|
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)
|
logf("ERROR", "[ZT][持仓] %s 无本地状态,跳过", code)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
switch item.Pending {
|
switch item.Pending {
|
||||||
case "base_opening":
|
case pendingBaseOpening:
|
||||||
syncOpen(cfg, state, item, volume, avgPrice, buys, books)
|
syncOpen(state, item, volume, avgPrice, buys, books)
|
||||||
case "add":
|
case pendingAdd:
|
||||||
syncAdd(cfg, state, item, volume, avgPrice, buys, books)
|
syncAdd(state, item, volume, avgPrice, buys, books)
|
||||||
case "sell_add":
|
case pendingSellAdd:
|
||||||
syncSellAdd(cfg, state, item, volume, avgPrice, sells, books)
|
syncSellAdd(state, item, volume, avgPrice, sells, books)
|
||||||
case "sell_base":
|
case pendingSellBase:
|
||||||
syncSellBase(cfg, state, item, volume, avgPrice, sells, books)
|
syncSellBase(state, item, volume, avgPrice, sells, books)
|
||||||
default:
|
default:
|
||||||
if volume <= 0 {
|
if volume <= 0 {
|
||||||
state.Remove(code)
|
state.Remove(code)
|
||||||
@@ -162,11 +155,11 @@ func syncItem(cfg Config, state *ZTState, code string, volume int, avgPrice floa
|
|||||||
return state.Get(code)
|
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 {
|
if volume > 0 {
|
||||||
item.BaseQty, item.BaseCost = volume, avgPrice
|
item.BaseQty, item.BaseCost = volume, avgPrice
|
||||||
}
|
}
|
||||||
if books.sideBusy(cfg, item.Code, "buy", buys) {
|
if books.sideBusy(item.Code, sideBuy, buys) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if volume <= 0 {
|
if volume <= 0 {
|
||||||
@@ -174,18 +167,18 @@ func syncOpen(cfg Config, state *ZTState, item *SymbolState, volume int, avgPric
|
|||||||
logf("INFO", "[ZT][委托] %s 开仓委托已失效,允许重新开仓", item.Code)
|
logf("INFO", "[ZT][委托] %s 开仓委托已失效,允许重新开仓", item.Code)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
item.Pending = ""
|
clearPending(item)
|
||||||
logf("INFO", "[ZT][持仓] %s 开仓确认 数量=%d 成本=%.2f", item.Code, item.BaseQty, item.BaseCost)
|
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 {
|
if volume > item.BaseQty {
|
||||||
item.AddQty = volume - item.BaseQty
|
item.AddQty = volume - item.BaseQty
|
||||||
if item.AddQty > 0 {
|
if item.AddQty > 0 {
|
||||||
item.AddCost = math.Max(0, (avgPrice*float64(volume)-item.BaseCost*float64(item.BaseQty))/float64(item.AddQty))
|
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
|
return
|
||||||
}
|
}
|
||||||
if volume <= 0 {
|
if volume <= 0 {
|
||||||
@@ -198,12 +191,12 @@ func syncAdd(cfg Config, state *ZTState, item *SymbolState, volume int, avgPrice
|
|||||||
item.AddCost = 0
|
item.AddCost = 0
|
||||||
logf("INFO", "[ZT][持仓] %s 补仓未成交,回退底仓", item.Code)
|
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 volume <= 0 {
|
||||||
if !books.sideBusy(cfg, item.Code, "sell", sells) {
|
if !books.sideBusy(item.Code, sideSell, sells) {
|
||||||
state.Remove(item.Code)
|
state.Remove(item.Code)
|
||||||
logf("INFO", "[ZT][委托] %s 卖出后已无持仓,清除状态", item.Code)
|
logf("INFO", "[ZT][委托] %s 卖出后已无持仓,清除状态", item.Code)
|
||||||
}
|
}
|
||||||
@@ -218,42 +211,50 @@ func syncSellAdd(cfg Config, state *ZTState, item *SymbolState, volume int, avgP
|
|||||||
} else {
|
} else {
|
||||||
item.AddQty = volume - item.BaseQty
|
item.AddQty = volume - item.BaseQty
|
||||||
}
|
}
|
||||||
if !books.sideBusy(cfg, item.Code, "sell", sells) {
|
if !books.sideBusy(item.Code, sideSell, sells) {
|
||||||
item.Pending = ""
|
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 volume <= 0 {
|
||||||
if !books.sideBusy(cfg, item.Code, "sell", sells) {
|
if !books.sideBusy(item.Code, sideSell, sells) {
|
||||||
state.Remove(item.Code)
|
state.Remove(item.Code)
|
||||||
logf("INFO", "[ZT][委托] %s 卖出后已无持仓,清除状态", item.Code)
|
logf("INFO", "[ZT][委托] %s 卖出后已无持仓,清除状态", item.Code)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
item.BaseQty, item.BaseCost = volume, avgPrice
|
item.BaseQty, item.BaseCost = volume, avgPrice
|
||||||
if !books.sideBusy(cfg, item.Code, "sell", sells) {
|
if !books.sideBusy(item.Code, sideSell, sells) {
|
||||||
item.Pending = ""
|
clearPending(item)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func addOnRebound(ctx context.Context, client *sdk.Client, books *OrderBook, cfg Config, item *SymbolState, price float64, marketOK bool) {
|
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, cfg, "补仓", item.Code, price) {
|
if !marketOK || !dipTriggered(&posDip.mu, posDip.store, "补仓", item.Code, price) {
|
||||||
return
|
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.AddCost = price
|
||||||
item.Pending = "add"
|
setPending(item, pendingAdd, orderID)
|
||||||
getState(cfg).Save()
|
*buyBudget -= estimated
|
||||||
logf("INFO", "[ZT][补仓] %s 买入 %d 股", item.Code, item.BaseQty)
|
getState().Save()
|
||||||
|
logf("INFO", "[ZT][补仓] %s 买入 %d 股", item.Code, volume)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func retreated(cfg Config, item *SymbolState, leg string, pnl float64) bool {
|
func retreated(item *SymbolState, leg string, pnl float64) bool {
|
||||||
if pnl < cfg.MinProfitPct {
|
if pnl < config.Account.MinProfitPct {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
grid := int(math.Floor(pnl / cfg.GridStepPct))
|
grid := int(math.Floor(pnl / config.Account.GridStepPct))
|
||||||
key := peakKey(item.Code, leg)
|
key := peakKey(item.Code, leg)
|
||||||
peakMu.Lock()
|
peakMu.Lock()
|
||||||
defer peakMu.Unlock()
|
defer peakMu.Unlock()
|
||||||
@@ -266,21 +267,22 @@ func retreated(cfg Config, item *SymbolState, leg string, pnl float64) bool {
|
|||||||
return grid < peak
|
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
|
volume -= volume % 100
|
||||||
if volume <= 0 || usable < volume {
|
if volume <= 0 || usable < volume {
|
||||||
logf("INFO", "[ZT][止盈] %s 可用股数不足,需要=%d 可用=%d", item.Code, volume, usable)
|
logf("INFO", "[ZT][止盈] %s 可用股数不足,需要=%d 可用=%d", item.Code, volume, usable)
|
||||||
return
|
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
|
return
|
||||||
}
|
}
|
||||||
if leg == "add" {
|
if leg == "add" {
|
||||||
item.Pending = "sell_add"
|
setPending(item, pendingSellAdd, orderID)
|
||||||
} else {
|
} 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)
|
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"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sync"
|
"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 {
|
type SymbolState struct {
|
||||||
Code string `json:"code"`
|
Code string `json:"code"`
|
||||||
BaseQty int `json:"base_qty"`
|
BaseQty int `json:"base_qty"`
|
||||||
BaseCost float64 `json:"base_cost"`
|
BaseCost float64 `json:"base_cost"`
|
||||||
AddQty int `json:"add_qty"`
|
AddQty int `json:"add_qty"`
|
||||||
AddCost float64 `json:"add_cost"`
|
AddCost float64 `json:"add_cost"`
|
||||||
Pending string `json:"pending"`
|
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 {
|
type filePayload struct {
|
||||||
@@ -26,6 +51,7 @@ type ZTState struct {
|
|||||||
path string
|
path string
|
||||||
Items map[string]*SymbolState
|
Items map[string]*SymbolState
|
||||||
LoadError string
|
LoadError string
|
||||||
|
fresh bool
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,14 +60,34 @@ var (
|
|||||||
states = map[string]*ZTState{}
|
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()
|
statesMu.Lock()
|
||||||
defer statesMu.Unlock()
|
defer statesMu.Unlock()
|
||||||
if s, ok := states[cfg.AccountID]; ok {
|
accountID := config.Account.AccountID
|
||||||
|
if s, ok := states[accountID]; ok {
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
s := loadZTState(cfg.DataDir, cfg.AccountID)
|
s := loadZTState(config.Global.QMTDataDir, accountID)
|
||||||
states[cfg.AccountID] = s
|
states[accountID] = s
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,19 +99,23 @@ func loadZTState(dataDir, accountID string) *ZTState {
|
|||||||
raw, err := os.ReadFile(st.path)
|
raw, err := os.ReadFile(st.path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
|
st.fresh = true
|
||||||
return st
|
return st
|
||||||
}
|
}
|
||||||
st.rebuild(err)
|
st.LoadError = err.Error()
|
||||||
|
logf("ERROR", "[ZT][状态] 读取状态文件失败: %v", err)
|
||||||
return st
|
return st
|
||||||
}
|
}
|
||||||
var payload filePayload
|
var payload filePayload
|
||||||
if err := json.Unmarshal(raw, &payload); err != nil || payload.Version != 1 {
|
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
|
return st
|
||||||
}
|
}
|
||||||
data := payload.Data
|
data := payload.Data
|
||||||
if data == nil {
|
if data == nil {
|
||||||
st.rebuild(fmt.Errorf("状态文件内容无效"))
|
st.LoadError = "状态文件内容无效"
|
||||||
|
logf("ERROR", "[ZT][状态] %s", st.LoadError)
|
||||||
return st
|
return st
|
||||||
}
|
}
|
||||||
symbolsAny, _ := data["symbols"]
|
symbolsAny, _ := data["symbols"]
|
||||||
@@ -91,19 +141,16 @@ func loadZTState(dataDir, accountID string) *ZTState {
|
|||||||
return st
|
return st
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ZTState) rebuild(err error) {
|
func (s *ZTState) Fresh() bool {
|
||||||
if err := os.Remove(s.path); err != nil && !os.IsNotExist(err) {
|
s.mu.Lock()
|
||||||
s.LoadError = err.Error()
|
defer s.mu.Unlock()
|
||||||
logf("ERROR", "[ZT][状态] 状态文件重建失败: %s", s.LoadError)
|
return s.fresh
|
||||||
return
|
}
|
||||||
}
|
|
||||||
s.Items = map[string]*SymbolState{}
|
func (s *ZTState) completeBootstrap() {
|
||||||
if saveErr := s.saveUnlocked(); saveErr != nil {
|
s.mu.Lock()
|
||||||
s.LoadError = fmt.Sprintf("%v;重建失败: %v", err, saveErr)
|
defer s.mu.Unlock()
|
||||||
logf("ERROR", "[ZT][状态] 状态文件重建失败: %s", s.LoadError)
|
s.fresh = false
|
||||||
return
|
|
||||||
}
|
|
||||||
logf("WARNING", "[ZT][状态] 状态文件损坏,已删除并重建: %v", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ZTState) Get(code string) *SymbolState {
|
func (s *ZTState) Get(code string) *SymbolState {
|
||||||
@@ -146,7 +193,9 @@ func (s *ZTState) Save() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := s.saveUnlocked(); err != nil {
|
if err := s.saveUnlocked(); err != nil {
|
||||||
|
s.LoadError = err.Error()
|
||||||
logf("ERROR", "[ZT][状态] 保存失败: %v", err)
|
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"
|
"time"
|
||||||
|
|
||||||
"big-qmt/go-client/apps/zt/logic"
|
"big-qmt/go-client/apps/zt/logic"
|
||||||
|
"big-qmt/go-client/config"
|
||||||
|
"big-qmt/go-client/libs"
|
||||||
"big-qmt/go-client/sdk"
|
"big-qmt/go-client/sdk"
|
||||||
|
|
||||||
|
"github.com/robfig/cron/v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
log.SetFlags(log.LstdFlags | log.Lmicroseconds)
|
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)
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||||
defer stop()
|
defer stop()
|
||||||
|
|
||||||
startup := context.Background()
|
// 第一步:只从 YAML 文件加载系统配置和本机账户配置。
|
||||||
assets, err := client.Assets(startup)
|
err := config.Load("etc")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("[ERROR] 启动获取资产失败: %v", err)
|
log.Fatalf("[ERROR] 加载配置失败: %v", err)
|
||||||
}
|
}
|
||||||
positions, err := client.Positions(startup)
|
client := sdk.New(config.Global.QMTBaseURL, config.Global.QMTToken, config.HttpTimeOut)
|
||||||
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")
|
|
||||||
|
|
||||||
ticker := time.NewTicker(cfg.LoopInterval)
|
// 第二步:QMT 未就绪时持续重试,退出信号仍可立即终止等待。
|
||||||
defer ticker.Stop()
|
assets, positions, ok := waitForQMT(ctx, client)
|
||||||
logic.RunOnce(ctx, client, books, cfg)
|
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 {
|
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 {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
log.Printf("[INFO] [ZT] 停止")
|
return nil, nil, false
|
||||||
return
|
case <-time.After(5 * time.Second):
|
||||||
case <-ticker.C:
|
|
||||||
logic.RunOnce(ctx, client, books, cfg)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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
|
module big-qmt/go-client
|
||||||
|
|
||||||
go 1.22
|
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
|
package libs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
func getJSON(rawURL string, params url.Values, timeout time.Duration) (map[string]any, error) {
|
// GetJSON 请求 JSON 接口并返回对象。
|
||||||
if timeout <= 0 {
|
func GetJSON(rawURL string, timeout time.Duration) ([]byte, error) {
|
||||||
timeout = 5 * time.Second
|
|
||||||
}
|
|
||||||
if params != nil {
|
|
||||||
if strings.Contains(rawURL, "?") {
|
|
||||||
rawURL += "&" + params.Encode()
|
|
||||||
} else {
|
|
||||||
rawURL += "?" + params.Encode()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
req, err := http.NewRequest(http.MethodGet, rawURL, nil)
|
req, err := http.NewRequest(http.MethodGet, rawURL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -31,9 +29,5 @@ func getJSON(rawURL string, params url.Values, timeout time.Duration) (map[strin
|
|||||||
if resp.StatusCode >= 400 {
|
if resp.StatusCode >= 400 {
|
||||||
return nil, fmt.Errorf("http %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
return nil, fmt.Errorf("http %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||||
}
|
}
|
||||||
out := map[string]any{}
|
return body, nil
|
||||||
if err := json.Unmarshal(body, &out); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,24 +3,33 @@ package libs
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
MarketUrl = "/a/market"
|
||||||
|
Period = "60m"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AllowOpen 每次开仓或补仓前取 60 分钟大盘信号,只有 UP 才放行。
|
// AllowOpen 每次开仓或补仓前取 60 分钟大盘信号,只有 UP 才放行。
|
||||||
func AllowOpen(apiHost string, timeout time.Duration) bool {
|
func AllowOpen() bool {
|
||||||
rawURL := strings.TrimRight(apiHost, "/") + "/a/market"
|
// gen url.
|
||||||
payload, err := getJSON(rawURL, url.Values{"period": {"60m"}}, timeout)
|
fullUrl := fmt.Sprintf("%s%s?period=%s&t=%s", API_HOST, MarketUrl, Period, randStr(16))
|
||||||
|
payload, err := GetJSON(fullUrl, HTTPTimeout)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("[ERROR] 获取60m大盘信号失败: %s %v", rawURL, err)
|
log.Printf("[ERROR] 获取大盘指数失败: %s %v", fullUrl, err)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
status := Status(payload)
|
var result map[string]any
|
||||||
log.Printf("[INFO] 大盘信号: url=%s status=%s", rawURL, status)
|
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"
|
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 {
|
type PassorderRequest struct {
|
||||||
OpType int `json:"opType"`
|
OpType int `json:"opType"`
|
||||||
OrderType int `json:"orderType,omitempty"`
|
OrderType int `json:"orderType,omitempty"`
|
||||||
Stock string `json:"stock"`
|
Stock string `json:"stock"`
|
||||||
PrType int `json:"prType,omitempty"`
|
PrType int `json:"prType,omitempty"`
|
||||||
Price float64 `json:"price"`
|
Price float64 `json:"price"`
|
||||||
Volume int `json:"volume"`
|
Volume int `json:"volume"`
|
||||||
QuickTrade int `json:"quickTrade,omitempty"`
|
QuickTrade int `json:"quickTrade,omitempty"`
|
||||||
|
StrategyName string `json:"strategyName,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) Passorder(ctx context.Context, req PassorderRequest) (*OrderRefResult, error) {
|
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
|
return &out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// PassorderLatest 按最新价下单。服务端策略名写死为 qmt,无法传投资备注。
|
// PassorderLatest 按最新价下单,不附加策略订单号。
|
||||||
func (c *Client) PassorderLatest(ctx context.Context, buy bool, stock string, volume int) (*OrderRefResult, error) {
|
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
|
op := OpSell
|
||||||
if buy {
|
if buy {
|
||||||
op = OpBuy
|
op = OpBuy
|
||||||
}
|
}
|
||||||
return c.Passorder(ctx, PassorderRequest{
|
return c.Passorder(ctx, PassorderRequest{
|
||||||
OpType: op,
|
OpType: op,
|
||||||
OrderType: OrderTypeVolume,
|
OrderType: OrderTypeVolume,
|
||||||
Stock: stock,
|
Stock: stock,
|
||||||
PrType: PrTypeLatest,
|
PrType: PrTypeLatest,
|
||||||
Price: -1,
|
Price: -1,
|
||||||
Volume: volume,
|
Volume: volume,
|
||||||
QuickTrade: QuickTradeNow,
|
QuickTrade: QuickTradeNow,
|
||||||
|
StrategyName: orderID,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user