This commit is contained in:
2026-08-28 18:52:27 +08:00
parent e0ecdfba52
commit d09f271569
89 changed files with 1716 additions and 2515 deletions

Binary file not shown.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 ""
}

View File

@@ -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

View File

@@ -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 # 大于信号的昨收价是否开仓

View File

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

View File

@@ -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=

View File

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

View File

@@ -1,8 +0,0 @@
package libs
import "time"
var (
API_HOST = "http://139.224.247.176:13499"
HTTPTimeout = 5 * time.Second
)

View File

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

View File

@@ -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"
}
}

View File

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

View File

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

View File

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

View File

@@ -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]) + "..."
}

View File

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

View File

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

View File

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

View File

@@ -1,4 +0,0 @@
// Package sdk 是 QMT_API.py HTTP 服务的 Go 客户端。
//
// 用法sdk.New(baseURL, token, timeout),账户类型默认 stock资金账号由服务端环境变量决定。
package sdk

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

Binary file not shown.

View File

@@ -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

Binary file not shown.

View File

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

11
py-client/etc/dev.yaml Normal file
View File

@@ -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"

View File

@@ -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"]

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

32
py-client/libs/calc.py Normal file
View File

@@ -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%

View File

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

8
py-client/libs/http.py Normal file
View File

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

24
py-client/libs/market.py Normal file
View File

@@ -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"

29
py-client/libs/signal.py Normal file
View File

@@ -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

111
py-client/main.py Normal file
View File

@@ -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())

14
py-client/sdk/__init__.py Normal file
View File

@@ -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"]

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

33
py-client/sdk/account.py Normal file
View File

@@ -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", [])

59
py-client/sdk/client.py Normal file
View File

@@ -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

83
py-client/sdk/data.py Normal file
View File

@@ -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"]}

14
py-client/sdk/errors.py Normal file
View File

@@ -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

31
py-client/sdk/misc.py Normal file
View File

@@ -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", {})

106
py-client/sdk/models.py Normal file
View File

@@ -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

54
py-client/sdk/trade.py Normal file
View File

@@ -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")

View File

@@ -0,0 +1,5 @@
"""交易策略启动入口。"""
from .trend.boot import StartTrend
__all__ = ["StartTrend"]

Binary file not shown.

View File

@@ -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

View File

@@ -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]

View File

@@ -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

View File

@@ -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

View File

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

View File

@@ -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

View File

@@ -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

View File

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

25
py-client/test.py Normal file
View File

@@ -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()

7
run.bat Normal file
View File

@@ -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

4
setup.bat Normal file
View File

@@ -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