feat
This commit is contained in:
BIN
__pycache__/main.cpython-311.pyc
Normal file
BIN
__pycache__/main.cpython-311.pyc
Normal file
Binary file not shown.
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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):
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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
|
||||
@@ -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 # 大于信号的昨收价是否开仓
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
package libs
|
||||
|
||||
import "time"
|
||||
|
||||
var (
|
||||
API_HOST = "http://139.224.247.176:13499"
|
||||
HTTPTimeout = 5 * time.Second
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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]) + "..."
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
// Package sdk 是 QMT_API.py HTTP 服务的 Go 客户端。
|
||||
//
|
||||
// 用法:sdk.New(baseURL, token, timeout),账户类型默认 stock,资金账号由服务端环境变量决定。
|
||||
package sdk
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
BIN
py-client/__pycache__/main.cpython-311.pyc
Normal file
BIN
py-client/__pycache__/main.cpython-311.pyc
Normal file
Binary file not shown.
BIN
py-client/__pycache__/test.cpython-311.pyc
Normal file
BIN
py-client/__pycache__/test.cpython-311.pyc
Normal file
Binary file not shown.
146
py-client/config/__init__.py
Normal file
146
py-client/config/__init__.py
Normal 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
|
||||
BIN
py-client/config/__pycache__/__init__.cpython-311.pyc
Normal file
BIN
py-client/config/__pycache__/__init__.cpython-311.pyc
Normal file
Binary file not shown.
11
py-client/etc/_global.yaml
Normal file
11
py-client/etc/_global.yaml
Normal 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
11
py-client/etc/dev.yaml
Normal 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"
|
||||
5
py-client/libs/__init__.py
Normal file
5
py-client/libs/__init__.py
Normal 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"]
|
||||
BIN
py-client/libs/__pycache__/__init__.cpython-311.pyc
Normal file
BIN
py-client/libs/__pycache__/__init__.cpython-311.pyc
Normal file
Binary file not shown.
BIN
py-client/libs/__pycache__/calc.cpython-311.pyc
Normal file
BIN
py-client/libs/__pycache__/calc.cpython-311.pyc
Normal file
Binary file not shown.
BIN
py-client/libs/__pycache__/grid_take_profit.cpython-311.pyc
Normal file
BIN
py-client/libs/__pycache__/grid_take_profit.cpython-311.pyc
Normal file
Binary file not shown.
BIN
py-client/libs/__pycache__/http.cpython-311.pyc
Normal file
BIN
py-client/libs/__pycache__/http.cpython-311.pyc
Normal file
Binary file not shown.
BIN
py-client/libs/__pycache__/market.cpython-311.pyc
Normal file
BIN
py-client/libs/__pycache__/market.cpython-311.pyc
Normal file
Binary file not shown.
BIN
py-client/libs/__pycache__/signal.cpython-311.pyc
Normal file
BIN
py-client/libs/__pycache__/signal.cpython-311.pyc
Normal file
Binary file not shown.
32
py-client/libs/calc.py
Normal file
32
py-client/libs/calc.py
Normal 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%
|
||||
80
py-client/libs/grid_take_profit.py
Normal file
80
py-client/libs/grid_take_profit.py
Normal 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
8
py-client/libs/http.py
Normal 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
24
py-client/libs/market.py
Normal 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
29
py-client/libs/signal.py
Normal 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
111
py-client/main.py
Normal 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
14
py-client/sdk/__init__.py
Normal 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"]
|
||||
BIN
py-client/sdk/__pycache__/__init__.cpython-311.pyc
Normal file
BIN
py-client/sdk/__pycache__/__init__.cpython-311.pyc
Normal file
Binary file not shown.
BIN
py-client/sdk/__pycache__/account.cpython-311.pyc
Normal file
BIN
py-client/sdk/__pycache__/account.cpython-311.pyc
Normal file
Binary file not shown.
BIN
py-client/sdk/__pycache__/client.cpython-311.pyc
Normal file
BIN
py-client/sdk/__pycache__/client.cpython-311.pyc
Normal file
Binary file not shown.
BIN
py-client/sdk/__pycache__/data.cpython-311.pyc
Normal file
BIN
py-client/sdk/__pycache__/data.cpython-311.pyc
Normal file
Binary file not shown.
BIN
py-client/sdk/__pycache__/errors.cpython-311.pyc
Normal file
BIN
py-client/sdk/__pycache__/errors.cpython-311.pyc
Normal file
Binary file not shown.
BIN
py-client/sdk/__pycache__/misc.cpython-311.pyc
Normal file
BIN
py-client/sdk/__pycache__/misc.cpython-311.pyc
Normal file
Binary file not shown.
BIN
py-client/sdk/__pycache__/models.cpython-311.pyc
Normal file
BIN
py-client/sdk/__pycache__/models.cpython-311.pyc
Normal file
Binary file not shown.
BIN
py-client/sdk/__pycache__/trade.cpython-311.pyc
Normal file
BIN
py-client/sdk/__pycache__/trade.cpython-311.pyc
Normal file
Binary file not shown.
33
py-client/sdk/account.py
Normal file
33
py-client/sdk/account.py
Normal 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
59
py-client/sdk/client.py
Normal 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
83
py-client/sdk/data.py
Normal 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
14
py-client/sdk/errors.py
Normal 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
31
py-client/sdk/misc.py
Normal 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
106
py-client/sdk/models.py
Normal 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
54
py-client/sdk/trade.py
Normal 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")
|
||||
5
py-client/strategy/__init__.py
Normal file
5
py-client/strategy/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""交易策略启动入口。"""
|
||||
|
||||
from .trend.boot import StartTrend
|
||||
|
||||
__all__ = ["StartTrend"]
|
||||
BIN
py-client/strategy/__pycache__/__init__.cpython-311.pyc
Normal file
BIN
py-client/strategy/__pycache__/__init__.cpython-311.pyc
Normal file
Binary file not shown.
BIN
py-client/strategy/__pycache__/boot.cpython-311.pyc
Normal file
BIN
py-client/strategy/__pycache__/boot.cpython-311.pyc
Normal file
Binary file not shown.
5
py-client/strategy/trend/__init__.py
Normal file
5
py-client/strategy/trend/__init__.py
Normal 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
|
||||
BIN
py-client/strategy/trend/__pycache__/__init__.cpython-311.pyc
Normal file
BIN
py-client/strategy/trend/__pycache__/__init__.cpython-311.pyc
Normal file
Binary file not shown.
BIN
py-client/strategy/trend/__pycache__/boot.cpython-311.pyc
Normal file
BIN
py-client/strategy/trend/__pycache__/boot.cpython-311.pyc
Normal file
Binary file not shown.
BIN
py-client/strategy/trend/__pycache__/open.cpython-311.pyc
Normal file
BIN
py-client/strategy/trend/__pycache__/open.cpython-311.pyc
Normal file
Binary file not shown.
BIN
py-client/strategy/trend/__pycache__/order.cpython-311.pyc
Normal file
BIN
py-client/strategy/trend/__pycache__/order.cpython-311.pyc
Normal file
Binary file not shown.
BIN
py-client/strategy/trend/__pycache__/positions.cpython-311.pyc
Normal file
BIN
py-client/strategy/trend/__pycache__/positions.cpython-311.pyc
Normal file
Binary file not shown.
BIN
py-client/strategy/trend/__pycache__/run.cpython-311.pyc
Normal file
BIN
py-client/strategy/trend/__pycache__/run.cpython-311.pyc
Normal file
Binary file not shown.
BIN
py-client/strategy/trend/__pycache__/runtime.cpython-311.pyc
Normal file
BIN
py-client/strategy/trend/__pycache__/runtime.cpython-311.pyc
Normal file
Binary file not shown.
BIN
py-client/strategy/trend/__pycache__/state.cpython-311.pyc
Normal file
BIN
py-client/strategy/trend/__pycache__/state.cpython-311.pyc
Normal file
Binary file not shown.
BIN
py-client/strategy/trend/__pycache__/strategy.cpython-311.pyc
Normal file
BIN
py-client/strategy/trend/__pycache__/strategy.cpython-311.pyc
Normal file
Binary file not shown.
BIN
py-client/strategy/trend/__pycache__/watch.cpython-311.pyc
Normal file
BIN
py-client/strategy/trend/__pycache__/watch.cpython-311.pyc
Normal file
Binary file not shown.
176
py-client/strategy/trend/boot.py
Normal file
176
py-client/strategy/trend/boot.py
Normal 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]
|
||||
105
py-client/strategy/trend/open.py
Normal file
105
py-client/strategy/trend/open.py
Normal 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
|
||||
148
py-client/strategy/trend/order.py
Normal file
148
py-client/strategy/trend/order.py
Normal 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
|
||||
167
py-client/strategy/trend/positions.py
Normal file
167
py-client/strategy/trend/positions.py
Normal 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)
|
||||
43
py-client/strategy/trend/runtime.py
Normal file
43
py-client/strategy/trend/runtime.py
Normal 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
|
||||
|
||||
146
py-client/strategy/trend/state.py
Normal file
146
py-client/strategy/trend/state.py
Normal 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
|
||||
34
py-client/strategy/trend/watch.py
Normal file
34
py-client/strategy/trend/watch.py
Normal 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
25
py-client/test.py
Normal 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
7
run.bat
Normal 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
|
||||
Reference in New Issue
Block a user