431 lines
10 KiB
Go
431 lines
10 KiB
Go
package logic
|
|
|
|
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{}{
|
|
48: {}, 49: {}, 50: {}, 51: {}, 52: {}, 55: {},
|
|
}
|
|
|
|
type parsedOrder struct {
|
|
OrderID string
|
|
StockCode string
|
|
Side string
|
|
Active bool
|
|
OrderTime int64
|
|
RemarkOwned bool
|
|
VolumeOrig int
|
|
VolumeLeft int
|
|
VolumeTraded int
|
|
Tag string
|
|
}
|
|
|
|
func (o parsedOrder) cancelVolume() int {
|
|
n := o.VolumeLeft + o.VolumeTraded
|
|
if n > 0 {
|
|
return n
|
|
}
|
|
return o.VolumeOrig
|
|
}
|
|
|
|
type submission struct {
|
|
Code string
|
|
Side string
|
|
}
|
|
|
|
type OrderBook struct {
|
|
mu sync.Mutex
|
|
cached []parsedOrder
|
|
hasCache bool
|
|
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()
|
|
o.hasCache = false
|
|
o.cached = nil
|
|
}
|
|
|
|
func (o *OrderBook) query(ctx context.Context, client *sdk.Client) ([]parsedOrder, error) {
|
|
o.mu.Lock()
|
|
if o.hasCache {
|
|
out := append([]parsedOrder(nil), o.cached...)
|
|
o.mu.Unlock()
|
|
return out, nil
|
|
}
|
|
o.mu.Unlock()
|
|
raw, err := client.TradeDetailData(ctx, "order")
|
|
if err != nil {
|
|
logf("ERROR", "[ZT][委托] 查询失败: %v", err)
|
|
return nil, err
|
|
}
|
|
orders := make([]parsedOrder, 0, len(raw))
|
|
for _, item := range raw {
|
|
orders = append(orders, parseOrder(item))
|
|
}
|
|
o.mu.Lock()
|
|
o.cached = orders
|
|
o.hasCache = true
|
|
o.mu.Unlock()
|
|
return orders, nil
|
|
}
|
|
|
|
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
|
|
}
|
|
buys, sells = map[string]struct{}{}, map[string]struct{}{}
|
|
for _, item := range orders {
|
|
if !item.Active || item.StockCode == "" {
|
|
continue
|
|
}
|
|
if item.Side == sideBuy {
|
|
buys[item.StockCode] = struct{}{}
|
|
} else {
|
|
sells[item.StockCode] = struct{}{}
|
|
}
|
|
}
|
|
return buys, sells, true
|
|
}
|
|
|
|
func (o *OrderBook) cancelExpired(ctx context.Context, client *sdk.Client) bool {
|
|
o.invalidate()
|
|
orders, err := o.query(ctx, client)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
state := getState()
|
|
now := time.Now()
|
|
timeout := time.Duration(config.Account.OrderTimeoutSec) * time.Second
|
|
seen := map[string]struct{}{}
|
|
cancelled := false
|
|
for _, order := range orders {
|
|
if !order.Active || order.StockCode == "" {
|
|
continue
|
|
}
|
|
if !o.claimed(state, order) {
|
|
continue
|
|
}
|
|
if order.OrderTime <= 0 || now.Sub(time.Unix(order.OrderTime, 0)) <= timeout {
|
|
continue
|
|
}
|
|
vol := order.cancelVolume()
|
|
if vol <= 0 {
|
|
logf("WARNING", "[ZT][委托] 超时单缺少数量,跳过 %s %s", order.OrderID, order.StockCode)
|
|
continue
|
|
}
|
|
key := order.StockCode + "|" + strconv.Itoa(vol)
|
|
if _, ok := seen[key]; ok {
|
|
continue
|
|
}
|
|
seen[key] = struct{}{}
|
|
if order.OrderID != "" {
|
|
can, err := client.CanCancelOrder(ctx, order.OrderID)
|
|
if err != nil {
|
|
logf("ERROR", "[ZT][委托] 查询是否可撤失败 %s: %v", order.OrderID, err)
|
|
continue
|
|
}
|
|
if !truthy(can) {
|
|
logf("INFO", "[ZT][委托] 不可撤 %s %s", order.OrderID, order.StockCode)
|
|
continue
|
|
}
|
|
}
|
|
ret, err := client.CancelByRule(ctx, order.StockCode, vol)
|
|
if err != nil {
|
|
logf("ERROR", "[ZT][委托] 撤单失败 %s %s: %v", order.OrderID, order.StockCode, err)
|
|
continue
|
|
}
|
|
if ret == nil || ret.Status != "success" {
|
|
msg := ""
|
|
if ret != nil {
|
|
msg = ret.Message
|
|
}
|
|
logf("WARNING", "[ZT][委托] 规则撤单未命中 %s %s volume=%d %s", order.OrderID, order.StockCode, vol, msg)
|
|
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
|
|
}
|
|
|
|
func (o *OrderBook) claimed(state *ZTState, order parsedOrder) bool {
|
|
if order.RemarkOwned {
|
|
return true
|
|
}
|
|
o.mu.Lock()
|
|
for _, s := range o.subs {
|
|
if s.Code == order.StockCode && s.Side == order.Side {
|
|
o.mu.Unlock()
|
|
return true
|
|
}
|
|
}
|
|
o.mu.Unlock()
|
|
if state == nil {
|
|
return false
|
|
}
|
|
item := state.Get(order.StockCode)
|
|
if item == nil || item.Pending == "" {
|
|
return false
|
|
}
|
|
switch item.Pending {
|
|
case pendingBaseOpening, pendingAdd:
|
|
return order.Side == sideBuy
|
|
case pendingSellAdd, pendingSellBase:
|
|
return order.Side == sideSell
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func (o *OrderBook) unlockSide(code, side string) {
|
|
o.mu.Lock()
|
|
defer o.mu.Unlock()
|
|
delete(o.locks(side), code)
|
|
n := 0
|
|
for _, s := range o.subs {
|
|
if s.Code == code && s.Side == side {
|
|
continue
|
|
}
|
|
o.subs[n] = s
|
|
n++
|
|
}
|
|
o.subs = o.subs[:n]
|
|
}
|
|
|
|
func (o *OrderBook) sideBusy(code, side string, active map[string]struct{}) bool {
|
|
if _, ok := active[code]; ok {
|
|
return true
|
|
}
|
|
return o.locked(code, side)
|
|
}
|
|
|
|
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) < time.Duration(config.Account.OrderTimeoutSec)*time.Second
|
|
}
|
|
|
|
func (o *OrderBook) locks(side string) map[string]time.Time {
|
|
if side == sideBuy {
|
|
return o.buyLocks
|
|
}
|
|
return o.sellLocks
|
|
}
|
|
|
|
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
|
|
}
|
|
for _, item := range orders {
|
|
if item.StockCode == code && item.Active && item.Side == side {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
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(code, side) {
|
|
logf("INFO", "[ZT][委托] %s %s锁定中", code, side)
|
|
return false
|
|
}
|
|
if o.hasActive(ctx, client, code, side) {
|
|
logf("INFO", "[ZT][委托] %s 已有%s在途委托", code, side)
|
|
return false
|
|
}
|
|
_, 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})
|
|
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, _ := 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 := item["m_strInsertDate"]
|
|
clock := strings.ReplaceAll(item["m_strInsertTime"], ":", "")
|
|
if date != "" {
|
|
if len(clock) < 6 {
|
|
clock = strings.Repeat("0", 6-len(clock)) + clock
|
|
}
|
|
if t, err := time.ParseInLocation("20060102150405", date+clock, time.Local); err == nil {
|
|
orderTime = t.Unix()
|
|
}
|
|
}
|
|
}
|
|
side := sideSell
|
|
if operation == opBuyStock || operation == opBuyAlt {
|
|
side = sideBuy
|
|
}
|
|
left, _ := strconv.Atoi(item["m_nVolumeTotal"])
|
|
traded, _ := strconv.Atoi(item["m_nVolumeTraded"])
|
|
orig, _ := strconv.Atoi(item["m_nVolumeTotalOriginal"])
|
|
_, active := activeStatuses[status]
|
|
return parsedOrder{
|
|
OrderID: item["m_strOrderSysID"],
|
|
StockCode: item["m_strInstrumentID"],
|
|
Side: side,
|
|
Active: active,
|
|
OrderTime: orderTime,
|
|
RemarkOwned: strings.HasPrefix(tag, "zt-"),
|
|
VolumeOrig: orig,
|
|
VolumeLeft: left,
|
|
VolumeTraded: traded,
|
|
Tag: tag,
|
|
}
|
|
}
|
|
|
|
func truthy(v any) bool {
|
|
if v == nil {
|
|
return false
|
|
}
|
|
switch x := v.(type) {
|
|
case bool:
|
|
return x
|
|
case string:
|
|
s := strings.ToLower(strings.TrimSpace(x))
|
|
return s == "true" || s == "1" || s == "yes"
|
|
case float64:
|
|
return x != 0
|
|
case int:
|
|
return x != 0
|
|
default:
|
|
s := strings.ToLower(strings.TrimSpace(fmt.Sprint(v)))
|
|
return s == "true" || s == "1"
|
|
}
|
|
}
|
|
|
|
func newOrderTag(leg string) string {
|
|
legCode := map[string]string{"base": "b", "add": "a", "take_profit": "t", "all": "s"}[leg]
|
|
if legCode == "" {
|
|
legCode = "x"
|
|
}
|
|
var buf [6]byte
|
|
_, _ = rand.Read(buf[:])
|
|
// 订单号同时用于 Windows 回写文件名,因此只使用文件名安全字符。
|
|
tag := fmt.Sprintf("zt-%s-%s", legCode, hex.EncodeToString(buf[:]))
|
|
if len(tag) > 24 {
|
|
return tag[:24]
|
|
}
|
|
return tag
|
|
}
|
|
|
|
func parseHM(now time.Time) int {
|
|
n, _ := strconv.Atoi(now.Format("1504"))
|
|
return n
|
|
}
|