diff --git a/__pycache__/main.cpython-311.pyc b/__pycache__/main.cpython-311.pyc new file mode 100644 index 0000000..7f4124f Binary files /dev/null and b/__pycache__/main.cpython-311.pyc differ diff --git a/go-client/apps/cmd/main.go b/go-client/apps/cmd/main.go deleted file mode 100644 index 6d1cecf..0000000 --- a/go-client/apps/cmd/main.go +++ /dev/null @@ -1,125 +0,0 @@ -package main - -import ( - "context" - "encoding/json" - "fmt" - "os" - "path/filepath" - "sort" - "strings" - "time" - - "big-qmt/go-client/sdk" -) - -var ( - BaseURL = "http://127.0.0.1:10086" - Token = "QMTbyYanweidong" - AccountType = "stock" - Timeout = 15 * time.Second -) - -func main() { - client := sdk.New(BaseURL, Token, Timeout).SetAccountType(AccountType) - ctx, cancel := context.WithTimeout(context.Background(), Timeout) - defer cancel() - - assets, err := client.Assets(ctx) - if err != nil { - fatal("获取资产失败: %v", err) - } - _, positions, err := client.Positions(ctx) - if err != nil { - fatal("获取持仓失败: %v", err) - } - - fmt.Println(strings.Repeat("=", 80)) - fmt.Printf("【时间】%s\n", time.Now().Format("2006-01-02 15:04:05")) - fmt.Printf("【服务】%s accountType=%s\n", BaseURL, AccountType) - fmt.Printf("【资金】总资产:%.2f元,可用资金:%.2f元\n", assets.Total, assets.Available) - fmt.Printf("【持仓】%d只\n", len(positions)) - fmt.Println(strings.Repeat("=", 80)) - - sort.Slice(positions, func(i, j int) bool { - return positions[i].StockCode < positions[j].StockCode - }) - for _, p := range positions { - if p.Volume <= 0 { - continue - } - fmt.Printf( - "【持仓】%s %s 持仓=%d 可用=%d 冻结=%d 在途=%d 昨仓=%d 成本=%.3f 现价=%.3f 市值=%.2f 浮盈=%.2f 盈亏比例=%.2f%%\n", - p.StockCode, p.StockName, p.Volume, p.CanUseVolume, p.FrozenVolume, p.OnRoadVolume, p.YesterdayVolume, - p.OpenPrice, p.LastPrice, p.MarketValue, p.FloatProfit, p.ProfitRate*100, - ) - } - - codes := loadPassCodes() - printTicks(client, codes) - // if _, err := client.Shutdown(ctx); err != nil { - // fatal("关闭服务失败: %v", err) - // } - // fmt.Println("【服务】已关闭") -} - -func loadPassCodes() []string { - dir := strings.TrimSpace(os.Getenv("QMT_DATA_DIR")) - if dir == "" { - fatal("环境变量 QMT_DATA_DIR 为空") - } - path := filepath.Join(dir, "pass_codes.json") - raw, err := os.ReadFile(path) - if err != nil { - fatal("读取 %s 失败: %v", path, err) - } - var codes []string - if err := json.Unmarshal(raw, &codes); err != nil { - fatal("解析 %s 失败: %v", path, err) - } - return codes -} - -func printTicks(client *sdk.Client, codes []string) { - fmt.Println(strings.Repeat("-", 80)) - if len(codes) == 0 { - fmt.Println("【行情】pass_codes.json 为空,跳过") - return - } - ctx, cancel := context.WithTimeout(context.Background(), Timeout) - defer cancel() - ticks, err := client.FullTick(ctx, codes) - if err != nil { - fatal("获取行情失败: %v", err) - } - fmt.Printf("【行情】请求 %d 只,返回 %d 只\n", len(codes), len(ticks)) - keys := make([]string, 0, len(ticks)) - for code := range ticks { - keys = append(keys, code) - } - sort.Strings(keys) - for _, code := range keys { - t := ticks[code] - fmt.Printf("【Tick】%s last=%.3f close=%.3f open=%s high=%s low=%s volume=%s\n", - code, t.LastPrice, t.LastClose, - rawStr(t.Raw, "open", "lastOpen", "Open"), - rawStr(t.Raw, "high", "High"), - rawStr(t.Raw, "low", "Low"), - rawStr(t.Raw, "volume", "Volume"), - ) - } -} - -func rawStr(m map[string]any, names ...string) string { - for _, name := range names { - if v, ok := m[name]; ok && v != nil { - return fmt.Sprint(v) - } - } - return "-" -} - -func fatal(format string, args ...any) { - fmt.Fprintf(os.Stderr, format+"\n", args...) - os.Exit(1) -} diff --git a/go-client/apps/trend/logic/boot.go b/go-client/apps/trend/logic/boot.go deleted file mode 100644 index 9c12bb7..0000000 --- a/go-client/apps/trend/logic/boot.go +++ /dev/null @@ -1,111 +0,0 @@ -package logic - -import ( - "context" - "fmt" - "log" - "slices" - "strings" - "time" - - "big-qmt/go-client/config" - "big-qmt/go-client/libs" - "big-qmt/go-client/sdk" -) - -func logf(level, format string, args ...any) { - log.Printf("[%s] %s", level, fmt.Sprintf(format, args...)) -} - -func Overview(assets *sdk.Assets, positions []sdk.Position) { - fmt.Println("\n" + strings.Repeat("=", 80)) - fmt.Printf("【时间】%s\n", time.Now().Format("2006-01-02 15:04:05")) - fmt.Printf("【配置】account_id: %s host_key: %s buy_value: %.0f\n", config.Account.AccountID, config.Account.HostKey, config.Account.BuyValue) - if assets != nil { - fmt.Printf("【资金】总资产:%.2f元,可用资金:%.2f元\n", assets.Total, assets.Available) - } else { - fmt.Println("【资金】查询失败") - } - fmt.Printf("【持仓】%d只\n", len(positions)) - fmt.Println(strings.Repeat("=", 80)) - for _, p := range positions { - if p.Volume <= 0 { - continue - } - code := p.StockCode - fmt.Printf("【持仓】%s %s 持仓=%d 可用=%d 冻结=%d 在途=%d 昨仓=%d 成本=%.3f 现价=%.3f 市值=%.2f 浮盈=%.2f 盈亏比例=%.2f%%\n", - code, p.StockName, p.Volume, p.CanUseVolume, p.FrozenVolume, p.OnRoadVolume, p.YesterdayVolume, - p.OpenPrice, p.LastPrice, p.MarketValue, p.FloatProfit, p.ProfitRate*100) - } -} - -func RunOnce(ctx context.Context, client *sdk.Client, signals []*libs.SignalItem) { - if !libs.TradingTime(time.Now()) { - return - } - - // 1 取消过期订单 - OrderBook.CancelExpired(client) - - // 2 验证可用资金 - assets, err := client.Assets(ctx) - if err != nil { - logf("ERROR", "获取资产失败: %v", err) - return - } - if assets.Available < assets.Total*config.Account.MinCashRatio { - logf("INFO", "资金总闸:可用金额太少,禁止开新仓") - return - } - - // 3 获取大盘状态 - IsAllow := libs.MarketAllowOpen() - - // 4 获取持仓 - var allCodes []string - pos_codes, positions, err := client.Positions(ctx) - if err != nil { - logf("ERROR", "获取持仓失败: %v", err) - return - } - allCodes = append(allCodes, pos_codes...) - - // 5 验证有效开仓信号 - allowOpen := make([]*libs.SignalItem, 0) - for _, item := range signals { - if !slices.Contains(pos_codes, item.Code) { - allowOpen = append(allowOpen, item) - } - } - allowOpen = SignalFilter(allowOpen, config.Account.SignalAllow) - - // 6 获取行情tick - ticks, err := client.FullTick(ctx, allCodes) - if err != nil { - logf("ERROR", "获取行情失败: %v", err) - return - } - - // 7 执行开仓:有开仓信号 && 大盘指数允许开仓 - if len(allowOpen) > 0 && IsAllow { - openSignal(client, ticks, allowOpen) - } - - // 8 持仓计算 - managePositions(client, ticks, positions, IsAllow) -} - -func SignalFilter(in []*libs.SignalItem, name []string) []*libs.SignalItem { - newSignalItem := make([]*libs.SignalItem, 0) - if len(name) == 0 { - return newSignalItem - } - for _, i := range in { - for _, n := range name { - if i.SignalKey == n { - newSignalItem = append(newSignalItem, i) - } - } - } - return newSignalItem -} diff --git a/go-client/apps/trend/logic/open.go b/go-client/apps/trend/logic/open.go deleted file mode 100644 index 12c7b19..0000000 --- a/go-client/apps/trend/logic/open.go +++ /dev/null @@ -1,90 +0,0 @@ -package logic - -import ( - "strconv" - "strings" - "time" - - "big-qmt/go-client/config" - "big-qmt/go-client/libs" - "big-qmt/go-client/sdk" -) - -func openSignal(client *sdk.Client, ticks map[string]sdk.Tick, openSignals []*libs.SignalItem) { - for _, item := range openSignals { - // 验证信号配置的时间区间 - if !CheckTimezone(item.SignalKey) { - continue - } - // 是否有锁 - if OrderBook.IsLock("BUY", item.Code) { - continue - } - // 验证价格 - price := ticks[item.Code].LastPrice - if price <= 0 { - continue - } - // 防止接飞刀 - if !OpenWatch.Triggered("开仓", item.Code, price) { - continue - } - // 计算开仓数量 - volume := libs.CalcBuyVolume(price, config.Account.BuyValue) - if volume <= 0 { - continue - } - // 开仓 - orderID := NewOrderID("base") - if !OrderBook.Place(client, sdk.OpBuy, item.Code, volume, orderID) { - continue - } - // 保存状态 - QuantState.Set(&StateItem{Code: item.Code, BaseOrderId: orderID, BaseQty: volume, BaseCost: price, BaseStatus: StatusIng}) - if err := QuantState.Save(); err != nil { - logf("ERROR", "%v", err) - } - logf("INFO", "[ZT][开仓] %s 买入 %d 股", item.Code, volume) - } -} - -// 当前时间区间验证 *代表全时间段,9:30-10:30,13:30-14:30 代表2个时间段 -func CheckTimezone(sk string) bool { - timezone := strings.TrimSpace(config.Global.Signals[sk].Timezone) - if timezone == "*" { - return true - } - - parse := func(value string) (int, bool) { - parts := strings.Split(strings.TrimSpace(value), ":") - if len(parts) != 2 { - return 0, false - } - hour, errHour := strconv.Atoi(parts[0]) - minute, errMinute := strconv.Atoi(parts[1]) - if errHour != nil || errMinute != nil || hour < 0 || hour > 23 || minute < 0 || minute > 59 { - return 0, false - } - return hour*60 + minute, true - } - - current := time.Now() - now := current.Hour()*60 + current.Minute() - for _, section := range strings.Split(timezone, ",") { - bounds := strings.Split(strings.TrimSpace(section), "-") - if len(bounds) != 2 { - continue - } - start, startOK := parse(bounds[0]) - end, endOK := parse(bounds[1]) - if !startOK || !endOK { - continue - } - if (start <= end && now >= start && now <= end) || - (start > end && (now >= start || now <= end)) { - return true - } - } - - return false -} diff --git a/go-client/apps/trend/logic/order.go b/go-client/apps/trend/logic/order.go deleted file mode 100644 index c0c88c0..0000000 --- a/go-client/apps/trend/logic/order.go +++ /dev/null @@ -1,154 +0,0 @@ -package logic - -import ( - "context" - "crypto/rand" - "encoding/hex" - "fmt" - "slices" - "strconv" - "strings" - "sync" - "time" - - "big-qmt/go-client/sdk" -) - -var ( - STOCK_DIRECTION = 48 - STOCK_SIDE_BUY = 48 - STOCK_SIDE_SELL = 49 - OffsetFlag = map[string]string{"23": "BUY", "24": "SELL", "48": "BUY", "49": "SELL"} - OrderTimeout = 5 * time.Minute - - OrderBook *Books -) - -type OrderItem struct { - ID string - Code string - Side string - Remark string - Status string - CreatedAt time.Time - Volume int -} - -type Books struct { - mu sync.Mutex - Data map[string]*OrderItem - Index []string -} - -func NewOrderBook() { - OrderBook = &Books{Data: make(map[string]*OrderItem), Index: make([]string, 0)} -} - -func NewOrderID(leg string) string { - var random [6]byte - _, _ = rand.Read(random[:]) - tag := fmt.Sprintf("zt-%s-%s", leg, hex.EncodeToString(random[:])) - if len(tag) > 24 { - return tag[:24] - } - return tag -} - -func (o *Books) IsLock(side, code string) bool { - o.mu.Lock() - defer o.mu.Unlock() - - keyStr := fmt.Sprintf("%s-%s", side, code) - return slices.Contains(o.Index, keyStr) -} - -func (o *Books) Refresh(client *sdk.Client) error { - o.mu.Lock() - defer o.mu.Unlock() - raw, err := client.TradeDetailData(context.Background(), "order") - if err != nil { - return err - } - var idx []string - orders := make(map[string]*OrderItem) - for _, row := range raw { - keyStr, item := parseOrder(row) - orders[keyStr] = item - idx = append(idx, keyStr) - } - o.Data = orders - o.Index = idx - return nil -} - -func (o *Books) CancelExpired(client *sdk.Client) error { - ctx := context.Background() - err := o.Refresh(client) - if err != nil { - return fmt.Errorf("[委托] 查询失败: %v", err) - } - for _, order := range o.Data { - if order.CreatedAt.IsZero() || time.Since(order.CreatedAt) <= OrderTimeout { - continue - } - if order.ID != "" { - rs, err := client.CanCancelOrder(ctx, order.ID) - if err != nil { - logf("ERROR", "[委托] 撤销失败:%v", err) - continue - } else { - logf("INFO", "[委托] 撤销成功:%v", rs) - } - } - } - return nil -} - -func (o *Books) Place(client *sdk.Client, op int, code string, volume int, sn string) bool { - if _, err := client.PassorderLatestTagged(context.Background(), op, code, volume, sn); err != nil { - logf("ERROR", "[委托] %s 下单失败: %v", code, err) - return false - } - - o.mu.Lock() - defer o.mu.Unlock() - - keyStr := fmt.Sprintf("%s-%s", OffsetFlag[strconv.Itoa(op)], code) - o.Index = append(o.Index, keyStr) - - logf("INFO", "[委托] 下单已提交 %d %s %d股", op, code, volume) - return true -} - -func parseOrder(row map[string]string) (string, *OrderItem) { - left, _ := strconv.Atoi(row["m_nVolumeTotal"]) - traded, _ := strconv.Atoi(row["m_nVolumeTraded"]) - volume := left + traded - - item := &OrderItem{ - ID: row["m_strOrderSysID"], - Code: row["m_strInstrumentID"], - Side: OffsetFlag[row["m_nOffsetFlag"]], - Remark: row["m_strRemark"], - Status: row["m_nOrderStatus"], - Volume: volume, - CreatedAt: time.Unix(parseTimestamp(row), 0), - } - keyStr := fmt.Sprintf("%s-%s", item.Side, item.Code) - return keyStr, item -} - -func parseTimestamp(row map[string]string) int64 { - ts, _ := strconv.ParseInt(row["m_nOrderTime"], 10, 64) - if ts > 1e11 { - return ts / 1000 - } - if ts > 0 { - return ts - } - date := row["m_strInsertDate"] - clock := strings.ReplaceAll(row["m_strInsertTime"], ":", "") - clock = strings.Repeat("0", max(0, 6-len(clock))) + clock - t, _ := time.ParseInLocation("20060102150405", date+clock, time.Local) - return t.Unix() -} diff --git a/go-client/apps/trend/logic/positions.go b/go-client/apps/trend/logic/positions.go deleted file mode 100644 index 0b4e463..0000000 --- a/go-client/apps/trend/logic/positions.go +++ /dev/null @@ -1,132 +0,0 @@ -package logic - -import ( - "math" - "sync" - - "big-qmt/go-client/config" - "big-qmt/go-client/libs" - "big-qmt/go-client/sdk" -) - -const ( - legBase = "base" - legAdded = "add" -) - -var ( - peakMu sync.Mutex - peakGrids = make(map[string]int) -) - -func managePositions(client *sdk.Client, ticks map[string]sdk.Tick, positions []sdk.Position, marketOK bool) { - -} - -func syncAdded(item *StateItem, position sdk.Position) { - addedQty := position.Volume - item.BaseQty - if addedQty <= 0 { - item.BaseQty = position.Volume - item.BaseCost = position.OpenPrice - item.AddedQty = 0 - item.AddedCost = 0 - item.AddedStatus = StatusNone - peakMu.Lock() - delete(peakGrids, item.Code+"|"+legAdded) - peakMu.Unlock() - return - } - - item.AddedQty = addedQty - totalCost := position.OpenPrice * float64(position.Volume) - baseCost := item.BaseCost * float64(item.BaseQty) - item.AddedCost = math.Max(0, (totalCost-baseCost)/float64(addedQty)) - item.AddedStatus = StatusOk -} - -func buyAdded(client *sdk.Client, item *StateItem, price float64, marketOK bool, budget *float64) { - if !marketOK || !PosbuyWatch.Triggered("补仓", item.Code, price) { - return - } - volume := libs.CalcBuyVolume(price, config.Account.BuyValue) - amount := price * float64(volume) - if amount > *budget || orderBusy(item.Code, "BUY") { - return - } - - orderID := NewOrderID(legAdded) - if !OrderBook.Place(client, sdk.OpBuy, item.Code, volume, orderID) { - return - } - item.AddedOrderId = orderID - item.AddedNum++ - item.AddedQty = volume - item.AddedCost = price - item.AddedStatus = StatusIng - QuantState.Set(item) - *budget -= amount -} - -func sell(client *sdk.Client, item *StateItem, usable, volume int, leg string, pnl float64) { - volume -= volume % 100 - if volume <= 0 || usable < volume || orderBusy(item.Code, "SELL") { - return - } - orderID := NewOrderID(leg) - if !OrderBook.Place(client, sdk.OpSell, item.Code, volume, orderID) { - return - } - if leg == legAdded { - item.AddedOrderId = orderID - item.AddedStatus = StatusIng - } else { - item.BaseOrderId = orderID - item.BaseStatus = StatusIng - } - QuantState.Set(item) - logf("INFO", "[止盈] %s 卖出%d股,盈利=%.2f%%", item.Code, volume, pnl) -} - -func orderBusy(code, side string) bool { - OrderBook.mu.Lock() - defer OrderBook.mu.Unlock() - order := OrderBook.Data[side+"-"+code] - if order == nil { - return false - } - switch order.Status { - case "48", "49", "50", "51", "52", "55": - return true - default: - return false - } -} - -func shouldSell(code, leg string, pnl float64) bool { - if pnl < config.Account.MinProfitPct { - return false - } - grid := int(math.Floor(pnl / config.Account.GridStepPct)) - key := code + "|" + leg - peakMu.Lock() - defer peakMu.Unlock() - peak, tracked := peakGrids[key] - if !tracked || grid > peak { - peakGrids[key] = grid - return false - } - return grid < peak -} - -func forget(code string) { - OpenWatch.mu.Lock() - delete(OpenWatch.Data, code) - OpenWatch.mu.Unlock() - PosbuyWatch.mu.Lock() - delete(PosbuyWatch.Data, code) - PosbuyWatch.mu.Unlock() - peakMu.Lock() - delete(peakGrids, code+"|"+legBase) - delete(peakGrids, code+"|"+legAdded) - peakMu.Unlock() -} diff --git a/go-client/apps/trend/logic/state.go b/go-client/apps/trend/logic/state.go deleted file mode 100644 index f1bc9d3..0000000 --- a/go-client/apps/trend/logic/state.go +++ /dev/null @@ -1,152 +0,0 @@ -package logic - -import ( - "encoding/json" - "fmt" - "os" - "path" - "slices" - "sync" - - "big-qmt/go-client/config" - "big-qmt/go-client/sdk" -) - -var ( - StatusNone = "" - StatusIng = "ING" // 处理中 - StatusOk = "OK" // 成功 - QuantState *State -) - -type State struct { - AbsPath string - mu sync.Mutex - Items map[string]*StateItem - Codes []string -} - -type StateItem struct { - Code string `json:"code"` - BaseOrderId string `json:"base_order_id"` - BaseQty int `json:"base_qty"` - BaseCost float64 `json:"base_cost"` - BaseStatus string `json:"base_status,omitempty"` - AddedOrderId string `json:"added_order_id"` - AddedNum int `json:"add_num"` - AddedQty int `json:"add_qty"` - AddedCost float64 `json:"add_cost"` - AddedStatus string `json:"added_status,omitempty"` -} - -func InitState(sn string) error { - absPath := path.Join(config.Global.QMTDataDir, fmt.Sprintf("%s_%s_state.json", sn, config.Account.AccountID)) - items, err := loadStateFile(absPath) - if err != nil { - return err - } - - var codes []string - for code, _ := range items { - codes = append(codes, code) - } - - QuantState = &State{ - AbsPath: absPath, - Items: items, - Codes: codes, - } - return nil -} - -func loadStateFile(fp string) (map[string]*StateItem, error) { - raw, err := os.ReadFile(fp) - if err != nil { - return nil, fmt.Errorf("[状态] 读取失败: %v", err) - } - var items map[string]*StateItem - if err := json.Unmarshal(raw, &items); err != nil { - return nil, fmt.Errorf("[状态] 解析失败:%s", err) - } - return items, nil - -} - -func SyncPositions(positions []sdk.Position) error { - for _, pos := range positions { - code := pos.StockCode - if code == "" || pos.Volume <= 0 || pos.OpenPrice <= 0 { - continue - } - if !slices.Contains(QuantState.Codes, code) { - item := &StateItem{ - Code: code, - BaseQty: pos.Volume, - BaseCost: pos.OpenPrice, - BaseStatus: StatusOk, - } - QuantState.Append(item) - logf("WARNING", "[状态] %s 首次接管为底仓 数量=%d 成本=%.2f", code, pos.Volume, pos.OpenPrice) - } - } - - return QuantState.Save() -} - -func (s *State) Append(i *StateItem) { - s.mu.Lock() - defer s.mu.Unlock() - - s.Items[i.Code] = i - s.Codes = append(s.Codes, i.Code) -} - -func (s *State) Get(code string) (*StateItem, error) { - s.mu.Lock() - defer s.mu.Unlock() - - if i, ok := s.Items[code]; ok { - return i, nil - } else { - return nil, fmt.Errorf("%s not found.", code) - } -} - -func (s *State) Set(i *StateItem) { - s.mu.Lock() - defer s.mu.Unlock() - - if _, ok := s.Items[i.Code]; !ok { - s.Codes = append(s.Codes, i.Code) - } - s.Items[i.Code] = i -} - -func (s *State) Delete(code string) { - s.mu.Lock() - defer s.mu.Unlock() - - delete(s.Items, code) - if index := slices.Index(s.Codes, code); index >= 0 { - s.Codes = slices.Delete(s.Codes, index, index+1) - } -} - -func (s *State) Save() error { - s.mu.Lock() - defer s.mu.Unlock() - - // 写入AbsPath文件 - f, err := os.OpenFile(s.AbsPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644) - if err != nil { - return fmt.Errorf("[状态] 打开文件失败: %v", err) - } - defer f.Close() - - encoder := json.NewEncoder(f) - encoder.SetIndent("", " ") - if err := encoder.Encode(s.Items); err != nil { - return fmt.Errorf("[状态] 写入失败: %v", err) - } - return nil -} diff --git a/go-client/apps/trend/logic/watch.go b/go-client/apps/trend/logic/watch.go deleted file mode 100644 index 132d899..0000000 --- a/go-client/apps/trend/logic/watch.go +++ /dev/null @@ -1,66 +0,0 @@ -package logic - -import ( - "sync" - "time" -) - -var ( - WatchExpireTime = 5 * time.Minute - WatchReThreshold = 0.61 - - OpenWatch *WatchMu - PosbuyWatch *WatchMu -) - -type dipWatch struct { - LastClose float64 - ExpiresAt time.Time -} - -type WatchMu struct { - mu sync.Mutex - Data map[string]dipWatch -} - -func InitWatch() { - OpenWatch = &WatchMu{ - Data: make(map[string]dipWatch), - } - PosbuyWatch = &WatchMu{ - Data: make(map[string]dipWatch), - } -} - -func (w *WatchMu) Triggered(tag, code string, price float64) bool { - if price <= 0 { - return false - } - w.mu.Lock() - defer w.mu.Unlock() - now := time.Now() - watch, ok := w.Data[code] - if !ok || now.After(watch.ExpiresAt) || now.Equal(watch.ExpiresAt) { - w.Data[code] = dipWatch{LastClose: price, ExpiresAt: now.Add(WatchExpireTime)} - logf("INFO", "[%s-观察] %s 现价=%.2f", tag, code, price) - return false - } - if price < watch.LastClose { - watch.LastClose = price - watch.ExpiresAt = now.Add(WatchExpireTime) - w.Data[code] = watch - logf("INFO", "[%s-下跌] %s 刷新低点=%.2f", tag, code, price) - return false - } - rebound := (price - watch.LastClose) / watch.LastClose * 100 - if rebound <= 0 { - return false - } - if rebound < WatchReThreshold { - logf("INFO", "[%s-等待] %s 反弹=%.2f%% 阈值=%.2f%%", tag, code, rebound, WatchReThreshold) - return false - } - delete(w.Data, code) - logf("INFO", "[%s-触发] %s 反弹=%.2f%% 低点=%.2f", tag, code, rebound, watch.LastClose) - return true -} diff --git a/go-client/apps/trend/main.go b/go-client/apps/trend/main.go deleted file mode 100644 index 5fdcfd3..0000000 --- a/go-client/apps/trend/main.go +++ /dev/null @@ -1,94 +0,0 @@ -package main - -import ( - "context" - "log" - "os" - "os/signal" - "syscall" - "time" - - "big-qmt/go-client/apps/trend/logic" - "big-qmt/go-client/config" - "big-qmt/go-client/libs" - "big-qmt/go-client/sdk" - - "github.com/robfig/cron/v3" -) - -var ( - StrategyName = "trend" -) - -func main() { - log.SetFlags(log.LstdFlags | log.Lmicroseconds) - ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) - defer stop() - - // 第一步:只从 YAML 文件加载系统配置和本机账户配置。 - err := config.Load("etc") - if err != nil { - log.Fatalf("[ERROR] 加载配置失败: %v", err) - } - client := sdk.New(config.Global.QMTBaseURL, config.Global.QMTToken, config.HttpTimeOut) - - // 第二步:QMT 未就绪时持续重试,退出信号仍可立即终止等待。 - assets, positions, ok := waitForQMT(ctx, client) - if !ok { - return - } - - // 第三步 初始化 - logic.NewOrderBook() - logic.InitWatch() - if err := logic.InitState(StrategyName); err != nil { - log.Panicln("ERROR", err.Error()) - } - if err := logic.SyncPositions(positions); err != nil { - log.Panicln("ERROR", err.Error()) - } - signals, err := libs.InitSignals() - if err != nil { - log.Panicln("ERROR", err.Error()) - } - - // 打印启动信息 - logic.Overview(assets, positions) - log.Printf("[INFO] 已加载 %d 个开仓信号", len(signals)) - - // 第四步:工作日每 30 秒触发,交易时段由 RunOnce 统一判断。 - 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, signals) - }); err != nil { - log.Fatalf("[ERROR] 创建计划任务失败: %v", err) - } - scheduler.Start() - log.Printf("[INFO] 计划任务已启动") - - <-ctx.Done() - <-scheduler.Stop().Done() - log.Printf("[INFO] 停止") -} - -func waitForQMT(ctx context.Context, client *sdk.Client) (*sdk.Assets, []sdk.Position, bool) { - for { - attempt, cancel := context.WithTimeout(ctx, config.HttpTimeOut) - assets, assetsErr := client.Assets(attempt) - _, positions, positionsErr := client.Positions(attempt) - cancel() - if assetsErr == nil && positionsErr == nil { - log.Printf("[INFO] QMT连接成功: %s", config.Global.QMTBaseURL) - return assets, positions, true - } - log.Printf("[WARNING] QMT未就绪,5秒后重试: assets=%v positions=%v", assetsErr, positionsErr) - select { - case <-ctx.Done(): - return nil, nil, false - case <-time.After(5 * time.Second): - } - } -} diff --git a/go-client/config/config.go b/go-client/config/config.go deleted file mode 100644 index 7e784c1..0000000 --- a/go-client/config/config.go +++ /dev/null @@ -1,104 +0,0 @@ -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"` - Signals map[string]SignalConfig `yaml:"signals"` -} - -type SignalConfig struct { - Url string `yaml:"url"` - Timezone string `yaml:"timezone"` - GtLastPriceIsOpen bool `yaml:"gt_last_price_is_open"` -} - -type AccountConfig struct { - AccountID string `yaml:"account_id"` - HostKey string `yaml:"host_key"` - 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"` - SignalAllow []string `yaml:"signal_allow"` -} - -// 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") - } - - account.HostKey = strings.ToLower(account.HostKey) - 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 "" -} diff --git a/go-client/etc/dev.yaml b/go-client/etc/dev.yaml deleted file mode 100644 index ed1763f..0000000 --- a/go-client/etc/dev.yaml +++ /dev/null @@ -1,11 +0,0 @@ -# 当前计算机使用的账户和策略参数。 -account_id: CHANGE_ME -host_key: "dev" -buy_value: 5000 -min_cash_ratio: 0.10 -loss_trigger_pct: -30 -grid_step_pct: 1 -min_profit_pct: 2 -rebound_threshold: 0.61 -signal_allow: - - dcm diff --git a/go-client/etc/global.yaml b/go-client/etc/global.yaml deleted file mode 100644 index fec6d29..0000000 --- a/go-client/etc/global.yaml +++ /dev/null @@ -1,27 +0,0 @@ -# 系统公共参数。hosts 将 Windows 计算机名映射到账户配置文件。 -qmt_base_url: http://127.0.0.1:10086 -qmt_token: QMTbyYanweidong -api_host: http://139.224.247.176:13499 -qmt_data_dir: D:/qmt_strategy_data - -hosts: - DESKTOP-39H91QV: dev.yaml - -signals: - dcm: - url: /a/dcm_signal - timezone: * # *代表全时间段,9:30-10:30,13:30-14:30 代表2个时间段 - gt_last_price_is_open: false # 大于信号的昨收价是否开仓 - morning: - url: /a/morning_signal - timezone: 9:30-10:30 # *代表全时间段,9:30-10:30,13:30-14:30 代表2个时间段 - gt_last_price_is_open: true # 大于信号的昨收价是否开仓 - tail: - url: /a/tail_signal - timezone: 14:30-14:55 # *代表全时间段,9:30-10:30,13:30-14:30 代表2个时间段 - gt_last_price_is_open: false # 大于信号的昨收价是否开仓 - arbitrage: - url: /a/arbitrage_signal - timezone: * # *代表全时间段,9:30-10:30,13:30-14:30 代表2个时间段 - gt_last_price_is_open: false # 大于信号的昨收价是否开仓 - diff --git a/go-client/go.mod b/go-client/go.mod deleted file mode 100644 index 3e33354..0000000 --- a/go-client/go.mod +++ /dev/null @@ -1,15 +0,0 @@ -module big-qmt/go-client - -go 1.26.5 - -require ( - git.apinb.com/bsm-sdk/core v0.2.1 - github.com/robfig/cron/v3 v3.0.1 - gopkg.in/yaml.v3 v3.0.1 -) - -require ( - github.com/google/uuid v1.6.0 // indirect - github.com/oklog/ulid/v2 v2.1.2 // indirect - github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect -) diff --git a/go-client/go.sum b/go-client/go.sum deleted file mode 100644 index fea000d..0000000 --- a/go-client/go.sum +++ /dev/null @@ -1,15 +0,0 @@ -git.apinb.com/bsm-sdk/core v0.2.1 h1:1kpbdij3qOlf1DmKTq3coIXSgLth5iJHJ3LvVZnjaXM= -git.apinb.com/bsm-sdk/core v0.2.1/go.mod h1:BL/aGHujCWdxrKZrWaiebmLx69J0OrTVv5XfugbbyhE= -github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= -github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/oklog/ulid/v2 v2.1.2 h1:IEclFb9JNvzYA6MW2SCxbLzcHTVsfqm3PrqGQJH5zec= -github.com/oklog/ulid/v2 v2.1.2/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= -github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= -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= -github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= -github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= -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= diff --git a/go-client/libs/calc.go b/go-client/libs/calc.go deleted file mode 100644 index 2764370..0000000 --- a/go-client/libs/calc.go +++ /dev/null @@ -1,38 +0,0 @@ -package libs - -import ( - "math" - "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) -} - -func CalcBuyVolume(price, buyValue float64) int { - if price <= 0 || buyValue <= 0 { - return 0 - } - // 不足一手时仍按最低一手委托。 - hands := int(math.Floor(buyValue / (price * 100))) - if hands == 0 { - hands = 1 - } - return hands * 100 -} diff --git a/go-client/libs/const.go b/go-client/libs/const.go deleted file mode 100644 index 5ebf0f2..0000000 --- a/go-client/libs/const.go +++ /dev/null @@ -1,8 +0,0 @@ -package libs - -import "time" - -var ( - API_HOST = "http://139.224.247.176:13499" - HTTPTimeout = 5 * time.Second -) diff --git a/go-client/libs/http.go b/go-client/libs/http.go deleted file mode 100644 index cb62abd..0000000 --- a/go-client/libs/http.go +++ /dev/null @@ -1,33 +0,0 @@ -package libs - -import ( - "fmt" - "io" - "net/http" - "strings" - "time" -) - -// GetJSON 请求 JSON 接口并返回对象。 -func GetJSON(rawURL string, timeout time.Duration) ([]byte, error) { - req, err := http.NewRequest(http.MethodGet, rawURL, nil) - if err != nil { - return nil, err - } - req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", "big-qmt-go/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))) - } - return body, nil -} diff --git a/go-client/libs/market.go b/go-client/libs/market.go deleted file mode 100644 index bdfb528..0000000 --- a/go-client/libs/market.go +++ /dev/null @@ -1,67 +0,0 @@ -package libs - -import ( - "encoding/json" - "fmt" - "log" - "strings" -) - -var ( - MarketUrl = "/a/market" - Period = "60m" -) - -// AllowOpen 每次开仓或补仓前取 60 分钟大盘信号,只有 UP 才放行。 -func MarketAllowOpen() bool { - // gen url. - fullUrl := fmt.Sprintf("%s%s?period=%s&t=%s", API_HOST, MarketUrl, Period, randStr(16)) - payload, err := GetJSON(fullUrl, HTTPTimeout) - if err != nil { - log.Printf("[ERROR] 获取大盘指数失败: %s %v", fullUrl, err) - return false - } - var result map[string]any - err = json.Unmarshal(payload, &result) - if err != nil { - log.Printf("[ERROR] 获取大盘指数解析: %v", err) - return false - } - - status := Status(result) - log.Printf("[INFO] 大盘信号: url=%s status=%s", fullUrl, status) - return status == "UP" -} - -// Status 兼容常见响应结构;无法识别的值统一按 UNKNOWN 处理。 -func Status(payload map[string]any) string { - var value any = payload - if m, ok := value.(map[string]any); ok { - if d, exists := m["data"]; exists { - value = d - } - } - if arr, ok := value.([]any); ok { - if len(arr) == 0 { - value = nil - } else { - value = arr[len(arr)-1] - } - } - if m, ok := value.(map[string]any); ok { - if v, exists := m["action"]; exists { - value = v - } else if v, exists := m["status"]; exists { - value = v - } else if v, exists := m["signal"]; exists { - value = v - } - } - s := strings.ToUpper(strings.TrimSpace(fmt.Sprint(value))) - switch s { - case "UP", "DOWN", "NEUTRAL": - return s - default: - return "UNKNOWN" - } -} diff --git a/go-client/libs/signal.go b/go-client/libs/signal.go deleted file mode 100644 index 84e0547..0000000 --- a/go-client/libs/signal.go +++ /dev/null @@ -1,57 +0,0 @@ -package libs - -import ( - "big-qmt/go-client/config" - "encoding/json" - "fmt" -) - -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 { - SignalKey string `json:"signal_key"` - Code string `json:"code"` - Name string `json:"name"` - Desc string `json:"desc"` - LastClose float64 `json:"last_close"` - TechIndicator map[string]float64 `json:"tech_indicator"` -} - -func InitSignals() ([]*SignalItem, error) { - CacheSignals := make([]*SignalItem, 0) - for key, s := range config.Global.Signals { - result, err := FetchSignal(s.Url) - if err != nil { - return nil, fmt.Errorf("[信号] %s, 获取错误:%v", key, err) - } - for _, item := range result.Data { - item.SignalKey = key - CacheSignals = append(CacheSignals, &item) - } - } - return CacheSignals, nil -} - -// FetchSignals 启动时读取信号,运行期间直接使用内存数据。 -func FetchSignal(subUrl string) (*SignalResult, error) { - // gen url. - fullUrl := fmt.Sprintf("%s%s?t=%s", API_HOST, subUrl, randStr(16)) - // doing - payload, err := GetJSON(fullUrl, HTTPTimeout) - if err != nil { - return nil, fmt.Errorf("开仓信号获取失败: %s %v", fullUrl, err) - } - - var result SignalResult - err = json.Unmarshal(payload, &result) - if err != nil { - return nil, fmt.Errorf("开仓信号解析失败: %s %v", fullUrl, err) - } - return &result, nil -} diff --git a/go-client/sdk/account.go b/go-client/sdk/account.go deleted file mode 100644 index 97b8ece..0000000 --- a/go-client/sdk/account.go +++ /dev/null @@ -1,179 +0,0 @@ -package sdk - -import ( - "context" - "encoding/json" - "fmt" -) - -// Position 对应 HoldingHandler 封装后的持仓。 -type Position struct { - StockCode string `json:"StockCode"` - StockName string `json:"StockName"` - Direction any `json:"Direction"` - Volume int `json:"Volume"` - OpenPrice float64 `json:"OpenPrice"` - FloatProfit float64 `json:"FloatProfit"` - MarketValue float64 `json:"MarketValue"` - StockHolder string `json:"StockHolder"` - FrozenVolume int `json:"FrozenVolume"` - CanUseVolume int `json:"CanUseVolume"` - OnRoadVolume int `json:"OnRoadVolume"` - YesterdayVolume int `json:"YesterdayVolume"` - LastPrice float64 `json:"LastPrice"` - ProfitRate float64 `json:"ProfitRate"` - FutureTradeType any `json:"FutureTradeType"` - ExpireDate string `json:"ExpireDate"` -} - -type Assets struct { - Total float64 `json:"total"` - Available float64 `json:"available"` -} - -func (c *Client) Positions(ctx context.Context) ([]string, []Position, error) { - return c.decodePositions(ctx, "/api/v2/positions") -} - -func (c *Client) Holding(ctx context.Context) ([]string, []Position, error) { - return c.decodePositions(ctx, "/api/holding") -} - -func (c *Client) decodePositions(ctx context.Context, path string) ([]string, []Position, error) { - raw := map[string]json.RawMessage{} - if err := c.post(ctx, path, map[string]any{"account": c.accountType}, &raw); err != nil { - return nil, nil, err - } - codes := make([]string, 0, len(raw)) - out := make([]Position, 0, len(raw)) - for code, blob := range raw { - var p Position - if err := json.Unmarshal(blob, &p); err != nil { - return nil, nil, fmt.Errorf("position %s: %w", code, err) - } - if p.StockCode == "" { - p.StockCode = code - } - codes = append(codes, code) - out = append(out, p) - } - return codes, out, nil -} - -func (c *Client) Assets(ctx context.Context) (*Assets, error) { - var out Assets - if err := c.post(ctx, "/api/v2/assets", map[string]any{"account": c.accountType}, &out); err != nil { - return nil, err - } - return &out, nil -} - -func (c *Client) TotalMoney(ctx context.Context) (float64, error) { - var out struct { - TotalMoney float64 `json:"total_money"` - } - if err := c.post(ctx, "/api/money/total", map[string]any{"account": c.accountType}, &out); err != nil { - return 0, err - } - return out.TotalMoney, nil -} - -func (c *Client) AvailableMoney(ctx context.Context) (float64, error) { - var out struct { - AvailableMoney float64 `json:"available_money"` - } - if err := c.post(ctx, "/api/money/available", map[string]any{"account": c.accountType}, &out); err != nil { - return 0, err - } - return out.AvailableMoney, nil -} - -type OrderRefResult struct { - Status string `json:"status"` - Action string `json:"action"` - Stock string `json:"stock"` - OpType int `json:"opType"` - OrderRef string `json:"order_ref"` -} - -func (c *Client) Buy(ctx context.Context, stock string, price float64, volume int, prType int) (*OrderRefResult, error) { - body := map[string]any{"stock": stock, "price": price, "volume": volume} - if prType != 0 { - body["prType"] = prType - } - var out OrderRefResult - if err := c.post(ctx, "/api/order/buy", body, &out); err != nil { - return nil, err - } - return &out, nil -} - -func (c *Client) Sell(ctx context.Context, stock string, price float64, volume int, prType int) (*OrderRefResult, error) { - body := map[string]any{"stock": stock, "price": price, "volume": volume} - if prType != 0 { - body["prType"] = prType - } - var out OrderRefResult - if err := c.post(ctx, "/api/order/sell", body, &out); err != nil { - return nil, err - } - return &out, nil -} - -type OrderStatus struct { - OrderSysID string `json:"order_sys_id"` - Status int `json:"status"` - VolumeLeft int `json:"volume_left"` - VolumeTraded int `json:"volume_traded"` -} - -func (c *Client) OrderStatusList(ctx context.Context) ([]OrderStatus, error) { - var out struct { - Orders []OrderStatus `json:"orders"` - } - if err := c.post(ctx, "/api/order/status", map[string]any{"account": c.accountType}, &out); err != nil { - return nil, err - } - return out.Orders, nil -} - -type CanceledOrder struct { - OrderSysID string `json:"order_sys_id"` - Stock string `json:"stock"` - VolumeLeft int `json:"volume_left"` -} - -type CancelAllResult struct { - Status string `json:"status"` - Message string `json:"message"` - CanceledOrders []CanceledOrder `json:"canceled_orders"` - CanceledSysIDs []string `json:"canceled_sys_ids"` -} - -func (c *Client) CancelAll(ctx context.Context) (*CancelAllResult, error) { - var out CancelAllResult - if err := c.post(ctx, "/api/order/cancel_all", map[string]any{"account": c.accountType}, &out); err != nil { - return nil, err - } - return &out, nil -} - -// CancelByRule 按「代码.市场 + (剩余+已成)」匹配撤单。HTTP 没有按委托号撤单。 -func (c *Client) CancelByRule(ctx context.Context, stock string, volume int) (*CancelAllResult, error) { - var out CancelAllResult - body := map[string]any{"stock": stock, "volume": volume, "account": c.accountType} - if err := c.post(ctx, "/api/order/cancel_order", body, &out); err != nil { - return nil, err - } - return &out, nil -} - -func (c *Client) Deals(ctx context.Context) ([]map[string]string, error) { - var out struct { - Deals []map[string]string `json:"deals"` - } - if err := c.post(ctx, "/api/order/deal", map[string]any{"account": c.accountType}, &out); err != nil { - return nil, err - } - return out.Deals, nil -} diff --git a/go-client/sdk/check.go b/go-client/sdk/check.go deleted file mode 100644 index e0109cf..0000000 --- a/go-client/sdk/check.go +++ /dev/null @@ -1,30 +0,0 @@ -package sdk - -import "context" - -func (c *Client) IsLastBar(ctx context.Context) (any, error) { - return c.getField(ctx, "/api/check/is_last_bar", "is_last_bar") -} - -func (c *Client) IsNewBar(ctx context.Context) (any, error) { - return c.getField(ctx, "/api/check/is_new_bar", "is_new_bar") -} - -func (c *Client) IsSuspendedStock(ctx context.Context, stockcode string) (any, error) { - return c.postField(ctx, "/api/check/is_suspended_stock", map[string]any{"stockcode": stockcode}, "is_suspended") -} - -func (c *Client) IsSectorStock(ctx context.Context, sectorname, market, stockcode string) (any, error) { - body := map[string]any{"sectorname": sectorname, "market": market, "stockcode": stockcode} - return c.postField(ctx, "/api/check/is_sector_stock", body, "is_in_sector") -} - -func (c *Client) IsTypedStock(ctx context.Context, stocktypenum int, market, stockcode string) (any, error) { - body := map[string]any{"stocktypenum": stocktypenum, "market": market, "stockcode": stockcode} - return c.postField(ctx, "/api/check/is_typed_stock", body, "result") -} - -func (c *Client) IndustryNameOfStock(ctx context.Context, industryType, stockcode string) (any, error) { - body := map[string]any{"industryType": industryType, "stockcode": stockcode} - return c.postField(ctx, "/api/check/get_industry_name_of_stock", body, "industry_name") -} diff --git a/go-client/sdk/client.go b/go-client/sdk/client.go deleted file mode 100644 index b8009c8..0000000 --- a/go-client/sdk/client.go +++ /dev/null @@ -1,141 +0,0 @@ -package sdk - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "strings" - "time" -) - -type Client struct { - baseURL string - token string - accountType string - http *http.Client -} - -func New(baseURL, token string, timeout time.Duration) *Client { - if timeout <= 0 { - timeout = 15 * time.Second - } - return &Client{ - baseURL: strings.TrimRight(baseURL, "/"), - token: token, - accountType: "stock", - http: &http.Client{Timeout: timeout}, - } -} - -func (c *Client) SetAccountType(accountType string) *Client { - if strings.TrimSpace(accountType) != "" { - c.accountType = accountType - } - return c -} - -func (c *Client) get(ctx context.Context, path string, dest any) error { - return c.do(ctx, http.MethodGet, path, nil, dest) -} - -func (c *Client) post(ctx context.Context, path string, body any, dest any) error { - if body == nil { - body = map[string]any{} - } - return c.do(ctx, http.MethodPost, path, body, dest) -} - -func (c *Client) do(ctx context.Context, method, path string, body any, dest any) error { - var rdr io.Reader - if body != nil && method != http.MethodGet { - raw, err := json.Marshal(body) - if err != nil { - return fmt.Errorf("marshal request: %w", err) - } - rdr = bytes.NewReader(raw) - } - req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, rdr) - if err != nil { - return err - } - req.Header.Set("X-Token", c.token) - req.Header.Set("Accept", "application/json") - if rdr != nil { - req.Header.Set("Content-Type", "application/json") - } - resp, err := c.http.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - raw, err := io.ReadAll(resp.Body) - if err != nil { - return err - } - if resp.StatusCode >= 400 { - apiErr := &APIError{StatusCode: resp.StatusCode, Message: strings.TrimSpace(string(raw))} - var parsed APIError - if json.Unmarshal(raw, &parsed) == nil { - if parsed.StatusCode == 0 { - parsed.StatusCode = resp.StatusCode - } - if parsed.Message != "" { - apiErr = &parsed - } - } - return apiErr - } - if dest == nil || len(raw) == 0 { - return nil - } - if err := json.Unmarshal(raw, dest); err != nil { - return fmt.Errorf("unmarshal %s: %w; body=%s", path, err, truncate(raw, 512)) - } - return nil -} - -func (c *Client) getField(ctx context.Context, path, key string) (any, error) { - var out map[string]any - if err := c.get(ctx, path, &out); err != nil { - return nil, err - } - return out[key], nil -} - -func (c *Client) postField(ctx context.Context, path string, body any, key string) (any, error) { - var out map[string]any - if err := c.post(ctx, path, body, &out); err != nil { - return nil, err - } - if msg, ok := out["error"].(string); ok && msg != "" { - return nil, &BusinessError{Message: msg} - } - if key == "" { - return out, nil - } - if v, ok := out[key]; ok { - return v, nil - } - return out, nil -} - -func csvJoin(items []string) string { - parts := make([]string, 0, len(items)) - for _, s := range items { - s = strings.TrimSpace(s) - if s != "" { - parts = append(parts, s) - } - } - return strings.Join(parts, ",") -} - -func truncate(b []byte, n int) string { - if len(b) <= n { - return string(b) - } - return string(b[:n]) + "..." -} diff --git a/go-client/sdk/coerce.go b/go-client/sdk/coerce.go deleted file mode 100644 index 0dc2aef..0000000 --- a/go-client/sdk/coerce.go +++ /dev/null @@ -1,63 +0,0 @@ -package sdk - -import ( - "encoding/json" - "strconv" - "strings" -) - -func asString(v any) string { - if v == nil { - return "" - } - switch x := v.(type) { - case string: - return x - case json.Number: - return x.String() - default: - return strings.TrimSpace(fmtSprint(v)) - } -} - -func fmtSprint(v any) string { - b, err := json.Marshal(v) - if err != nil { - return "" - } - return strings.Trim(string(b), `"`) -} - -func asFloat(v any) float64 { - if v == nil { - return 0 - } - switch x := v.(type) { - case float64: - return x - case float32: - return float64(x) - case int: - return float64(x) - case int64: - return float64(x) - case json.Number: - f, _ := x.Float64() - return f - case string: - f, _ := strconv.ParseFloat(strings.TrimSpace(x), 64) - return f - default: - f, _ := strconv.ParseFloat(asString(v), 64) - return f - } -} - -func mapField(m map[string]any, names ...string) any { - for _, name := range names { - if v, ok := m[name]; ok && v != nil { - return v - } - } - return nil -} diff --git a/go-client/sdk/context.go b/go-client/sdk/context.go deleted file mode 100644 index bcaf736..0000000 --- a/go-client/sdk/context.go +++ /dev/null @@ -1,78 +0,0 @@ -package sdk - -import "context" - -type ContextInfo struct { - Period any `json:"period"` - Barpos any `json:"barpos"` - TimeTickSize any `json:"time_tick_size"` - Stockcode any `json:"stockcode"` - DividendType any `json:"dividend_type"` - Market any `json:"market"` - DoBackTest any `json:"do_back_test"` - Benchmark any `json:"benchmark"` - Capital any `json:"capital"` - Universe any `json:"universe"` -} - -func (c *Client) ContextPeriod(ctx context.Context) (any, error) { - return c.getField(ctx, "/api/context/period", "period") -} - -func (c *Client) ContextBarpos(ctx context.Context) (any, error) { - return c.getField(ctx, "/api/context/barpos", "barpos") -} - -func (c *Client) ContextTimeTickSize(ctx context.Context) (any, error) { - return c.getField(ctx, "/api/context/time_tick_size", "time_tick_size") -} - -func (c *Client) ContextStockcode(ctx context.Context) (any, error) { - return c.getField(ctx, "/api/context/stockcode", "stockcode") -} - -func (c *Client) ContextDividendType(ctx context.Context) (any, error) { - return c.getField(ctx, "/api/context/dividend_type", "dividend_type") -} - -func (c *Client) ContextMarket(ctx context.Context) (any, error) { - return c.getField(ctx, "/api/context/market", "market") -} - -func (c *Client) ContextDoBackTest(ctx context.Context) (any, error) { - return c.getField(ctx, "/api/context/do_back_test", "do_back_test") -} - -func (c *Client) ContextBenchmark(ctx context.Context) (any, error) { - return c.getField(ctx, "/api/context/benchmark", "benchmark") -} - -func (c *Client) ContextCapital(ctx context.Context) (any, error) { - return c.getField(ctx, "/api/context/capital", "capital") -} - -func (c *Client) ContextUniverse(ctx context.Context) ([]string, error) { - v, err := c.getField(ctx, "/api/context/universe", "universe") - if err != nil { - return nil, err - } - switch u := v.(type) { - case nil: - return nil, nil - case []any: - codes := make([]string, 0, len(u)) - for _, item := range u { - if s := asString(item); s != "" { - codes = append(codes, s) - } - } - return codes, nil - case []string: - return u, nil - default: - if s := asString(u); s != "" { - return []string{s}, nil - } - return nil, nil - } -} diff --git a/go-client/sdk/data.go b/go-client/sdk/data.go deleted file mode 100644 index ac6e9ed..0000000 --- a/go-client/sdk/data.go +++ /dev/null @@ -1,374 +0,0 @@ -package sdk - -import ( - "context" - "strconv" - "strings" -) - -type Tick struct { - LastPrice float64 - LastClose float64 - Raw map[string]any -} - -type HistoryDataRequest struct { - Len int - Period string - Field string - DividendType int - SkipPaused *bool -} - -type MarketDataRequest struct { - Fields []string - Stocks []string - StartTime string - EndTime string - Period string - DividendType string - Count int -} - -type SubscribeResult struct { - Status string `json:"status"` - SubID any `json:"sub_id"` -} - -func (c *Client) StockName(ctx context.Context, stockcode string) (any, error) { - return c.postField(ctx, "/api/data/stock_name", map[string]any{"stockcode": stockcode}, "name") -} - -func (c *Client) OpenDate(ctx context.Context, stockcode string) (any, error) { - return c.postField(ctx, "/api/data/open_date", map[string]any{"stockcode": stockcode}, "open_date") -} - -func (c *Client) LastVolume(ctx context.Context, stockcode string) (any, error) { - return c.postField(ctx, "/api/data/last_volume", map[string]any{"stockcode": stockcode}, "last_volume") -} - -func (c *Client) BarTimetag(ctx context.Context, index int) (any, error) { - return c.postField(ctx, "/api/data/bar_timetag", map[string]any{"index": index}, "timetag") -} - -func (c *Client) TickTimetag(ctx context.Context) (any, error) { - return c.getField(ctx, "/api/data/tick_timetag", "timetag") -} - -func (c *Client) Sector(ctx context.Context, sector string, realtime int) ([]any, error) { - var out struct { - Stocks []any `json:"stocks"` - } - if err := c.post(ctx, "/api/data/sector", map[string]any{"sector": sector, "realtime": realtime}, &out); err != nil { - return nil, err - } - return out.Stocks, nil -} - -func (c *Client) Industry(ctx context.Context, industry string) ([]any, error) { - var out struct { - Stocks []any `json:"stocks"` - } - if err := c.post(ctx, "/api/data/industry", map[string]any{"industry": industry}, &out); err != nil { - return nil, err - } - return out.Stocks, nil -} - -func (c *Client) StockListInSector(ctx context.Context, sectorname string) ([]any, error) { - var out struct { - Stocks []any `json:"stocks"` - } - if err := c.post(ctx, "/api/data/stock_list_in_sector", map[string]any{"sectorname": sectorname}, &out); err != nil { - return nil, err - } - return out.Stocks, nil -} - -func (c *Client) WeightInIndex(ctx context.Context, indexcode, stockcode string) (any, error) { - return c.postField(ctx, "/api/data/weight_in_index", map[string]any{"indexcode": indexcode, "stockcode": stockcode}, "weight") -} - -func (c *Client) ContractMultiplier(ctx context.Context, contractcode string) (any, error) { - return c.postField(ctx, "/api/data/contract_multiplier", map[string]any{"contractcode": contractcode}, "multiplier") -} - -func (c *Client) RiskFreeRate(ctx context.Context, index int) (any, error) { - return c.postField(ctx, "/api/data/risk_free_rate", map[string]any{"index": index}, "risk_free_rate") -} - -func (c *Client) DateLocation(ctx context.Context, strdate string) (any, error) { - return c.postField(ctx, "/api/data/date_location", map[string]any{"strdate": strdate}, "location") -} - -func (c *Client) HistoryData(ctx context.Context, req HistoryDataRequest) (any, error) { - if req.Len == 0 { - req.Len = 10 - } - skip := "true" - if req.SkipPaused != nil { - skip = strconv.FormatBool(*req.SkipPaused) - } - return c.postField(ctx, "/api/data/history_data", map[string]any{ - "len": req.Len, "period": req.Period, "field": req.Field, - "dividend_type": req.DividendType, "skip_paused": skip, - }, "data") -} - -func (c *Client) marketDataBody(req MarketDataRequest) map[string]any { - return map[string]any{ - "fields": csvJoin(req.Fields), - "stock_code": csvJoin(req.Stocks), - "start_time": req.StartTime, - "end_time": req.EndTime, - "period": req.Period, - "dividend_type": req.DividendType, - "count": req.Count, - } -} - -func (c *Client) MarketData(ctx context.Context, req MarketDataRequest) (any, error) { - return c.postField(ctx, "/api/data/market_data", c.marketDataBody(req), "data") -} - -func (c *Client) MarketDataEx(ctx context.Context, req MarketDataRequest) (any, error) { - return c.postField(ctx, "/api/data/market_data_ex", c.marketDataBody(req), "data") -} - -func (c *Client) FullTick(ctx context.Context, stocks []string) (map[string]Tick, error) { - raw := map[string]any{} - if err := c.post(ctx, "/api/data/full_tick", map[string]any{"stocks": stocks}, &raw); err != nil { - return nil, err - } - out := make(map[string]Tick, len(raw)) - for code, v := range raw { - tick := Tick{Raw: map[string]any{}} - if m, ok := v.(map[string]any); ok { - tick.Raw = m - tick.LastPrice = asFloat(mapField(m, "lastPrice", "last_price", "LastPrice")) - tick.LastClose = asFloat(mapField(m, "lastClose", "last_close", "LastClose")) - } - out[code] = tick - } - return out, nil -} - -func (c *Client) DividFactors(ctx context.Context, stockcode string) (any, error) { - return c.postField(ctx, "/api/data/divid_factors", map[string]any{"stockcode": stockcode}, "factors") -} - -func (c *Client) MainContract(ctx context.Context, codemarket string) (any, error) { - return c.postField(ctx, "/api/data/main_contract", map[string]any{"codemarket": codemarket}, "main_contract") -} - -func (c *Client) TimetagToDatetime(ctx context.Context, timetag int64, format string) (any, error) { - body := map[string]any{"timetag": timetag} - if format != "" { - body["format"] = format - } - return c.postField(ctx, "/api/data/timetag_to_datetime", body, "datetime") -} - -func (c *Client) TotalShare(ctx context.Context, stockcode string) (any, error) { - return c.postField(ctx, "/api/data/total_share", map[string]any{"stockcode": stockcode}, "total_share") -} - -func (c *Client) TradingDates(ctx context.Context, stockcode, startDate, endDate, period string, count int) ([]any, error) { - body := map[string]any{"stockcode": stockcode, "start_date": startDate, "end_date": endDate, "period": period} - if count != 0 { - body["count"] = count - } - var out struct { - Dates []any `json:"dates"` - } - if err := c.post(ctx, "/api/data/trading_dates", body, &out); err != nil { - return nil, err - } - return out.Dates, nil -} - -func (c *Client) Svol(ctx context.Context, stockcode string) (any, error) { - return c.postField(ctx, "/api/data/svol", map[string]any{"stockcode": stockcode}, "svol") -} - -func (c *Client) Bvol(ctx context.Context, stockcode string) (any, error) { - return c.postField(ctx, "/api/data/bvol", map[string]any{"stockcode": stockcode}, "bvol") -} - -func (c *Client) Longhubang(ctx context.Context, stockList []string, startTime, endTime string) (any, error) { - return c.postField(ctx, "/api/data/longhubang", map[string]any{ - "stock_list": csvJoin(stockList), "startTime": startTime, "endTime": endTime, - }, "data") -} - -func (c *Client) Top10ShareHolder(ctx context.Context, stockList []string, dataName, startTime, endTime string) (any, error) { - return c.postField(ctx, "/api/data/top10_share_holder", map[string]any{ - "stock_list": csvJoin(stockList), "data_name": dataName, "start_time": startTime, "end_time": endTime, - }, "data") -} - -func (c *Client) OptionDetail(ctx context.Context, optioncode string) (any, error) { - return c.postField(ctx, "/api/data/option_detail", map[string]any{"optioncode": optioncode}, "detail") -} - -func (c *Client) TurnoverRate(ctx context.Context, stockList []string, startTime, endTime string) (any, error) { - return c.postField(ctx, "/api/data/turnover_rate", map[string]any{ - "stock_list": csvJoin(stockList), "startTime": startTime, "endTime": endTime, - }, "data") -} - -func (c *Client) ETFInfo(ctx context.Context, stockcode string) (any, error) { - return c.postField(ctx, "/api/data/etf_info", map[string]any{"stockcode": stockcode}, "info") -} - -func (c *Client) ETFIOPV(ctx context.Context, stockcode string) (any, error) { - return c.postField(ctx, "/api/data/etf_iopv", map[string]any{"stockcode": stockcode}, "iopv") -} - -func (c *Client) InstrumentDetail(ctx context.Context, stockcode string) (any, error) { - return c.postField(ctx, "/api/data/instrumentdetail", map[string]any{"stockcode": stockcode}, "detail") -} - -func (c *Client) ContractExpireDate(ctx context.Context, codemarket string) (any, error) { - return c.postField(ctx, "/api/data/contract_expire_date", map[string]any{"codemarket": codemarket}, "expire_date") -} - -func (c *Client) OptionUndlData(ctx context.Context, undlCodeRef string) (any, error) { - return c.postField(ctx, "/api/data/option_undl_data", map[string]any{"undl_code_ref": undlCodeRef}, "data") -} - -type FinancialDataRequest struct { - Tabname string - Colname string - Market string - Code string - ReportType string - Barpos int - FieldList []string - StockList []string - StartDate string - EndDate string -} - -func (c *Client) FinancialData(ctx context.Context, req FinancialDataRequest) (any, error) { - body := map[string]any{ - "tabname": req.Tabname, "colname": req.Colname, "market": req.Market, "code": req.Code, - "report_type": req.ReportType, "barpos": req.Barpos, - "fieldList": csvJoin(req.FieldList), "stockList": csvJoin(req.StockList), - "startDate": req.StartDate, "endDate": req.EndDate, - } - return c.postField(ctx, "/api/data/financial_data", body, "data") -} - -type FactorDataRequest struct { - FieldList []string - StockList []string - StockCode string - StartDate string - EndDate string -} - -func (c *Client) FactorData(ctx context.Context, req FactorDataRequest) (any, error) { - body := map[string]any{ - "fieldList": csvJoin(req.FieldList), "stockList": csvJoin(req.StockList), - "stockCode": req.StockCode, "startDate": req.StartDate, "endDate": req.EndDate, - } - return c.postField(ctx, "/api/data/factor_data", body, "data") -} - -func (c *Client) HisSTData(ctx context.Context, stockCode string) (any, error) { - return c.postField(ctx, "/api/data/his_st_data", map[string]any{"stockCode": stockCode}, "data") -} - -func (c *Client) HisIndexData(ctx context.Context, index string) (any, error) { - return c.postField(ctx, "/api/data/his_index_data", map[string]any{"index": index}, "data") -} - -func (c *Client) AllSubscription(ctx context.Context) (any, error) { - return c.getField(ctx, "/api/data/all_subscription", "subscriptions") -} - -func (c *Client) OptionList(ctx context.Context, undlCode, dedate, opttype string, isavailable bool) (any, error) { - body := map[string]any{ - "undl_code": undlCode, "dedate": dedate, "opttype": opttype, - "isavailable": strconv.FormatBool(isavailable), - } - return c.postField(ctx, "/api/data/option_list", body, "option_list") -} - -func (c *Client) HisContractList(ctx context.Context, market string) (any, error) { - return c.postField(ctx, "/api/data/his_contract_list", map[string]any{"market": market}, "contracts") -} - -func (c *Client) OptionIV(ctx context.Context, optioncode string) (any, error) { - return c.postField(ctx, "/api/data/option_iv", map[string]any{"optioncode": optioncode}, "iv") -} - -type BSMPriceRequest struct { - OptionType string - ObjectPrices any // float64 或 []float64 - StrikePrice float64 - RiskFree float64 - Sigma float64 - Days int - Dividend float64 -} - -func (c *Client) BSMPrice(ctx context.Context, req BSMPriceRequest) (any, error) { - prices := req.ObjectPrices - if vals, ok := req.ObjectPrices.([]float64); ok { - parts := make([]string, len(vals)) - for i, v := range vals { - parts[i] = strconv.FormatFloat(v, 'f', -1, 64) - } - prices = strings.Join(parts, ",") - } - return c.postField(ctx, "/api/data/bsm_price", map[string]any{ - "optionType": req.OptionType, "objectPrices": prices, "strikePrice": req.StrikePrice, - "riskFree": req.RiskFree, "sigma": req.Sigma, "days": req.Days, "dividend": req.Dividend, - }, "price") -} - -type BSMIVRequest struct { - OptionType string `json:"optionType"` - ObjectPrices float64 `json:"objectPrices"` - StrikePrice float64 `json:"strikePrice"` - OptionPrice float64 `json:"optionPrice"` - RiskFree float64 `json:"riskFree"` - Days int `json:"days"` - Dividend float64 `json:"dividend"` -} - -func (c *Client) BSMIV(ctx context.Context, req BSMIVRequest) (any, error) { - return c.postField(ctx, "/api/data/bsm_iv", req, "iv") -} - -type LocalDataRequest struct { - StockCode string `json:"stock_code"` - StartTime string `json:"start_time,omitempty"` - EndTime string `json:"end_time,omitempty"` - Period string `json:"period,omitempty"` - DividType string `json:"divid_type,omitempty"` - Count int `json:"count"` -} - -func (c *Client) LocalData(ctx context.Context, req LocalDataRequest) (any, error) { - return c.postField(ctx, "/api/data/local_data", req, "data") -} - -func (c *Client) SubscribeQuote(ctx context.Context, stockCode, period, dividendType string) (*SubscribeResult, error) { - var out SubscribeResult - body := map[string]any{"stock_code": stockCode, "period": period, "dividend_type": dividendType} - if err := c.post(ctx, "/api/data/subscribe_quote", body, &out); err != nil { - return nil, err - } - return &out, nil -} - -func (c *Client) UnsubscribeQuote(ctx context.Context, subID int) (*SubscribeResult, error) { - var out SubscribeResult - if err := c.post(ctx, "/api/data/unsubscribe_quote", map[string]any{"sub_id": subID}, &out); err != nil { - return nil, err - } - return &out, nil -} diff --git a/go-client/sdk/doc.go b/go-client/sdk/doc.go deleted file mode 100644 index 9ac602a..0000000 --- a/go-client/sdk/doc.go +++ /dev/null @@ -1,4 +0,0 @@ -// Package sdk 是 QMT_API.py HTTP 服务的 Go 客户端。 -// -// 用法:sdk.New(baseURL, token, timeout),账户类型默认 stock,资金账号由服务端环境变量决定。 -package sdk diff --git a/go-client/sdk/error.go b/go-client/sdk/error.go deleted file mode 100644 index cd888e4..0000000 --- a/go-client/sdk/error.go +++ /dev/null @@ -1,38 +0,0 @@ -package sdk - -import ( - "fmt" - "net/http" -) - -// APIError 表示服务端返回的 HTTP 错误(write_error 格式)。 -type APIError struct { - StatusCode int `json:"status_code"` - Message string `json:"error"` -} - -func (e *APIError) Error() string { - if e == nil { - return "qmt api error" - } - if e.Message == "" { - return fmt.Sprintf("qmt api: http %d", e.StatusCode) - } - return fmt.Sprintf("qmt api: http %d: %s", e.StatusCode, e.Message) -} - -func (e *APIError) Unauthorized() bool { - return e != nil && e.StatusCode == http.StatusUnauthorized -} - -// BusinessError 表示 HTTP 200 但业务 JSON 带 error 字段。 -type BusinessError struct { - Message string -} - -func (e *BusinessError) Error() string { - if e == nil || e.Message == "" { - return "qmt api business error" - } - return e.Message -} diff --git a/go-client/sdk/ext.go b/go-client/sdk/ext.go deleted file mode 100644 index b42e122..0000000 --- a/go-client/sdk/ext.go +++ /dev/null @@ -1,27 +0,0 @@ -package sdk - -import "context" - -func (c *Client) ExtData(ctx context.Context, extdataname, stockcode string, deviation int) (any, error) { - return c.postField(ctx, "/api/ext/ext_data", map[string]any{ - "extdataname": extdataname, "stockcode": stockcode, "deviation": deviation, - }, "value") -} - -func (c *Client) ExtDataRank(ctx context.Context, extdataname, stockcode string, deviation int) (any, error) { - return c.postField(ctx, "/api/ext/ext_data_rank", map[string]any{ - "extdataname": extdataname, "stockcode": stockcode, "deviation": deviation, - }, "rank") -} - -func (c *Client) GetFactorValue(ctx context.Context, factorname, stockcode string, deviation int) (any, error) { - return c.postField(ctx, "/api/ext/get_factor_value", map[string]any{ - "factorname": factorname, "stockcode": stockcode, "deviation": deviation, - }, "value") -} - -func (c *Client) GetFactorRank(ctx context.Context, factorname, stockcode string, deviation int) (any, error) { - return c.postField(ctx, "/api/ext/get_factor_rank", map[string]any{ - "factorname": factorname, "stockcode": stockcode, "deviation": deviation, - }, "rank") -} diff --git a/go-client/sdk/sys.go b/go-client/sdk/sys.go deleted file mode 100644 index e7d3f03..0000000 --- a/go-client/sdk/sys.go +++ /dev/null @@ -1,30 +0,0 @@ -package sdk - -import "context" - -type PythonVersion struct { - PythonVersion string `json:"python_version"` - PythonVersionInfo struct { - Major int `json:"major"` - Minor int `json:"minor"` - Micro int `json:"micro"` - ReleaseLevel string `json:"releaselevel"` - Serial int `json:"serial"` - } `json:"python_version_info"` -} - -func (c *Client) PythonVersion(ctx context.Context) (*PythonVersion, error) { - var out PythonVersion - if err := c.get(ctx, "/api/sys/python_version", &out); err != nil { - return nil, err - } - return &out, nil -} - -func (c *Client) Shutdown(ctx context.Context) (map[string]any, error) { - var out map[string]any - if err := c.post(ctx, "/api/sys/shutdown", map[string]any{}, &out); err != nil { - return nil, err - } - return out, nil -} diff --git a/go-client/sdk/trade.go b/go-client/sdk/trade.go deleted file mode 100644 index 7728adb..0000000 --- a/go-client/sdk/trade.go +++ /dev/null @@ -1,252 +0,0 @@ -package sdk - -import "context" - -const ( - OpBuy = 23 - OpSell = 24 - OrderTypeVolume = 1101 - PrTypeLatest = 5 - QuickTradeNow = 2 -) - -type PassorderRequest struct { - OpType int `json:"opType"` - OrderType int `json:"orderType,omitempty"` - Stock string `json:"stock"` - PrType int `json:"prType,omitempty"` - Price float64 `json:"price"` - Volume int `json:"volume"` - QuickTrade int `json:"quickTrade,omitempty"` - StrategyName string `json:"strategyName,omitempty"` -} - -func (c *Client) Passorder(ctx context.Context, req PassorderRequest) (*OrderRefResult, error) { - var out OrderRefResult - if err := c.post(ctx, "/api/trade/passorder", req, &out); err != nil { - return nil, err - } - return &out, nil -} - -// PassorderLatest 按最新价下单,不附加策略订单号。 -func (c *Client) PassorderLatest(ctx context.Context, side int, stock string, volume int) (*OrderRefResult, error) { - return c.PassorderLatestTagged(ctx, side, stock, volume, "") -} - -// PassorderLatestTagged 使用 strategyName 将本地唯一订单号传给 QMT。 -func (c *Client) PassorderLatestTagged(ctx context.Context, side int, stock string, volume int, orderID string) (*OrderRefResult, error) { - return c.Passorder(ctx, PassorderRequest{ - OpType: side, - OrderType: OrderTypeVolume, - Stock: stock, - PrType: PrTypeLatest, - Price: -1, - Volume: volume, - QuickTrade: QuickTradeNow, - StrategyName: orderID, - }) -} - -type AlgoPassorderRequest struct { - OpType int `json:"opType"` - OrderType int `json:"orderType,omitempty"` - Stock string `json:"stock"` - PrType int `json:"prType"` - Price float64 `json:"price"` - Volume int `json:"volume"` - StrategyName string `json:"strategyName,omitempty"` - QuickTrade int `json:"quickTrade,omitempty"` - UserOrderID string `json:"userOrderId,omitempty"` - UserOrderParam map[string]any `json:"userOrderParam,omitempty"` -} - -func (c *Client) AlgoPassorder(ctx context.Context, req AlgoPassorderRequest) (*OrderRefResult, error) { - var out OrderRefResult - if err := c.post(ctx, "/api/trade/algo_passorder", req, &out); err != nil { - return nil, err - } - return &out, nil -} - -type SmartAlgoPassorderRequest struct { - OpType int `json:"opType"` - OrderType int `json:"orderType,omitempty"` - Stock string `json:"stock"` - PrType int `json:"prType"` - Price float64 `json:"price"` - Volume int `json:"volume"` - SmartAlgoType string `json:"smartAlgoType"` - LimitOverRate int `json:"limitOverRate"` - MinAmountPerOrder int `json:"minAmountPerOrder"` - StartTime string `json:"startTime,omitempty"` - EndTime string `json:"endTime,omitempty"` -} - -func (c *Client) SmartAlgoPassorder(ctx context.Context, req SmartAlgoPassorderRequest) (*OrderRefResult, error) { - var out OrderRefResult - if err := c.post(ctx, "/api/trade/smart_algo_passorder", req, &out); err != nil { - return nil, err - } - return &out, nil -} - -type StyleOrderResult struct { - Status string `json:"status"` - Action string `json:"action"` - Stock string `json:"stock"` -} - -func (c *Client) styleOrder(ctx context.Context, path string, body map[string]any) (*StyleOrderResult, error) { - var out StyleOrderResult - if err := c.post(ctx, path, body, &out); err != nil { - return nil, err - } - return &out, nil -} - -func (c *Client) OrderLots(ctx context.Context, stock string, lots int, style string, price float64) (*StyleOrderResult, error) { - return c.styleOrder(ctx, "/api/trade/order_lots", map[string]any{"stock": stock, "lots": lots, "style": style, "price": price}) -} - -func (c *Client) OrderValue(ctx context.Context, stock string, value float64, style string, price float64) (*StyleOrderResult, error) { - return c.styleOrder(ctx, "/api/trade/order_value", map[string]any{"stock": stock, "value": value, "style": style, "price": price}) -} - -func (c *Client) OrderPercent(ctx context.Context, stock string, percent float64, style string, price float64) (*StyleOrderResult, error) { - return c.styleOrder(ctx, "/api/trade/order_percent", map[string]any{"stock": stock, "percent": percent, "style": style, "price": price}) -} - -func (c *Client) OrderTargetValue(ctx context.Context, stock string, tarValue float64, style string, price float64) (*StyleOrderResult, error) { - return c.styleOrder(ctx, "/api/trade/order_target_value", map[string]any{"stock": stock, "tar_value": tarValue, "style": style, "price": price}) -} - -func (c *Client) OrderTargetPercent(ctx context.Context, stock string, tarPercent float64, style string, price float64) (*StyleOrderResult, error) { - return c.styleOrder(ctx, "/api/trade/order_target_percent", map[string]any{"stock": stock, "tar_percent": tarPercent, "style": style, "price": price}) -} - -func (c *Client) OrderShares(ctx context.Context, stock string, shares int, style string, price float64) (*StyleOrderResult, error) { - return c.styleOrder(ctx, "/api/trade/order_shares", map[string]any{"stock": stock, "shares": shares, "style": style, "price": price}) -} - -func (c *Client) FuturesBuyOpen(ctx context.Context, stock string, amount int, style string, price float64) (*StyleOrderResult, error) { - return c.styleOrder(ctx, "/api/trade/futures/buy_open", map[string]any{"stock": stock, "amount": amount, "style": style, "price": price}) -} -func (c *Client) FuturesBuyCloseTdayFirst(ctx context.Context, stock string, amount int, style string, price float64) (*StyleOrderResult, error) { - return c.styleOrder(ctx, "/api/trade/futures/buy_close_tdayfirst", map[string]any{"stock": stock, "amount": amount, "style": style, "price": price}) -} -func (c *Client) FuturesBuyCloseYdayFirst(ctx context.Context, stock string, amount int, style string, price float64) (*StyleOrderResult, error) { - return c.styleOrder(ctx, "/api/trade/futures/buy_close_ydayfirst", map[string]any{"stock": stock, "amount": amount, "style": style, "price": price}) -} -func (c *Client) FuturesSellOpen(ctx context.Context, stock string, amount int, style string, price float64) (*StyleOrderResult, error) { - return c.styleOrder(ctx, "/api/trade/futures/sell_open", map[string]any{"stock": stock, "amount": amount, "style": style, "price": price}) -} -func (c *Client) FuturesSellCloseTdayFirst(ctx context.Context, stock string, amount int, style string, price float64) (*StyleOrderResult, error) { - return c.styleOrder(ctx, "/api/trade/futures/sell_close_tdayfirst", map[string]any{"stock": stock, "amount": amount, "style": style, "price": price}) -} -func (c *Client) FuturesSellCloseYdayFirst(ctx context.Context, stock string, amount int, style string, price float64) (*StyleOrderResult, error) { - return c.styleOrder(ctx, "/api/trade/futures/sell_close_ydayfirst", map[string]any{"stock": stock, "amount": amount, "style": style, "price": price}) -} - -type TaskResult struct { - Status string `json:"status"` - TaskID any `json:"taskId"` -} - -func (c *Client) CancelTask(ctx context.Context, taskID string) (*TaskResult, error) { - return c.task(ctx, "/api/trade/cancel_task", taskID) -} -func (c *Client) PauseTask(ctx context.Context, taskID string) (*TaskResult, error) { - return c.task(ctx, "/api/trade/pause_task", taskID) -} -func (c *Client) ResumeTask(ctx context.Context, taskID string) (*TaskResult, error) { - return c.task(ctx, "/api/trade/resume_task", taskID) -} - -func (c *Client) task(ctx context.Context, path, taskID string) (*TaskResult, error) { - var out TaskResult - if err := c.post(ctx, path, map[string]any{"taskId": taskID, "accountType": c.accountType}, &out); err != nil { - return nil, err - } - return &out, nil -} - -func (c *Client) DoOrder(ctx context.Context) (map[string]any, error) { - var out map[string]any - if err := c.post(ctx, "/api/trade/do_order", nil, &out); err != nil { - return nil, err - } - return out, nil -} - -func (c *Client) TradeDetailData(ctx context.Context, datatype string) ([]map[string]string, error) { - var out struct { - Data []map[string]string `json:"data"` - } - if err := c.post(ctx, "/api/trade/trade_detail_data", map[string]any{"account": c.accountType, "datatype": datatype}, &out); err != nil { - return nil, err - } - if out.Data == nil { - return []map[string]string{}, nil - } - return out.Data, nil -} - -func (c *Client) ValueByOrderID(ctx context.Context, orderID, datatype string) (map[string]string, error) { - var out struct { - Data map[string]string `json:"data"` - } - body := map[string]any{"orderId": orderID, "accountType": c.accountType, "datatype": datatype} - if err := c.post(ctx, "/api/trade/value_by_order_id", body, &out); err != nil { - return nil, err - } - return out.Data, nil -} - -func (c *Client) LastOrderID(ctx context.Context, datatype string) (any, error) { - var out struct { - LastOrderID any `json:"last_order_id"` - } - if err := c.post(ctx, "/api/trade/last_order_id", map[string]any{"account": c.accountType, "datatype": datatype}, &out); err != nil { - return nil, err - } - return out.LastOrderID, nil -} - -func (c *Client) CanCancelOrder(ctx context.Context, orderID string) (any, error) { - var out struct { - CanCancel any `json:"can_cancel"` - } - if err := c.post(ctx, "/api/trade/can_cancel_order", map[string]any{"orderId": orderID, "accountType": c.accountType}, &out); err != nil { - return nil, err - } - return out.CanCancel, nil -} - -func (c *Client) contractList(ctx context.Context, path string) ([]map[string]string, error) { - var out struct { - Data []map[string]string `json:"data"` - } - if err := c.post(ctx, path, nil, &out); err != nil { - return nil, err - } - return out.Data, nil -} - -func (c *Client) DebtContract(ctx context.Context) ([]map[string]string, error) { - return c.contractList(ctx, "/api/trade/debt_contract") -} -func (c *Client) AssureContract(ctx context.Context) ([]map[string]string, error) { - return c.contractList(ctx, "/api/trade/assure_contract") -} -func (c *Client) EnableShortContract(ctx context.Context) ([]map[string]string, error) { - return c.contractList(ctx, "/api/trade/enable_short_contract") -} - -func (c *Client) IPOData(ctx context.Context, typ string) (any, error) { - return c.postField(ctx, "/api/trade/ipo_data", map[string]any{"type": typ}, "data") -} - -func (c *Client) NewPurchaseLimit(ctx context.Context) (any, error) { - return c.postField(ctx, "/api/trade/new_purchase_limit", nil, "data") -} diff --git a/py-client/__pycache__/main.cpython-311.pyc b/py-client/__pycache__/main.cpython-311.pyc new file mode 100644 index 0000000..bac70a4 Binary files /dev/null and b/py-client/__pycache__/main.cpython-311.pyc differ diff --git a/py-client/__pycache__/test.cpython-311.pyc b/py-client/__pycache__/test.cpython-311.pyc new file mode 100644 index 0000000..22ef188 Binary files /dev/null and b/py-client/__pycache__/test.cpython-311.pyc differ diff --git a/py-client/config/__init__.py b/py-client/config/__init__.py new file mode 100644 index 0000000..ee290ea --- /dev/null +++ b/py-client/config/__init__.py @@ -0,0 +1,146 @@ +from __future__ import annotations + +import socket +from dataclasses import dataclass, field +from pathlib import Path + +import yaml + + +@dataclass +class SignalConfig: + """单个交易信号的数据源及开仓限制配置。""" + + # 信号接口相对于 api_host 的路径。 + url: str = "" + + # 允许使用该信号的时间段;"*" 表示不限制时间。 + timezone: str = "*" + + # 当前价格高于信号昨收价时是否仍允许开仓。 + gt_last_price_is_open: bool = False + + +@dataclass +class GlobalConfig: + """所有主机共享的系统配置。""" + + qmt_base_url: str = "" + qmt_token: str = "" + api_host: str = "" + qmt_data_dir: str = "" + + # Windows 主机名到对应账户配置文件的映射。 + hosts: dict[str, str] = field(default_factory=dict) + + # 信号名称到信号配置的映射。 + signals: dict[str, SignalConfig] = field(default_factory=dict) + + +@dataclass +class AccountConfig: + """当前主机所使用的账户及交易策略参数。""" + + account_id: str = "" + host_key: str = "" + buy_value: float = 0 + min_cash_ratio: float = 0 + loss_trigger_pct: float = 0 + grid_step_pct: float = 1 + min_profit_pct: float = 0 + enable_loss_add_position: bool = False + signal_allow: list[str] = field(default_factory=list) + excluded_codes: list[str] = field(default_factory=list) + + # 当前账户启用的策略名称,例如 trend。 + strategy: str = "" + + +# load() 成功后保存已加载的配置,供策略模块直接读取。 +global_config: GlobalConfig | None = None +account_config: AccountConfig | None = None + +# QMT 和外部 HTTP 接口的默认请求超时时间,单位为秒。 +HTTP_TIMEOUT = 5.0 + + +def load( + etc_dir: str | Path | None = None, + hostname: str | None = None, +) -> tuple[GlobalConfig, AccountConfig]: + """加载公共配置以及当前主机对应的账户配置。 + + Args: + etc_dir: 配置文件目录,其中必须包含 ``_global.yaml``;为空时 + 默认使用 py-client 下的 ``etc`` 目录。 + hostname: 指定要加载的主机名;为空时使用当前计算机名。 + + Returns: + 由全局配置和账户配置组成的二元组。 + + Raises: + ValueError: 配置缺失、格式错误或策略参数不合法。 + """ + global global_config, account_config + + root = Path(etc_dir) if etc_dir is not None else Path(__file__).parent.parent / "etc" + raw = _yaml(root / "_global.yaml") + + # 将原始字典转换为带类型的信号配置,方便业务代码使用属性访问。 + signals = { + key: SignalConfig(**(value or {})) + for key, value in (raw.get("signals") or {}).items() + } + values = { + key: raw.get(key, "") + for key in ("qmt_base_url", "qmt_token", "api_host", "qmt_data_dir") + } + + current = hostname or socket.gethostname() + hosts = raw.get("hosts") or {} + account_file = next( + ( + value + for key, value in hosts.items() + if key.strip().lower() == current.strip().lower() + ), + "", + ) + + # QMT 地址、外部 API 地址和数据目录是启动策略的必要参数。 + if ( + not values["qmt_base_url"] + or not values["api_host"] + or values["qmt_data_dir"] == "." + ): + raise ValueError("Global 配置缺少必要参数") + + if not account_file: + raise ValueError(f'_global.yaml 未配置计算机 "{current}"') + if not Path(account_file).suffix: + account_file += ".yaml" + + global_config = GlobalConfig(**values, hosts=hosts, signals=signals) + + # 策略状态文件写入该目录,启动时提前确保目录存在。 + Path(global_config.qmt_data_dir).mkdir(parents=True, exist_ok=True) + + account_config = AccountConfig(**_yaml(root / account_file)) + if account_config.buy_value <= 0 or account_config.grid_step_pct <= 0: + raise ValueError("buy_value、grid_step_pct 必须大于 0") + if not account_config.strategy.strip(): + raise ValueError("strategy 不能为空") + + # host_key 统一为小写,避免不同模块比较时受大小写影响。 + account_config.host_key = account_config.host_key.lower() + account_config.strategy = account_config.strategy.lower() + return global_config, account_config + + +def _yaml(path: Path) -> dict: + """读取 YAML 文件,并将空文件转换为空字典。""" + try: + with path.open(encoding="utf-8") as handle: + return yaml.safe_load(handle) or {} + except (OSError, yaml.YAMLError) as exc: + raise ValueError(f"读取或解析配置 {path} 失败: {exc}") from exc diff --git a/py-client/config/__pycache__/__init__.cpython-311.pyc b/py-client/config/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..dbf8677 Binary files /dev/null and b/py-client/config/__pycache__/__init__.cpython-311.pyc differ diff --git a/py-client/etc/_global.yaml b/py-client/etc/_global.yaml new file mode 100644 index 0000000..14dcd85 --- /dev/null +++ b/py-client/etc/_global.yaml @@ -0,0 +1,11 @@ +qmt_base_url: http://127.0.0.1:10086 +qmt_token: QMTbyYanweidong +api_host: http://139.224.247.176:13499 +qmt_data_dir: D:/qmt_strategy_data +hosts: + DESKTOP-39H91QV: dev.yaml +signals: + dcm: {url: /a/dcm_signal, timezone: "*", gt_last_price_is_open: false} + morning: {url: /a/morning_signal, timezone: "9:30-10:30", gt_last_price_is_open: true} + tail: {url: /a/tail_signal, timezone: "14:30-14:55", gt_last_price_is_open: false} + arbitrage: {url: /a/arbitrage_signal, timezone: "*", gt_last_price_is_open: false} diff --git a/py-client/etc/dev.yaml b/py-client/etc/dev.yaml new file mode 100644 index 0000000..6d9f9e8 --- /dev/null +++ b/py-client/etc/dev.yaml @@ -0,0 +1,11 @@ +account_id: 86037237 +host_key: dev +buy_value: 5000 +min_cash_ratio: 0.10 +loss_trigger_pct: -30 +grid_step_pct: 1 +min_profit_pct: 2 +strategy: trend +enable_loss_add_position: True +excluded_codes: + - "00000.SZ" diff --git a/py-client/libs/__init__.py b/py-client/libs/__init__.py new file mode 100644 index 0000000..aeba29b --- /dev/null +++ b/py-client/libs/__init__.py @@ -0,0 +1,5 @@ +from .calc import calc_buy_volume, trading_time +from .market import market_allow_open, status +from .signal import SignalItem, SignalResult, fetch_signal, init_signals + +__all__ = ["calc_buy_volume", "trading_time", "market_allow_open", "status", "SignalItem", "SignalResult", "fetch_signal", "init_signals"] diff --git a/py-client/libs/__pycache__/__init__.cpython-311.pyc b/py-client/libs/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..4cc46b6 Binary files /dev/null and b/py-client/libs/__pycache__/__init__.cpython-311.pyc differ diff --git a/py-client/libs/__pycache__/calc.cpython-311.pyc b/py-client/libs/__pycache__/calc.cpython-311.pyc new file mode 100644 index 0000000..f7b26b8 Binary files /dev/null and b/py-client/libs/__pycache__/calc.cpython-311.pyc differ diff --git a/py-client/libs/__pycache__/grid_take_profit.cpython-311.pyc b/py-client/libs/__pycache__/grid_take_profit.cpython-311.pyc new file mode 100644 index 0000000..7e8bb89 Binary files /dev/null and b/py-client/libs/__pycache__/grid_take_profit.cpython-311.pyc differ diff --git a/py-client/libs/__pycache__/http.cpython-311.pyc b/py-client/libs/__pycache__/http.cpython-311.pyc new file mode 100644 index 0000000..c3930de Binary files /dev/null and b/py-client/libs/__pycache__/http.cpython-311.pyc differ diff --git a/py-client/libs/__pycache__/market.cpython-311.pyc b/py-client/libs/__pycache__/market.cpython-311.pyc new file mode 100644 index 0000000..9f8ad48 Binary files /dev/null and b/py-client/libs/__pycache__/market.cpython-311.pyc differ diff --git a/py-client/libs/__pycache__/signal.cpython-311.pyc b/py-client/libs/__pycache__/signal.cpython-311.pyc new file mode 100644 index 0000000..c73ccc5 Binary files /dev/null and b/py-client/libs/__pycache__/signal.cpython-311.pyc differ diff --git a/py-client/libs/calc.py b/py-client/libs/calc.py new file mode 100644 index 0000000..34d53a3 --- /dev/null +++ b/py-client/libs/calc.py @@ -0,0 +1,32 @@ +from datetime import datetime, time +from math import floor + + +def trading_time(now: datetime) -> bool: + if now.weekday() >= 5: return False + return time(9, 30) <= now.time() <= time(11, 30) or time(13) <= now.time() <= time(15) + + +def calc_buy_volume(price: float, buy_value: float) -> int: + if price <= 0 or buy_value <= 0: return 0 + return max(1, floor(buy_value / (price * 100))) * 100 + +def calculate_min_profit_rate(price: float, profit_mult: int) -> float: + """ + 根据价格返回最小利润率 + + Args: + price: 股票价格 + profit_mult: 利润倍数配置 + + Returns: + float: 最小利润率(百分比) + """ + if price >= 300: + return 3 * profit_mult # 3% + if price >= 200: + return 5 * profit_mult # 5% + elif price >= 100: + return 7 * profit_mult # 7% + else: + return 9 * profit_mult # 9% \ No newline at end of file diff --git a/py-client/libs/grid_take_profit.py b/py-client/libs/grid_take_profit.py new file mode 100644 index 0000000..60de290 --- /dev/null +++ b/py-client/libs/grid_take_profit.py @@ -0,0 +1,80 @@ +# -*- coding: utf-8 -*- +"""网格回撤止盈状态机。 + +该模块只负责记录每个持仓的最高盈利网格,并判断当前盈亏率是否从 +峰值网格回撤。它不包含下单逻辑,由主策略和 Upmax 根据返回的状态决定是否卖出。 +""" + +from dataclasses import dataclass +from enum import Enum +import math +import threading + + +class GridState(str, Enum): + """单次盈亏率观察后的网格状态。""" + + ARMED = "armed" # 首次记录该持仓的峰值网格 + RAISED = "raised" # 盈利继续上升,峰值网格已抬高 + RETREAT = "retreat" # 从峰值网格回撤,应由调用方执行止盈 + STEADY = "steady" # 仍处于当前峰值网格,继续持有 + + +@dataclass(frozen=True) +class GridObservation: + """一次网格观察的不可变结果。""" + + state: GridState + current_grid: int # 当前盈亏率所处的网格 + peak_grid: int # 该持仓自观察以来的最高网格 + + +class GridTrailingTracker: + """按持仓键隔离、线程安全的峰值网格跟踪器。""" + + def __init__(self, step: float = 1.0): + """ + Args: + step: 单个网格的盈亏率跨度(百分点),必须大于 0。 + """ + if step <= 0: + raise ValueError("grid step must be positive") + self._step = step + # key 由调用方组成“账户 + 股票代码”,防止多账户状态串扰。 + self._peaks: dict[str, int] = {} + # 主策略和回调线程可能并发访问,所有峰值读写均在同一把锁内。 + self._lock = threading.Lock() + + def observe(self, position_key: str, pnl_rate: float) -> GridObservation: + """记录当前盈亏率,并返回相对于历史峰值的状态。""" + # floor 保证负数盈亏率也按完整网格向下归档。 + current_grid = math.floor(pnl_rate / self._step) + with self._lock: + peak_grid = self._peaks.get(position_key) + + # 第一次看到该持仓:建立基准,不触发止盈。 + if peak_grid is None: + self._peaks[position_key] = current_grid + return GridObservation(GridState.ARMED, current_grid, current_grid) + + # 进入更高网格:更新峰值,继续持有。 + if current_grid > peak_grid: + self._peaks[position_key] = current_grid + return GridObservation(GridState.RAISED, current_grid, current_grid) + + # 跌破峰值网格:报告回撤,但保留峰值直到卖出成功后 clear。 + if current_grid < peak_grid: + return GridObservation(GridState.RETREAT, current_grid, peak_grid) + + return GridObservation(GridState.STEADY, current_grid, peak_grid) + + def clear(self, position_key: str) -> None: + """持仓卖出成功后删除峰值,使下次建仓从新状态开始。""" + with self._lock: + self._peaks.pop(position_key, None) + + def retain(self, position_keys) -> None: + """删除已不在券商持仓中的峰值,避免同代码重新开仓继承旧状态。""" + active = set(position_keys) + with self._lock: + self._peaks = {key: value for key, value in self._peaks.items() if key in active} diff --git a/py-client/libs/http.py b/py-client/libs/http.py new file mode 100644 index 0000000..4574114 --- /dev/null +++ b/py-client/libs/http.py @@ -0,0 +1,8 @@ +import json +from urllib.request import Request, urlopen + + +def get_json(url: str, timeout: float = 5.0): + request = Request(url, headers={"Accept": "application/json", "User-Agent": "big-qmt-python/1"}) + with urlopen(request, timeout=timeout) as response: + return json.load(response) diff --git a/py-client/libs/market.py b/py-client/libs/market.py new file mode 100644 index 0000000..21192e9 --- /dev/null +++ b/py-client/libs/market.py @@ -0,0 +1,24 @@ +import logging +import secrets + +from .http import get_json + +API_HOST = "http://139.224.247.176:13499" +MARKET_URL, PERIOD, HTTP_TIMEOUT = "/a/market", "60m", 5.0 + + +def status(payload) -> str: + value = payload.get("data", payload) if isinstance(payload, dict) else payload + if isinstance(value, list): value = value[-1] if value else None + if isinstance(value, dict): value = value.get("action", value.get("status", value.get("signal"))) + result = str(value).strip().upper() + return result if result in {"UP", "DOWN", "NEUTRAL"} else "UNKNOWN" + + +def market_allow_open(api_host: str = API_HOST) -> bool: + url = f"{api_host}{MARKET_URL}?period={PERIOD}&t={secrets.token_urlsafe(12)}" + try: result = status(get_json(url, HTTP_TIMEOUT)) + except Exception as exc: + logging.error("获取大盘指数失败: %s %s", url, exc); return False + logging.info("大盘信号: url=%s status=%s", url, result) + return result == "UP" diff --git a/py-client/libs/signal.py b/py-client/libs/signal.py new file mode 100644 index 0000000..aedbbe8 --- /dev/null +++ b/py-client/libs/signal.py @@ -0,0 +1,29 @@ +from dataclasses import dataclass, field +import secrets + +from .http import get_json + + +@dataclass +class SignalItem: + signal_key: str = ""; code: str = ""; name: str = ""; desc: str = ""; last_close: float = 0 + tech_indicator: dict[str, float] = field(default_factory=dict) + +@dataclass +class SignalResult: + code: str = ""; total: int = 0; updated: str = ""; data: dict[str, SignalItem] = field(default_factory=dict); message: str = "" + + +def fetch_signal(api_host: str, sub_url: str, timeout: float = 5.0) -> SignalResult: + url = f"{api_host}{sub_url}?t={secrets.token_urlsafe(12)}" + raw = get_json(url, timeout) + items = {code: SignalItem(**item) for code, item in (raw.get("data") or {}).items()} + return SignalResult(raw.get("code", ""), raw.get("total", 0), raw.get("updated", ""), items, raw.get("message", "")) + + +def init_signals(global_config, allow: list[str]) -> list[SignalItem]: + result = [] + for key, cfg in global_config.signals.items(): + if key in allow: + for item in fetch_signal(global_config.api_host, cfg.url).data.values(): item.signal_key = key; result.append(item) + return result diff --git a/py-client/main.py b/py-client/main.py new file mode 100644 index 0000000..beea784 --- /dev/null +++ b/py-client/main.py @@ -0,0 +1,111 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +import logging as log +import os +import sys +import time +import config +from dataclasses import dataclass +import yaml + +PROJECT_ROOT = os.path.dirname(os.path.abspath(__file__)) +GLOBAL_CONFIG_PATH = os.path.join(PROJECT_ROOT, "etc", "_global.yaml") +if PROJECT_ROOT not in sys.path: + sys.path.insert(0, PROJECT_ROOT) + +from sdk import Client +from strategy.trend.boot import StartTrend + + +@dataclass(frozen=True) +class StrategyDefinition: + mutex_scope: str + start_strategy: object + + +STRATEGIES = { + "trend": StrategyDefinition("Trend", StartTrend), +} + +def require_windows() -> bool: + return os.name == "nt" + +def check_single_instance(project_root: str) -> bool: + """使用 Windows 命名互斥锁保证单实例。""" + try: + import ctypes + + error_already_exists = 183 + invalid_handle_value = -1 + safe_path = project_root.replace(":", "_").replace("\\", "_") + mutex_name = f"Global\\QMT_System_{safe_path}" + kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + handle = kernel32.CreateMutexW(None, True, mutex_name) + if not handle or handle == invalid_handle_value: + log.error(f"无法创建互斥锁,错误代码:{ctypes.get_last_error()}") + return False + if ctypes.get_last_error() == error_already_exists: + log.error("程序已在运行中,无法启动多个实例") + kernel32.CloseHandle(handle) + return False + log.info(f"成功获取互斥锁:{mutex_name}") + return True + except Exception as exc: + log.error(f"单实例检测失败:{exc}", exc_info=True) + return False + + +def ping_api_host( + rpc_host: str, + retry_interval: float = 5.0, + connect_timeout: float = 3.0, +) -> None: + """循环检查 API 地址,连通后才返回。""" + client = Client(config.global_config.qmt_base_url, config.global_config.qmt_token, config.HTTP_TIMEOUT) + + while True: + try: + assets = client.assets() + log.info(f"API 服务已连通:{config.global_config.qmt_base_url}") + return + except: + log.warning( + f"API 服务未就绪:{config.global_config.qmt_base_url},{retry_interval:g} 秒后重试" + ) + time.sleep(retry_interval) + +def wait_for_any_key() -> None: + print("按任意键退出...", flush=True) + if os.name == "nt": + import msvcrt + + msvcrt.getch() + elif sys.stdin.isatty(): + sys.stdin.read(1) + + +def main() -> int: + try: + if not require_windows(): + log.error("本程序仅支持 Windows 环境运行") + return 1 + if not check_single_instance(PROJECT_ROOT): + return 1 + + config.load() + if config.global_config is None or config.account_config is None: + raise RuntimeError("配置尚未加载,请先调用 config.load()") + + ping_api_host(config.global_config.qmt_base_url) + + STRATEGIES[config.account_config.strategy].start_strategy() + return 0 + except (OSError, yaml.YAMLError, ValueError) as exc: + print(f"启动失败: {exc}", file=sys.stderr, flush=True) + wait_for_any_key() + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/py-client/sdk/__init__.py b/py-client/sdk/__init__.py new file mode 100644 index 0000000..6ea2685 --- /dev/null +++ b/py-client/sdk/__init__.py @@ -0,0 +1,14 @@ +from .account import AccountMixin +from .client import Client as _HTTPClient +from .data import DataMixin +from .errors import APIError, BusinessError +from .misc import MiscMixin +from .models import * +from .trade import * + + +class Client(AccountMixin, DataMixin, TradeMixin, MiscMixin, _HTTPClient): + """big-qmt 同步 HTTP 客户端。""" + + +__all__ = ["Client", "APIError", "BusinessError", "OP_BUY", "OP_SELL", "ORDER_TYPE_VOLUME", "PR_TYPE_LATEST", "QUICK_TRADE_NOW"] diff --git a/py-client/sdk/__pycache__/__init__.cpython-311.pyc b/py-client/sdk/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..842d9ff Binary files /dev/null and b/py-client/sdk/__pycache__/__init__.cpython-311.pyc differ diff --git a/py-client/sdk/__pycache__/account.cpython-311.pyc b/py-client/sdk/__pycache__/account.cpython-311.pyc new file mode 100644 index 0000000..5596a6f Binary files /dev/null and b/py-client/sdk/__pycache__/account.cpython-311.pyc differ diff --git a/py-client/sdk/__pycache__/client.cpython-311.pyc b/py-client/sdk/__pycache__/client.cpython-311.pyc new file mode 100644 index 0000000..605885d Binary files /dev/null and b/py-client/sdk/__pycache__/client.cpython-311.pyc differ diff --git a/py-client/sdk/__pycache__/data.cpython-311.pyc b/py-client/sdk/__pycache__/data.cpython-311.pyc new file mode 100644 index 0000000..bc6d313 Binary files /dev/null and b/py-client/sdk/__pycache__/data.cpython-311.pyc differ diff --git a/py-client/sdk/__pycache__/errors.cpython-311.pyc b/py-client/sdk/__pycache__/errors.cpython-311.pyc new file mode 100644 index 0000000..1df1be8 Binary files /dev/null and b/py-client/sdk/__pycache__/errors.cpython-311.pyc differ diff --git a/py-client/sdk/__pycache__/misc.cpython-311.pyc b/py-client/sdk/__pycache__/misc.cpython-311.pyc new file mode 100644 index 0000000..7ce8e3c Binary files /dev/null and b/py-client/sdk/__pycache__/misc.cpython-311.pyc differ diff --git a/py-client/sdk/__pycache__/models.cpython-311.pyc b/py-client/sdk/__pycache__/models.cpython-311.pyc new file mode 100644 index 0000000..cd06160 Binary files /dev/null and b/py-client/sdk/__pycache__/models.cpython-311.pyc differ diff --git a/py-client/sdk/__pycache__/trade.cpython-311.pyc b/py-client/sdk/__pycache__/trade.cpython-311.pyc new file mode 100644 index 0000000..9d01233 Binary files /dev/null and b/py-client/sdk/__pycache__/trade.cpython-311.pyc differ diff --git a/py-client/sdk/account.py b/py-client/sdk/account.py new file mode 100644 index 0000000..ccc02e5 --- /dev/null +++ b/py-client/sdk/account.py @@ -0,0 +1,33 @@ +from typing import Any + +from .models import Assets, Position + + +class AccountMixin: + account_type: str + + def _positions(self, path: str) -> tuple[list[str], list[Position]]: + raw = self._post(path, {"account": self.account_type}) or {} + return list(raw), [Position.from_dict(value, code) for code, value in raw.items()] + + def positions(self): return self._positions("/api/v2/positions") + def holding(self): return self._positions("/api/holding") + + def assets(self) -> Assets: + data = self._post("/api/v2/assets", {"account": self.account_type}) + return Assets(float(data.get("total", 0)), float(data.get("available", 0))) + + def total_money(self) -> float: return float(self._post("/api/money/total", {"account": self.account_type}).get("total_money", 0)) + def available_money(self) -> float: return float(self._post("/api/money/available", {"account": self.account_type}).get("available_money", 0)) + def buy(self, stock: str, price: float, volume: int, pr_type: int = 0): return self._order("/api/order/buy", stock, price, volume, pr_type) + def sell(self, stock: str, price: float, volume: int, pr_type: int = 0): return self._order("/api/order/sell", stock, price, volume, pr_type) + + def _order(self, path, stock, price, volume, pr_type): + body = {"stock": stock, "price": price, "volume": volume} + if pr_type: body["prType"] = pr_type + return self._post(path, body) + + def order_status_list(self): return self._post("/api/order/status", {"account": self.account_type}).get("orders", []) + def cancel_all(self): return self._post("/api/order/cancel_all", {"account": self.account_type}) + def cancel_by_rule(self, stock: str, volume: int): return self._post("/api/order/cancel_order", {"stock": stock, "volume": volume, "account": self.account_type}) + def deals(self): return self._post("/api/order/deal", {"account": self.account_type}).get("deals", []) diff --git a/py-client/sdk/client.py b/py-client/sdk/client.py new file mode 100644 index 0000000..8d9dfcb --- /dev/null +++ b/py-client/sdk/client.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +import json +from dataclasses import asdict, is_dataclass +from typing import Any +from urllib.error import HTTPError +from urllib.request import Request, urlopen + +from .errors import APIError, BusinessError + + +def csv_join(items: list[str]) -> str: + return ",".join(item.strip() for item in items if item.strip()) + + +class Client: + def __init__(self, base_url: str, token: str, timeout: float = 15.0) -> None: + self.base_url = base_url.rstrip("/") + self.token = token + self.timeout = timeout if timeout > 0 else 15.0 + self.account_type = "stock" + + def set_account_type(self, account_type: str) -> "Client": + if account_type.strip(): + self.account_type = account_type + return self + + def _request(self, method: str, path: str, body: Any = None) -> Any: + data = None + headers = {"X-Token": self.token, "Accept": "application/json"} + if method != "GET": + if body is None: body = {} + if is_dataclass(body): body = asdict(body) + data = json.dumps(body, ensure_ascii=False).encode() + headers["Content-Type"] = "application/json" + request = Request(self.base_url + path, data=data, headers=headers, method=method) + try: + with urlopen(request, timeout=self.timeout) as response: + raw = response.read() + except HTTPError as exc: + raw = exc.read() + try: message = json.loads(raw).get("error", raw.decode(errors="replace")) + except (ValueError, AttributeError): message = raw.decode(errors="replace").strip() + raise APIError(exc.code, str(message)) from exc + if not raw: return None + try: return json.loads(raw) + except ValueError as exc: raise ValueError(f"invalid JSON from {path}: {raw[:512]!r}") from exc + + def _get(self, path: str) -> Any: return self._request("GET", path) + def _post(self, path: str, body: Any = None) -> Any: return self._request("POST", path, body) + + def _get_field(self, path: str, key: str) -> Any: + return self._get(path).get(key) + + def _post_field(self, path: str, body: Any, key: str) -> Any: + result = self._post(path, body) + if isinstance(result, dict) and result.get("error"): + raise BusinessError(result["error"]) + return result.get(key, result) if key and isinstance(result, dict) else result diff --git a/py-client/sdk/data.py b/py-client/sdk/data.py new file mode 100644 index 0000000..e454300 --- /dev/null +++ b/py-client/sdk/data.py @@ -0,0 +1,83 @@ +from dataclasses import asdict +from typing import Any + +from .client import csv_join +from .models import * + + +class DataMixin: + def _one(self, endpoint, arg, value, key): return self._post_field(f"/api/data/{endpoint}", {arg: value}, key) + + def stock_name(self, code): return self._one("stock_name", "stockcode", code, "name") + def open_date(self, code): return self._one("open_date", "stockcode", code, "open_date") + def last_volume(self, code): return self._one("last_volume", "stockcode", code, "last_volume") + def bar_timetag(self, index): return self._one("bar_timetag", "index", index, "timetag") + def tick_timetag(self): return self._get_field("/api/data/tick_timetag", "timetag") + def sector(self, sector, realtime): return self._post("/api/data/sector", {"sector": sector, "realtime": realtime}).get("stocks", []) + def industry(self, industry): return self._post("/api/data/industry", {"industry": industry}).get("stocks", []) + def stock_list_in_sector(self, name): return self._post("/api/data/stock_list_in_sector", {"sectorname": name}).get("stocks", []) + def weight_in_index(self, indexcode, stockcode): return self._post_field("/api/data/weight_in_index", locals_body(indexcode=indexcode, stockcode=stockcode), "weight") + def contract_multiplier(self, code): return self._one("contract_multiplier", "contractcode", code, "multiplier") + def risk_free_rate(self, index): return self._one("risk_free_rate", "index", index, "risk_free_rate") + def date_location(self, date): return self._one("date_location", "strdate", date, "location") + + def history_data(self, req: HistoryDataRequest): + return self._post_field("/api/data/history_data", {"len": req.length or 10, "period": req.period, "field": req.field, "dividend_type": req.dividend_type, "skip_paused": str(req.skip_paused).lower()}, "data") + def _market_body(self, req): return {"fields": csv_join(req.fields), "stock_code": csv_join(req.stocks), "start_time": req.start_time, "end_time": req.end_time, "period": req.period, "dividend_type": req.dividend_type, "count": req.count} + def market_data(self, req): return self._post_field("/api/data/market_data", self._market_body(req), "data") + def market_data_ex(self, req): return self._post_field("/api/data/market_data_ex", self._market_body(req), "data") + + def full_tick(self, stocks): + raw = self._post("/api/data/full_tick", {"stocks": stocks}) or {} + def number(data, *names): + for name in names: + try: return float(data[name]) + except (KeyError, TypeError, ValueError): pass + return 0.0 + return {code: Tick(number(value, "lastPrice", "last_price", "LastPrice"), number(value, "lastClose", "last_close", "LastClose"), value if isinstance(value, dict) else {}) for code, value in raw.items()} + + def divid_factors(self, code): return self._one("divid_factors", "stockcode", code, "factors") + def main_contract(self, code): return self._one("main_contract", "codemarket", code, "main_contract") + def timetag_to_datetime(self, timetag, format=""): + body = {"timetag": timetag} + if format: body["format"] = format + return self._post_field("/api/data/timetag_to_datetime", body, "datetime") + def total_share(self, code): return self._one("total_share", "stockcode", code, "total_share") + def trading_dates(self, stockcode, start_date, end_date, period, count=0): + body = locals_body(stockcode=stockcode, start_date=start_date, end_date=end_date, period=period) + if count: body["count"] = count + return self._post("/api/data/trading_dates", body).get("dates", []) + def svol(self, code): return self._one("svol", "stockcode", code, "svol") + def bvol(self, code): return self._one("bvol", "stockcode", code, "bvol") + def longhubang(self, stocks, start, end): return self._post_field("/api/data/longhubang", {"stock_list": csv_join(stocks), "startTime": start, "endTime": end}, "data") + def top10_share_holder(self, stocks, name, start, end): return self._post_field("/api/data/top10_share_holder", {"stock_list": csv_join(stocks), "data_name": name, "start_time": start, "end_time": end}, "data") + def option_detail(self, code): return self._one("option_detail", "optioncode", code, "detail") + def turnover_rate(self, stocks, start, end): return self._post_field("/api/data/turnover_rate", {"stock_list": csv_join(stocks), "startTime": start, "endTime": end}, "data") + def etf_info(self, code): return self._one("etf_info", "stockcode", code, "info") + def etf_iopv(self, code): return self._one("etf_iopv", "stockcode", code, "iopv") + def instrument_detail(self, code): return self._one("instrumentdetail", "stockcode", code, "detail") + def contract_expire_date(self, code): return self._one("contract_expire_date", "codemarket", code, "expire_date") + def option_undl_data(self, code): return self._one("option_undl_data", "undl_code_ref", code, "data") + + def financial_data(self, req): + return self._post_field("/api/data/financial_data", {"tabname": req.tabname, "colname": req.colname, "market": req.market, "code": req.code, "report_type": req.report_type, "barpos": req.barpos, "fieldList": csv_join(req.field_list), "stockList": csv_join(req.stock_list), "startDate": req.start_date, "endDate": req.end_date}, "data") + def factor_data(self, req): return self._post_field("/api/data/factor_data", {"fieldList": csv_join(req.field_list), "stockList": csv_join(req.stock_list), "stockCode": req.stock_code, "startDate": req.start_date, "endDate": req.end_date}, "data") + def his_st_data(self, code): return self._one("his_st_data", "stockCode", code, "data") + def his_index_data(self, index): return self._one("his_index_data", "index", index, "data") + def all_subscription(self): return self._get_field("/api/data/all_subscription", "subscriptions") + def option_list(self, code, dedate, opttype, available): return self._post_field("/api/data/option_list", {"undl_code": code, "dedate": dedate, "opttype": opttype, "isavailable": str(available).lower()}, "option_list") + def his_contract_list(self, market): return self._one("his_contract_list", "market", market, "contracts") + def option_iv(self, code): return self._one("option_iv", "optioncode", code, "iv") + def bsm_price(self, req): + prices = ",".join(str(v) for v in req.object_prices) if isinstance(req.object_prices, list) else req.object_prices + return self._post_field("/api/data/bsm_price", {"optionType": req.option_type, "objectPrices": prices, "strikePrice": req.strike_price, "riskFree": req.risk_free, "sigma": req.sigma, "days": req.days, "dividend": req.dividend}, "price") + def bsm_iv(self, req): return self._post_field("/api/data/bsm_iv", camel_request(req), "iv") + def local_data(self, req): return self._post_field("/api/data/local_data", {"stock_code": req.stock_code, "start_time": req.start_time, "end_time": req.end_time, "period": req.period, "divid_type": req.divid_type, "count": req.count}, "data") + def subscribe_quote(self, code, period, dividend_type): return self._post("/api/data/subscribe_quote", {"stock_code": code, "period": period, "dividend_type": dividend_type}) + def unsubscribe_quote(self, sub_id): return self._post("/api/data/unsubscribe_quote", {"sub_id": sub_id}) + + +def locals_body(**kwargs): return kwargs +def camel_request(req): + data = asdict(req) + return {"optionType": data["option_type"], "objectPrices": data["object_prices"], "strikePrice": data["strike_price"], "optionPrice": data["option_price"], "riskFree": data["risk_free"], "days": data["days"], "dividend": data["dividend"]} diff --git a/py-client/sdk/errors.py b/py-client/sdk/errors.py new file mode 100644 index 0000000..8d7dfdb --- /dev/null +++ b/py-client/sdk/errors.py @@ -0,0 +1,14 @@ +class APIError(RuntimeError): + def __init__(self, status_code: int, message: str = "") -> None: + self.status_code = status_code + self.message = message + text = f"qmt api: http {status_code}" + super().__init__(f"{text}: {message}" if message else text) + + @property + def unauthorized(self) -> bool: + return self.status_code == 401 + + +class BusinessError(RuntimeError): + pass diff --git a/py-client/sdk/misc.py b/py-client/sdk/misc.py new file mode 100644 index 0000000..e3c4969 --- /dev/null +++ b/py-client/sdk/misc.py @@ -0,0 +1,31 @@ +from typing import Any + + +class MiscMixin: + def context_period(self): return self._get_field("/api/context/period", "period") + def context_barpos(self): return self._get_field("/api/context/barpos", "barpos") + def context_time_tick_size(self): return self._get_field("/api/context/time_tick_size", "time_tick_size") + def context_stockcode(self): return self._get_field("/api/context/stockcode", "stockcode") + def context_dividend_type(self): return self._get_field("/api/context/dividend_type", "dividend_type") + def context_market(self): return self._get_field("/api/context/market", "market") + def context_do_back_test(self): return self._get_field("/api/context/do_back_test", "do_back_test") + def context_benchmark(self): return self._get_field("/api/context/benchmark", "benchmark") + def context_capital(self): return self._get_field("/api/context/capital", "capital") + def context_universe(self): + value = self._get_field("/api/context/universe", "universe") + if value is None: return [] + return [str(v) for v in value if str(v)] if isinstance(value, list) else [str(value)] + + def is_last_bar(self): return self._get_field("/api/check/is_last_bar", "is_last_bar") + def is_new_bar(self): return self._get_field("/api/check/is_new_bar", "is_new_bar") + def is_suspended_stock(self, stockcode): return self._post_field("/api/check/is_suspended_stock", {"stockcode": stockcode}, "is_suspended") + def is_sector_stock(self, sectorname, market, stockcode): return self._post_field("/api/check/is_sector_stock", {"sectorname": sectorname, "market": market, "stockcode": stockcode}, "is_in_sector") + def is_typed_stock(self, stocktypenum, market, stockcode): return self._post_field("/api/check/is_typed_stock", {"stocktypenum": stocktypenum, "market": market, "stockcode": stockcode}, "result") + def industry_name_of_stock(self, industry_type, stockcode): return self._post_field("/api/check/get_industry_name_of_stock", {"industryType": industry_type, "stockcode": stockcode}, "industry_name") + + def ext_data(self, name, stockcode, deviation): return self._post_field("/api/ext/ext_data", {"extdataname": name, "stockcode": stockcode, "deviation": deviation}, "value") + def ext_data_rank(self, name, stockcode, deviation): return self._post_field("/api/ext/ext_data_rank", {"extdataname": name, "stockcode": stockcode, "deviation": deviation}, "rank") + def get_factor_value(self, name, stockcode, deviation): return self._post_field("/api/ext/get_factor_value", {"factorname": name, "stockcode": stockcode, "deviation": deviation}, "value") + def get_factor_rank(self, name, stockcode, deviation): return self._post_field("/api/ext/get_factor_rank", {"factorname": name, "stockcode": stockcode, "deviation": deviation}, "rank") + def python_version(self): return self._get("/api/sys/python_version") + def shutdown(self): return self._post("/api/sys/shutdown", {}) diff --git a/py-client/sdk/models.py b/py-client/sdk/models.py new file mode 100644 index 0000000..9ae8e8b --- /dev/null +++ b/py-client/sdk/models.py @@ -0,0 +1,106 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + + +def _number(value: Any, kind: type = float) -> Any: + try: + return kind(value or 0) + except (TypeError, ValueError): + return kind() + + +@dataclass +class Position: + stock_code: str = "" + stock_name: str = "" + direction: Any = None + volume: int = 0 + open_price: float = 0.0 + float_profit: float = 0.0 + market_value: float = 0.0 + stock_holder: str = "" + frozen_volume: int = 0 + can_use_volume: int = 0 + on_road_volume: int = 0 + yesterday_volume: int = 0 + last_price: float = 0.0 + profit_rate: float = 0.0 + future_trade_type: Any = None + expire_date: str = "" + + @classmethod + def from_dict(cls, data: dict[str, Any], code: str = "") -> "Position": + return cls( + stock_code=str(data.get("StockCode") or code), stock_name=str(data.get("StockName") or ""), + direction=data.get("Direction"), volume=_number(data.get("Volume"), int), + open_price=_number(data.get("OpenPrice")), float_profit=_number(data.get("FloatProfit")), + market_value=_number(data.get("MarketValue")), stock_holder=str(data.get("StockHolder") or ""), + frozen_volume=_number(data.get("FrozenVolume"), int), can_use_volume=_number(data.get("CanUseVolume"), int), + on_road_volume=_number(data.get("OnRoadVolume"), int), yesterday_volume=_number(data.get("YesterdayVolume"), int), + last_price=_number(data.get("LastPrice")), profit_rate=_number(data.get("ProfitRate")), + future_trade_type=data.get("FutureTradeType"), expire_date=str(data.get("ExpireDate") or ""), + ) + + +@dataclass +class Assets: + total: float = 0.0 + available: float = 0.0 + + +@dataclass +class Tick: + last_price: float = 0.0 + last_close: float = 0.0 + raw: dict[str, Any] = field(default_factory=dict) + + +@dataclass +class HistoryDataRequest: + length: int = 10 + period: str = "" + field: str = "" + dividend_type: int = 0 + skip_paused: bool = True + + +@dataclass +class MarketDataRequest: + fields: list[str] = field(default_factory=list) + stocks: list[str] = field(default_factory=list) + start_time: str = "" + end_time: str = "" + period: str = "" + dividend_type: str = "" + count: int = 0 + + +@dataclass +class FinancialDataRequest: + tabname: str = ""; colname: str = ""; market: str = ""; code: str = "" + report_type: str = ""; barpos: int = 0 + field_list: list[str] = field(default_factory=list); stock_list: list[str] = field(default_factory=list) + start_date: str = ""; end_date: str = "" + + +@dataclass +class FactorDataRequest: + field_list: list[str] = field(default_factory=list); stock_list: list[str] = field(default_factory=list) + stock_code: str = ""; start_date: str = ""; end_date: str = "" + + +@dataclass +class BSMPriceRequest: + option_type: str; object_prices: Any; strike_price: float; risk_free: float; sigma: float; days: int; dividend: float + + +@dataclass +class BSMIVRequest: + option_type: str; object_prices: float; strike_price: float; option_price: float; risk_free: float; days: int; dividend: float + + +@dataclass +class LocalDataRequest: + stock_code: str; start_time: str = ""; end_time: str = ""; period: str = ""; divid_type: str = ""; count: int = 0 diff --git a/py-client/sdk/trade.py b/py-client/sdk/trade.py new file mode 100644 index 0000000..3bb1aa1 --- /dev/null +++ b/py-client/sdk/trade.py @@ -0,0 +1,54 @@ +from typing import Any + +OP_BUY, OP_SELL = 23, 24 +ORDER_TYPE_VOLUME, PR_TYPE_LATEST, QUICK_TRADE_NOW = 1101, 5, 2 + + +class TradeMixin: + account_type: str + + def passorder(self, op_type, stock, volume, order_type=0, pr_type=0, price=0, quick_trade=0, strategy_name=""): + body = {"opType": op_type, "stock": stock, "price": price, "volume": volume} + for key, value in (("orderType", order_type), ("prType", pr_type), ("quickTrade", quick_trade), ("strategyName", strategy_name)): + if value: body[key] = value + return self._post("/api/trade/passorder", body) + + def passorder_latest(self, side, stock, volume): return self.passorder_latest_tagged(side, stock, volume, "") + def passorder_latest_tagged(self, side, stock, volume, order_id): + return self.passorder(side, stock, volume, ORDER_TYPE_VOLUME, PR_TYPE_LATEST, -1, QUICK_TRADE_NOW, order_id) + + def algo_passorder(self, **kwargs): return self._post("/api/trade/algo_passorder", kwargs) + def smart_algo_passorder(self, **kwargs): return self._post("/api/trade/smart_algo_passorder", kwargs) + + def _style_order(self, path, stock, value_key, value, style, price): + return self._post(path, {"stock": stock, value_key: value, "style": style, "price": price}) + def order_lots(self, stock, lots, style, price): return self._style_order("/api/trade/order_lots", stock, "lots", lots, style, price) + def order_value(self, stock, value, style, price): return self._style_order("/api/trade/order_value", stock, "value", value, style, price) + def order_percent(self, stock, percent, style, price): return self._style_order("/api/trade/order_percent", stock, "percent", percent, style, price) + def order_target_value(self, stock, value, style, price): return self._style_order("/api/trade/order_target_value", stock, "tar_value", value, style, price) + def order_target_percent(self, stock, percent, style, price): return self._style_order("/api/trade/order_target_percent", stock, "tar_percent", percent, style, price) + def order_shares(self, stock, shares, style, price): return self._style_order("/api/trade/order_shares", stock, "shares", shares, style, price) + + def _future(self, action, stock, amount, style, price): return self._style_order(f"/api/trade/futures/{action}", stock, "amount", amount, style, price) + def futures_buy_open(self, *args): return self._future("buy_open", *args) + def futures_buy_close_tdayfirst(self, *args): return self._future("buy_close_tdayfirst", *args) + def futures_buy_close_ydayfirst(self, *args): return self._future("buy_close_ydayfirst", *args) + def futures_sell_open(self, *args): return self._future("sell_open", *args) + def futures_sell_close_tdayfirst(self, *args): return self._future("sell_close_tdayfirst", *args) + def futures_sell_close_ydayfirst(self, *args): return self._future("sell_close_ydayfirst", *args) + + def _task(self, action, task_id): return self._post(f"/api/trade/{action}_task", {"taskId": task_id, "accountType": self.account_type}) + def cancel_task(self, task_id): return self._task("cancel", task_id) + def pause_task(self, task_id): return self._task("pause", task_id) + def resume_task(self, task_id): return self._task("resume", task_id) + def do_order(self): return self._post("/api/trade/do_order") + def trade_detail_data(self, datatype): return self._post("/api/trade/trade_detail_data", {"account": self.account_type, "datatype": datatype}).get("data", []) + def value_by_order_id(self, order_id, datatype): return self._post("/api/trade/value_by_order_id", {"orderId": order_id, "accountType": self.account_type, "datatype": datatype}).get("data") + def last_order_id(self, datatype): return self._post("/api/trade/last_order_id", {"account": self.account_type, "datatype": datatype}).get("last_order_id") + def can_cancel_order(self, order_id): return self._post("/api/trade/can_cancel_order", {"orderId": order_id, "accountType": self.account_type}).get("can_cancel") + def debt_contract(self): return self._contract("debt_contract") + def assure_contract(self): return self._contract("assure_contract") + def enable_short_contract(self): return self._contract("enable_short_contract") + def _contract(self, name): return self._post(f"/api/trade/{name}").get("data", []) + def ipo_data(self, typ): return self._post_field("/api/trade/ipo_data", {"type": typ}, "data") + def new_purchase_limit(self): return self._post_field("/api/trade/new_purchase_limit", None, "data") diff --git a/py-client/strategy/__init__.py b/py-client/strategy/__init__.py new file mode 100644 index 0000000..0622d5d --- /dev/null +++ b/py-client/strategy/__init__.py @@ -0,0 +1,5 @@ +"""交易策略启动入口。""" + +from .trend.boot import StartTrend + +__all__ = ["StartTrend"] diff --git a/py-client/strategy/__pycache__/__init__.cpython-311.pyc b/py-client/strategy/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..4b66374 Binary files /dev/null and b/py-client/strategy/__pycache__/__init__.cpython-311.pyc differ diff --git a/py-client/strategy/__pycache__/boot.cpython-311.pyc b/py-client/strategy/__pycache__/boot.cpython-311.pyc new file mode 100644 index 0000000..156af06 Binary files /dev/null and b/py-client/strategy/__pycache__/boot.cpython-311.pyc differ diff --git a/py-client/strategy/trend/__init__.py b/py-client/strategy/trend/__init__.py new file mode 100644 index 0000000..7556f2d --- /dev/null +++ b/py-client/strategy/trend/__init__.py @@ -0,0 +1,5 @@ +from .order import OrderBook, PlaceOrderRequest +from .state import State, StateItem +from .watch import DipWatch +from .open import check_timezone, open_signal +from .positions import manage_positions diff --git a/py-client/strategy/trend/__pycache__/__init__.cpython-311.pyc b/py-client/strategy/trend/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..c4e359b Binary files /dev/null and b/py-client/strategy/trend/__pycache__/__init__.cpython-311.pyc differ diff --git a/py-client/strategy/trend/__pycache__/boot.cpython-311.pyc b/py-client/strategy/trend/__pycache__/boot.cpython-311.pyc new file mode 100644 index 0000000..b79c5af Binary files /dev/null and b/py-client/strategy/trend/__pycache__/boot.cpython-311.pyc differ diff --git a/py-client/strategy/trend/__pycache__/open.cpython-311.pyc b/py-client/strategy/trend/__pycache__/open.cpython-311.pyc new file mode 100644 index 0000000..a2f444c Binary files /dev/null and b/py-client/strategy/trend/__pycache__/open.cpython-311.pyc differ diff --git a/py-client/strategy/trend/__pycache__/order.cpython-311.pyc b/py-client/strategy/trend/__pycache__/order.cpython-311.pyc new file mode 100644 index 0000000..04df09c Binary files /dev/null and b/py-client/strategy/trend/__pycache__/order.cpython-311.pyc differ diff --git a/py-client/strategy/trend/__pycache__/positions.cpython-311.pyc b/py-client/strategy/trend/__pycache__/positions.cpython-311.pyc new file mode 100644 index 0000000..b06d08c Binary files /dev/null and b/py-client/strategy/trend/__pycache__/positions.cpython-311.pyc differ diff --git a/py-client/strategy/trend/__pycache__/run.cpython-311.pyc b/py-client/strategy/trend/__pycache__/run.cpython-311.pyc new file mode 100644 index 0000000..5660eee Binary files /dev/null and b/py-client/strategy/trend/__pycache__/run.cpython-311.pyc differ diff --git a/py-client/strategy/trend/__pycache__/runtime.cpython-311.pyc b/py-client/strategy/trend/__pycache__/runtime.cpython-311.pyc new file mode 100644 index 0000000..71d8f32 Binary files /dev/null and b/py-client/strategy/trend/__pycache__/runtime.cpython-311.pyc differ diff --git a/py-client/strategy/trend/__pycache__/state.cpython-311.pyc b/py-client/strategy/trend/__pycache__/state.cpython-311.pyc new file mode 100644 index 0000000..f92ba39 Binary files /dev/null and b/py-client/strategy/trend/__pycache__/state.cpython-311.pyc differ diff --git a/py-client/strategy/trend/__pycache__/strategy.cpython-311.pyc b/py-client/strategy/trend/__pycache__/strategy.cpython-311.pyc new file mode 100644 index 0000000..ae3a455 Binary files /dev/null and b/py-client/strategy/trend/__pycache__/strategy.cpython-311.pyc differ diff --git a/py-client/strategy/trend/__pycache__/watch.cpython-311.pyc b/py-client/strategy/trend/__pycache__/watch.cpython-311.pyc new file mode 100644 index 0000000..87c9838 Binary files /dev/null and b/py-client/strategy/trend/__pycache__/watch.cpython-311.pyc differ diff --git a/py-client/strategy/trend/boot.py b/py-client/strategy/trend/boot.py new file mode 100644 index 0000000..15566e8 --- /dev/null +++ b/py-client/strategy/trend/boot.py @@ -0,0 +1,176 @@ +"""趋势策略启动器。 + +该模块负责组合 SDK、配置、状态存储和趋势策略组件,供 main.py 调用。 +""" + +from __future__ import annotations + +import logging +import time +from datetime import datetime + +import config +from libs import init_signals, market_allow_open, trading_time +from sdk import Client +from .state import State +from .order import OrderBook +from .watch import DipWatch +from .runtime import Runtime +from .open import open_signal +from .positions import manage_positions + + +def Overview(assets, positions, account_cfg=None) -> None: + """打印策略启动时的账户、资金和持仓概览。 + + 该函数对应 Go 客户端 ``logic.Overview``。为便于单独测试,可以 + 显式传入账户配置;未传入时使用 ``config.account_config``。 + """ + account_cfg = account_cfg or config.account_config + + print("\n" + "=" * 80) + print(f"【时间】{datetime.now():%Y-%m-%d %H:%M:%S}") + if account_cfg is not None: + print( + "【配置】" + f"account_id: {account_cfg.account_id} " + f"host_key: {account_cfg.host_key} " + f"buy_value: {account_cfg.buy_value:.0f}" + ) + + if assets is not None: + print( + f"【资金】总资产:{assets.total:.2f}元," + f"可用资金:{assets.available:.2f}元" + ) + else: + print("【资金】查询失败") + + print(f"【持仓】{len(positions)}只") + print("=" * 80) + for position in positions: + if position.volume <= 0: + continue + print( + f"【持仓】{position.stock_code} {position.stock_name} " + f"持仓={position.volume} 可用={position.can_use_volume} " + f"冻结={position.frozen_volume} 在途={position.on_road_volume} " + f"昨仓={position.yesterday_volume} 成本={position.open_price:.3f} " + f"现价={position.last_price:.3f} 市值={position.market_value:.2f} " + f"浮盈={position.float_profit:.2f} " + f"盈亏比例={position.profit_rate * 100:.2f}%" + ) + + + +def StartTrend() -> None: + """初始化趋势策略,并以 30 秒间隔持续执行。""" + client = Client( + config.global_config.qmt_base_url, + config.global_config.qmt_token, + config.HTTP_TIMEOUT, + ) + assets = client.assets() + _, positions = client.positions() + + storeState = State.for_strategy( + config.global_config.qmt_data_dir, + config.account_config.strategy, + config.account_config.account_id, + ) + storeState.sync_positions(positions) + + # 获取本策略的信号开仓数据 + signals = init_signals(config.global_config,["morning","tail","arbitrage"]) + run = Runtime( + client=client, + global_cfg=config.global_config, + account_cfg=config.account_config, + state=storeState, + orders=OrderBook(), + open_watch=DipWatch(), + add_watch=DipWatch(), + ) + + logging.info( + "趋势策略启动:总资产=%.2f,持仓=%d,信号=%d", + assets.total, + len(positions), + len(signals), + ) + Overview(assets, positions, config.account_config) + + while True: + started_at = time.monotonic() + try: + RunOnce(run, signals) + except Exception: + # 单轮错误只记录日志,下一轮仍继续运行。 + logging.exception("趋势策略本轮执行失败") + + elapsed = time.monotonic() - started_at + time.sleep(max(0.0, 30.0 - elapsed)) + + +def RunOnce(run: Runtime, signals) -> None: + """按固定步骤执行一轮趋势策略, ``RunOnce``。""" + if not trading_time(datetime.now()): + return + + # 1. 取消超过有效期仍未完成的委托订单。 + try: + run.orders.cancel_expired(run.client) + except Exception: + logging.exception("取消过期订单失败") + + # 2. 验证可用资金;低于资金安全线时禁止开新仓。 + try: + assets = run.client.assets() + except Exception: + logging.exception("获取资产失败") + return + if assets.available < assets.total * run.account_cfg.min_cash_ratio: + logging.info("资金总闸:可用金额太少,禁止开新仓") + return + + # 3. 获取大盘状态,只有大盘信号允许时才执行开仓。 + market_ok = market_allow_open(run.global_cfg.api_host) + + # 4. 获取当前持仓及持仓证券代码。 + try: + position_codes, positions = run.client.positions() + except Exception: + logging.exception("获取持仓失败") + return + + # 5. 验证有效开仓信号:排除已有持仓,并按 signal_allow 过滤。 + position_code_set = set(position_codes) + allow_open = [ + signal for signal in signals if signal.code not in position_code_set + ] + + # 6. 获取持仓和待开仓证券的实时行情 tick。 + all_codes = list(position_codes) + all_codes.extend( + signal.code for signal in allow_open if signal.code not in position_code_set + ) + try: + ticks = run.client.full_tick(list(dict.fromkeys(all_codes))) + except Exception: + logging.exception("获取行情失败") + return + + # 7. 执行开仓:必须同时存在有效信号且大盘允许开仓。 + if allow_open and market_ok: + open_signal(run, ticks, allow_open) + + # 8. 持仓计算。当前 Go 版本的 managePositions 为空,保留扩展入口。 + manage_positions(run, ticks, positions, market_ok,assets.available) + + +def SignalFilter(signals, allowed_names): + """只保留账户配置明确允许使用的信号。""" + if not allowed_names: + return [] + allowed = set(allowed_names) + return [signal for signal in signals if signal.signal_key in allowed] diff --git a/py-client/strategy/trend/open.py b/py-client/strategy/trend/open.py new file mode 100644 index 0000000..485c187 --- /dev/null +++ b/py-client/strategy/trend/open.py @@ -0,0 +1,105 @@ +"""趋势策略开仓逻辑,对应 Go 版本的 ``logic/open.go``。""" + +from __future__ import annotations + +import logging +from datetime import datetime + +from libs import calc_buy_volume +from sdk import OP_BUY + +from .order import PlaceOrderRequest +from .state import STATUS_ING, StateItem + + +def open_signal(run, ticks, open_signals) -> None: + """逐个验证开仓信号并提交买入委托。""" + for item in open_signals: + # 1. 验证信号配置允许开仓的时间区间。 + signal_config = run.global_cfg.signals.get(item.signal_key) + if signal_config is None or not check_timezone(signal_config.timezone): + continue + + # 2. 检查该证券是否已有买入委托锁,防止重复下单。 + if run.orders.busy(item.code,"BUY"): + continue + + # 3. 验证行情和最新价格是否有效。 + tick = ticks.get(item.code) + price = tick.last_price if tick is not None else 0 + if price <= 0: + continue + + # 4. 等待价格从观察低点反弹,防止直接接下跌中的“飞刀”。 + if not run.open_watch.triggered("开仓", item.code, price): + continue + + # 5. 根据单笔买入金额计算整手开仓数量。 + volume = calc_buy_volume(price, run.account_cfg.buy_value) + if volume <= 0: + continue + + # 6. 生成本地订单号并按最新价提交开仓委托。 + order_id = run.orders.new_order_id("base") + request = PlaceOrderRequest(run.client, OP_BUY, item.code, volume, order_id) + if not run.orders.place(request): + continue + + # 7. 保存底仓订单、数量、成本和处理中状态。 + run.state.set( + StateItem( + code=item.code, + base_order_id=order_id, + base_qty=volume, + base_cost=price, + base_status=STATUS_ING, + ) + ) + try: + run.state.save() + except OSError: + logging.exception("[状态] %s 开仓状态保存失败", item.code) + run.open_watch.forget(item.code) + logging.info("[ZT][开仓] %s 买入 %d 股", item.code, volume) + + +def check_timezone(timezone: str, now: datetime | None = None) -> bool: + """验证当前时间是否处于配置区间。 + + ``*`` 表示全天允许;多个区间用逗号分隔,例如 + ``9:30-10:30,13:30-14:30``。同时支持跨午夜区间。 + """ + timezone = str(timezone or "").strip() + if timezone == "*": + return True + + current = now or datetime.now() + current_minutes = current.hour * 60 + current.minute + + for section in timezone.split(","): + bounds = section.strip().split("-") + if len(bounds) != 2: + continue + start = _parse_minutes(bounds[0]) + end = _parse_minutes(bounds[1]) + if start is None or end is None: + continue + + if start <= end and start <= current_minutes <= end: + return True + if start > end and (current_minutes >= start or current_minutes <= end): + return True + + return False + + +def _parse_minutes(value: str) -> int | None: + """把 ``时:分`` 转换为当天分钟数,无效值返回 None。""" + try: + hour_text, minute_text = value.strip().split(":") + hour, minute = int(hour_text), int(minute_text) + except (TypeError, ValueError): + return None + if not 0 <= hour <= 23 or not 0 <= minute <= 59: + return None + return hour * 60 + minute diff --git a/py-client/strategy/trend/order.py b/py-client/strategy/trend/order.py new file mode 100644 index 0000000..dcfccb3 --- /dev/null +++ b/py-client/strategy/trend/order.py @@ -0,0 +1,148 @@ +"""趋势策略委托簿,对应 Go 客户端的 ``logic/order.go``。""" + +from __future__ import annotations + +import secrets +from dataclasses import dataclass +from datetime import datetime, timedelta +from threading import Lock +from typing import Any + +# QMT 开平方向字段到本地买卖方向的映射。 +OFFSET_FLAG = {"23": "BUY", "24": "SELL", "48": "BUY", "49": "SELL"} + +# 表示委托仍在处理、可能继续成交的 QMT 状态。 +BUSY_STATUSES = {"48", "49", "50", "51", "52", "55"} + + +@dataclass(frozen=True) +class PlaceOrderRequest: + """``OrderBook.place`` 提交委托所需的全部参数。""" + + client: Any + op: int + code: str + volume: int + order_id: str + + +@dataclass +class OrderItem: + """从 QMT 委托明细转换得到的本地订单记录。""" + + id: str + code: str + side: str + remark: str + status: str + created_at: datetime | None + volume: int + + +class OrderBook: + """线程安全的活动委托缓存。""" + + def __init__(self, timeout_seconds: float = 300) -> None: + self.timeout = timedelta(seconds=timeout_seconds) + self.data: dict[str, OrderItem] = {} + self.index: list[str] = [] + self.lock = Lock() + + @staticmethod + def new_order_id(leg: str) -> str: + """生成不超过 24 个字符的策略订单号。""" + return f"zt-{leg}-{secrets.token_hex(6)}"[:24] + + def is_lock(self, side: str, code: str) -> bool: + """判断证券在指定买卖方向上是否已经被委托锁定。""" + with self.lock: + return f"{side}-{code}" in self.index + + def busy(self, code: str, side: str) -> bool: + """判断证券是否存在仍在处理中的同方向委托。""" + with self.lock: + order = self.data.get(f"{side}-{code}") + return bool(order and order.status in BUSY_STATUSES) + + def refresh(self, client: Any) -> None: + """从 QMT 刷新当前委托明细和方向索引。""" + parsed_orders = [ + parse_order(row) for row in client.trade_detail_data("order") + ] + with self.lock: + self.data = {key: item for key, item in parsed_orders} + self.index = [key for key, _ in parsed_orders] + + def cancel_expired(self, client: Any, now: datetime | None = None) -> None: + """尝试撤销超过有效期且具有委托编号的订单。""" + self.refresh(client) + current = now or datetime.now() + + # 使用快照遍历,避免网络调用期间长期持有互斥锁。 + for order in list(self.data.values()): + if ( + order.created_at is not None + and current - order.created_at > self.timeout + and order.id + ): + client.can_cancel_order(order.id) + + def place(self, request: PlaceOrderRequest) -> bool: + """按最新价提交委托,并立即写入本地方向锁。""" + request.client.passorder_latest_tagged( + request.op, + request.code, + request.volume, + request.order_id, + ) + + side = OFFSET_FLAG.get(str(request.op), "") + with self.lock: + self.index.append(f"{side}-{request.code}") + return True + + +def parse_order(row: dict[str, Any]) -> tuple[str, OrderItem]: + """把 QMT 原始委托字段转换为本地订单及其索引键。""" + volume = _as_int(row.get("m_nVolumeTotal")) + _as_int( + row.get("m_nVolumeTraded") + ) + + timestamp = _as_int(row.get("m_nOrderTime")) + if timestamp > 100_000_000_000: + # QMT 某些版本返回毫秒时间戳。 + timestamp /= 1000 + created_at = ( + datetime.fromtimestamp(timestamp) + if timestamp + else _parse_insert_datetime(row) + ) + + item = OrderItem( + id=str(row.get("m_strOrderSysID") or ""), + code=str(row.get("m_strInstrumentID") or ""), + side=OFFSET_FLAG.get(str(row.get("m_nOffsetFlag")), ""), + remark=str(row.get("m_strRemark") or ""), + status=str(row.get("m_nOrderStatus") or ""), + created_at=created_at, + volume=volume, + ) + return f"{item.side}-{item.code}", item + + +def _as_int(value: Any) -> int: + """安全转换整数,无效值按 0 处理。""" + try: + return int(value or 0) + except (TypeError, ValueError): + return 0 + + +def _parse_insert_datetime(row: dict[str, Any]) -> datetime | None: + """使用委托日期和时间字段构造本地时间。""" + date = str(row.get("m_strInsertDate") or "") + clock = str(row.get("m_strInsertTime") or "").replace(":", "").zfill(6) + try: + return datetime.strptime(date + clock, "%Y%m%d%H%M%S") + except ValueError: + return None diff --git a/py-client/strategy/trend/positions.py b/py-client/strategy/trend/positions.py new file mode 100644 index 0000000..d99f41a --- /dev/null +++ b/py-client/strategy/trend/positions.py @@ -0,0 +1,167 @@ +"""趋势策略持仓管理逻辑,对应 Go 版本的 ``logic/positions.go``。""" + +from __future__ import annotations + +import logging +from math import floor + +from libs.calc import calc_buy_volume,calculate_min_profit_rate +from libs.grid_take_profit import GridState, GridTrailingTracker +from sdk import OP_BUY, OP_SELL +import config +from .order import PlaceOrderRequest +from .state import STATUS_ING, STATUS_NONE, STATUS_OK +from .runtime import Runtime + +LEG_BASE = "base" +LEG_ADDED = "add" + +# 止盈网格跟踪器延迟初始化,避免导入模块时账户配置尚未加载。 +profit_tracker = None + +# 分级补仓档位(百分比) +LOSS_TIERS = [-30, -50] +# 补仓反弹确认阈值(百分比) +LOSS_REBOUND_THRESHOLD = 0.5 + +def manage_positions(run:Runtime, ticks, positions, market_ok: bool,available:float) -> None: + """执行持仓计算。""" + logging.info(f"持仓:{len(positions)} 支股票,开始处理") + global profit_tracker + profit_tracker = GridTrailingTracker(step=run.account_cfg.grid_step_pct) + for idx,pos in positions: + code = pos['stock_code'] + avg_price = pos.get('avg_price', 0) + volume = pos.get('volume', 0) + can_use_volume = pos.get('can_use_volume', 0) + current_price = ticks.get(code, {}).get('lastPrice', 0) + strategy_name = pos.get('strategy_name', '') + market_value = pos.get('market_value',0) + profit = pos.get('profit_rate', 0) + + # 排除指定股票 + if code in config.account_config.excluded_codes: + continue + + # 过滤无效仓位 + if avg_price == 0 or can_use_volume == 0 or current_price == 0 or volume == 0: + continue + + # 计算盈亏率(百分比) + pnl_ratio = (current_price - avg_price) / avg_price * 100 if avg_price != 0 else 0 + pnl_ratio = round(pnl_ratio, 2) + + # 计算最小利润率:1倍 + min_profit_rate_val = calculate_min_profit_rate(avg_price, 1) + + # 盈利处理 + is_closed, message = handle_profit(run,code,avg_price, pnl_ratio, min_profit_rate_val, can_use_volume, strategy_name) + if is_closed: + logging.info("profit", code, f"止盈执行 | {message}") + if message != "": + logging.info("profit", code, message) + + # 补仓处理 + if config.account_config.enable_loss_add_position and market_ok: + is_replenished, message = handle_loss(run,code,current_price,pnl_ratio,market_value,market_ok,available) + if is_replenished: + logging.info("loss", code, f"补仓执行 | {message}") + if message != "": + logging.info("loss", code, message) + +# 盈利处理 +def handle_profit(run:Runtime, code: str, pnl_rate: float, + min_profit_rate: float, vol: int) -> tuple[bool, str]: + """ + 盈利处理 - 基于网格的止盈策略 + + Args: + code: 股票代码 + open_price: 开仓价格 + pnl_rate: 当前盈亏率(百分比) + min_profit_rate: 最小利润率阈值 + vol: 可用股数 + strategy_name: str + + Returns: + tuple[bool, str]: (是否执行平仓, 操作说明) + """ + # 预检查:未达到最小利润率 + if pnl_rate < min_profit_rate: + return False, "" + + position_key = f"{run.account_cfg.account_id}:{code}" + observation = profit_tracker.observe(position_key, pnl_rate) + + if observation.state == GridState.ARMED: + msg = f"首次达到{pnl_rate}%,设置峰值网格{observation.current_grid}" + return False, msg + + if observation.state == GridState.RAISED: + return False, f"上涨至{pnl_rate}%,更新峰值网格{observation.current_grid}" + + # 执行平仓 + if observation.state == GridState.RETREAT: + order_id = run.orders.new_order_id(LEG_BASE) + request = PlaceOrderRequest(run.client, OP_SELL, code, vol, order_id) + result = run.orders.place(request) + if result : + success_msg = f"✓ 委托成功 | {vol}股 订单号:{result} 等待成交" + logging.info("profit", code, success_msg) + return True, success_msg + else: + fail_msg = f"止盈委托失败: {code}" + logging.error("profit", code, "✗ 止盈委托失败") + return False, fail_msg + + +def handle_loss(run:Runtime, code: str, current_price,pnl_rate,market_value: float,market_ok: bool, available: float) -> tuple[bool, str]: + """满足条件时提交补仓委托,并返回扣减后的剩余预算。""" + state = run.state.get(code) + added_num = state.get('added_num',0) + # 预检查:未达到最低补仓阈值 + if pnl_rate > LOSS_TIERS[added_num]: + return False, "" + + # 强制条件 + if current_price>200 or market_value>=60000: + return False, f"成本价{current_price}>200,仓位价值{market_value}>=60000, 不补仓" + + # 1. 大盘必须允许开仓,且价格已从观察低点达到反弹阈值。 + if not market_ok or not run.add_watch.triggered("补仓", code, current_price): + return False + + # 2. 计算补仓数量和预计占用金额。 + volume = calc_buy_volume(current_price, run.account_cfg.buy_value) + amount = current_price * volume + + # 3. 检查预算。 + if amount > available: + return False, f"f{code} f{amount} 仓位资金不够补仓" + + # 是否已有未完成的买入委托 + if run.orders.busy(run, code, "BUY"): + return False, f"{code}订单锁定中" + + # 4. 生成补仓订单号并提交买入委托。 + order_id = run.orders.new_order_id(LEG_ADDED) + request = PlaceOrderRequest(run.client, OP_BUY, code, volume, order_id) + result = run.orders.place(request) + if result : + state.added_num = +1 + state.added_status = run.state.STATUS_ING + state.added_order_id = order_id + run.state.set(state) + run.state.save() + run.add_watch.forget(code) + return True,f"补仓委托成功: {code} {volume}手, 等待成交确认" + else: + return False,f"补仓失败: {code}" + + +def forget(run, code: str) -> None: + """持仓退出后清理开仓、补仓观察记录和止盈峰值。""" + + + + run.peak_grids.pop(f"{code}|{LEG_ADDED}", None) diff --git a/py-client/strategy/trend/runtime.py b/py-client/strategy/trend/runtime.py new file mode 100644 index 0000000..1b7f98b --- /dev/null +++ b/py-client/strategy/trend/runtime.py @@ -0,0 +1,43 @@ +"""趋势策略单次运行所需的上下文对象。""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +from config import AccountConfig, GlobalConfig +from sdk import Client + +from .order import OrderBook +from .state import State +from .watch import DipWatch + + +@dataclass(slots=True) +class Runtime: + """集中保存趋势策略运行期间共享的依赖和状态。 + + 将这些对象集中到一个 dataclass 后,开仓、持仓管理和单轮调度函数 + 只需接收一个 ``Runtime``,无需重复传递大量参数。 + + Attributes: + client: QMT HTTP 客户端,用于查询账户、行情和提交委托。 + global_cfg: 公共配置,包含 QMT、外部 API 和信号配置。 + account_cfg: 当前主机的账户及交易策略配置。 + state: 策略持仓状态的本地持久化存储。 + orders: 当前活动委托和证券方向锁。 + open_watch: 新开仓使用的价格反弹观察器。 + add_watch: 亏损补仓使用的价格反弹观察器。 + peak_grids: ``证券代码|仓位类型`` 到最高盈利网格的映射。 + """ + + # 外部服务与账户配置。 + client: Client + global_cfg: GlobalConfig + account_cfg: AccountConfig + + # 策略运行过程中共享的状态组件。 + state: State + orders: OrderBook + open_watch: DipWatch + add_watch: DipWatch + diff --git a/py-client/strategy/trend/state.py b/py-client/strategy/trend/state.py new file mode 100644 index 0000000..263cad4 --- /dev/null +++ b/py-client/strategy/trend/state.py @@ -0,0 +1,146 @@ +"""趋势策略持仓状态的内存管理与 JSON 持久化。""" + +from __future__ import annotations + +import json +from dataclasses import asdict, dataclass +from pathlib import Path +from threading import Lock +from typing import Iterable + +from sdk import Position + + +# 委托状态:无操作、处理中、已完成。 +STATUS_NONE = "" +STATUS_ING = "ING" +STATUS_OK = "OK" + + +@dataclass(slots=True) +class StateItem: + """单只证券的底仓和补仓状态。""" + + # 证券代码。 + code: str + + # 底仓订单、数量、成本和处理状态。 + base_order_id: str = "" + base_qty: int = 0 + base_cost: float = 0.0 + base_status: str = STATUS_NONE + + # 补仓订单、补仓次数、数量、成本和处理状态。 + added_order_id: str = "" + added_num: int = 0 + added_qty: int = 0 + added_cost: float = 0.0 + added_status: str = STATUS_NONE + + +class State: + """线程安全的策略状态存储。 + + 状态以内存字典提供快速访问,并通过临时文件替换的方式写入 JSON, + 防止程序在写入过程中退出而破坏原状态文件。 + """ + + def __init__(self, path: str | Path) -> None: + self.path = Path(path) + self.lock = Lock() + self.items = self._load() + + @classmethod + def for_strategy( + cls, + data_dir: str | Path, + strategy: str, + account_id: str, + ) -> "State": + """根据数据目录、策略名称和账户生成独立状态文件。""" + state_path = Path(data_dir) / f"{strategy}_{account_id}_state.json" + return cls(state_path) + + @property + def codes(self) -> list[str]: + """返回当前已经接管的全部证券代码快照。""" + with self.lock: + return list(self.items) + + def get(self, code: str) -> StateItem: + """获取指定证券的状态;不存在时抛出 KeyError。""" + with self.lock: + return self.items[code] + + def set(self, item: StateItem) -> None: + """新增或覆盖一只证券的状态。""" + with self.lock: + self.items[item.code] = item + + def delete(self, code: str) -> None: + """删除证券状态;证券不存在时不报错。""" + with self.lock: + self.items.pop(code, None) + + def sync_positions(self, positions: Iterable[Position]) -> None: + """把尚未接管的真实持仓初始化为已完成底仓。 + + 无证券代码、无持仓数量或成本无效的记录会被忽略。同步结束后 + 立即保存,确保首次接管的持仓在程序重启后仍可恢复。 + """ + known_codes = set(self.codes) + for position in positions: + if ( + not position.stock_code + or position.volume <= 0 + or position.open_price <= 0 + or position.stock_code in known_codes + ): + continue + + self.set( + StateItem( + code=position.stock_code, + base_qty=position.volume, + base_cost=position.open_price, + base_status=STATUS_OK, + ) + ) + known_codes.add(position.stock_code) + + self.save() + + def save(self) -> None: + """将内存状态格式化写入 JSON,并原子替换正式文件。""" + with self.lock: + self.path.parent.mkdir(parents=True, exist_ok=True) + temporary_path = self.path.with_suffix(self.path.suffix + ".tmp") + payload = { + code: asdict(item) + for code, item in self.items.items() + } + temporary_path.write_text( + json.dumps(payload, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + temporary_path.replace(self.path) + + def _load(self) -> dict[str, StateItem]: + """读取已有状态文件;文件不存在时从空状态开始。""" + try: + raw = json.loads(self.path.read_text(encoding="utf-8")) + except FileNotFoundError: + return {} + except (OSError, json.JSONDecodeError) as exc: + raise ValueError(f"[状态] 读取或解析失败: {exc}") from exc + + if not isinstance(raw, dict): + raise ValueError("[状态] 状态文件根节点必须是 JSON 对象") + + try: + return { + code: StateItem(**item) + for code, item in raw.items() + } + except (TypeError, ValueError) as exc: + raise ValueError(f"[状态] 状态字段无效: {exc}") from exc diff --git a/py-client/strategy/trend/watch.py b/py-client/strategy/trend/watch.py new file mode 100644 index 0000000..95e0ed0 --- /dev/null +++ b/py-client/strategy/trend/watch.py @@ -0,0 +1,34 @@ +from dataclasses import dataclass +from datetime import datetime, timedelta +from threading import Lock +import logging + + +@dataclass +class _Entry: + last_close: float + expires_at: datetime + + +class DipWatch: + def __init__(self, expire_seconds: float = 300, rebound_threshold: float = 0.61): + self.expire_seconds, self.rebound_threshold = expire_seconds, rebound_threshold + self.data: dict[str, _Entry] = {}; self.lock = Lock() + + def triggered(self, tag: str, code: str, price: float, now: datetime | None = None) -> bool: + if price <= 0: return False + now = now or datetime.now() + with self.lock: + watch = self.data.get(code) + if watch is None or now >= watch.expires_at: + self.data[code] = _Entry(price, now + timedelta(seconds=self.expire_seconds)); return False + if price < watch.last_close: + self.data[code] = _Entry(price, now + timedelta(seconds=self.expire_seconds)); return False + rebound = (price - watch.last_close) / watch.last_close * 100 + if rebound <= 0 or rebound < self.rebound_threshold: return False + del self.data[code] + logging.info("[%s-触发] %s 反弹=%.2f%%", tag, code, rebound) + return True + + def forget(self, code): + with self.lock: self.data.pop(code, None) diff --git a/py-client/test.py b/py-client/test.py new file mode 100644 index 0000000..b878c21 --- /dev/null +++ b/py-client/test.py @@ -0,0 +1,25 @@ +import json +import os +from pathlib import Path + +from sdk import Client + +BASE_URL = "http://127.0.0.1:10086" +TOKEN = "QMTbyYanweidong" + + +def main(): + client = Client(BASE_URL, TOKEN).set_account_type("stock") + assets = client.assets(); + _, positions = client.positions() + print(f"总资产:{assets.total:.2f}元,可用资金:{assets.available:.2f}元") + for p in sorted(positions, key=lambda item: item.stock_code): + if p.volume > 0: print(f"{p.stock_code} {p.stock_name} 持仓={p.volume} 可用={p.can_use_volume} 成本={p.open_price:.3f} 现价={p.last_price:.3f}") + data_dir = os.environ.get("QMT_DATA_DIR", "").strip() + if not data_dir: raise SystemExit("环境变量 QMT_DATA_DIR 为空") + codes = json.loads((Path(data_dir) / "pass_codes.json").read_text(encoding="utf-8")) + for code, tick in sorted(client.full_tick(codes).items()): + print(f"{code} last={tick.last_price:.3f} close={tick.last_close:.3f}") + + +if __name__ == "__main__": main() diff --git a/run.bat b/run.bat new file mode 100644 index 0000000..b1cf477 --- /dev/null +++ b/run.bat @@ -0,0 +1,7 @@ +git pull +set PYTHONUTF8=1 +taskkill /IM collector.exe /F +taskkill /IM python.exe /F +start python main.py +start bin\collector.exe +exit diff --git a/setup.bat b/setup.bat new file mode 100644 index 0000000..2eb5f4d --- /dev/null +++ b/setup.bat @@ -0,0 +1,4 @@ +set PYTHONUTF8=1 +pip config set global.index-url https://mirrors.aliyun.com/pypi/simple +python.exe -m pip install --upgrade pip +pip install -r requirements.txt \ No newline at end of file