This commit is contained in:
2026-08-26 23:27:42 +08:00
parent c30c1541d2
commit cdbd03fe53
13 changed files with 256 additions and 355 deletions

View File

@@ -39,7 +39,7 @@ func Overview(assets *sdk.Assets, positions []sdk.Position) {
}
}
func RunOnce(ctx context.Context, client *sdk.Client, signals *libs.SignalResult) {
func RunOnce(ctx context.Context, client *sdk.Client, signals []*libs.SignalItem) {
if !libs.TradingTime(time.Now()) {
return
}
@@ -71,12 +71,13 @@ func RunOnce(ctx context.Context, client *sdk.Client, signals *libs.SignalResult
allCodes = append(allCodes, pos_codes...)
// 5 验证有效开仓信号
allowOpen := make([]libs.SignalItem, 0)
for code, item := range signals.Data {
if !slices.Contains(pos_codes, code) {
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)
@@ -87,10 +88,24 @@ func RunOnce(ctx context.Context, client *sdk.Client, signals *libs.SignalResult
// 7 执行开仓:有开仓信号 && 大盘指数允许开仓
if len(allowOpen) > 0 && IsAllow {
openSignal(ctx, client, ticks, allowOpen)
openSignal(client, ticks, allowOpen)
}
// 8 持仓计算
buyBudget := assets.Available
managePositions(ctx, client, ticks, positions, IsAllow, &buyBudget)
managePositions(client, ticks, positions, IsAllow)
}
func SignalFilter(in []*libs.SignalItem, name []string) []*libs.SignalItem {
newSignalItem := make([]*libs.SignalItem, 0)
if len(name) == 0 {
return newSignalItem
}
for _, i := range in {
for _, n := range name {
if i.SignalKey == n {
newSignalItem = append(newSignalItem, i)
}
}
}
return newSignalItem
}

View File

@@ -1,14 +1,12 @@
package logic
import (
"context"
"big-qmt/go-client/config"
"big-qmt/go-client/libs"
"big-qmt/go-client/sdk"
)
func openSignal(ctx context.Context, client *sdk.Client, ticks map[string]sdk.Tick, openSignals []libs.SignalItem) {
func openSignal(client *sdk.Client, ticks map[string]sdk.Tick, openSignals []*libs.SignalItem) {
for _, item := range openSignals {
// 是否有锁
if OrderBook.IsLock("BUY", item.Code) {

View File

@@ -0,0 +1,155 @@
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) {
for _, pos := range positions {
item, err := QuantState.Get(code)
if err != nil || item.BaseStatus == StatusIng || item.AddedStatus == StatusIng || position.Volume != item.BaseQty+item.AddedQty {
continue
}
price := ticks[code].LastPrice
if price <= 0 {
continue
}
if item.AddedQty > 0 {
pnl := (price - item.AddedCost) / item.AddedCost * 100
if shouldSell(code, legAdded, pnl) {
sell(client, item, position.CanUseVolume, item.AddedQty, legAdded, pnl)
}
continue
}
pnl := (price - item.BaseCost) / item.BaseCost * 100
if shouldSell(code, legBase, pnl) {
sell(client, item, position.CanUseVolume, item.BaseQty, legBase, pnl)
} else if pnl <= config.Account.LossTriggerPct {
buyAdded(client, item, price, marketOK, budget)
}
}
}
func syncAdded(item *StateItem, position sdk.Position) {
addedQty := position.Volume - item.BaseQty
if addedQty <= 0 {
item.BaseQty = position.Volume
item.BaseCost = position.OpenPrice
item.AddedQty = 0
item.AddedCost = 0
item.AddedStatus = StatusNone
peakMu.Lock()
delete(peakGrids, item.Code+"|"+legAdded)
peakMu.Unlock()
return
}
item.AddedQty = addedQty
totalCost := position.OpenPrice * float64(position.Volume)
baseCost := item.BaseCost * float64(item.BaseQty)
item.AddedCost = math.Max(0, (totalCost-baseCost)/float64(addedQty))
item.AddedStatus = StatusOk
}
func buyAdded(client *sdk.Client, item *StateItem, price float64, marketOK bool, budget *float64) {
if !marketOK || !PosbuyWatch.Triggered("补仓", item.Code, price) {
return
}
volume := libs.CalcBuyVolume(price, config.Account.BuyValue)
amount := price * float64(volume)
if amount > *budget || orderBusy(item.Code, "BUY") {
return
}
orderID := NewOrderID(legAdded)
if !OrderBook.Place(client, sdk.OpBuy, item.Code, volume, orderID) {
return
}
item.AddedOrderId = orderID
item.AddedNum++
item.AddedQty = volume
item.AddedCost = price
item.AddedStatus = StatusIng
QuantState.Set(item)
*budget -= amount
}
func sell(client *sdk.Client, item *StateItem, usable, volume int, leg string, pnl float64) {
volume -= volume % 100
if volume <= 0 || usable < volume || orderBusy(item.Code, "SELL") {
return
}
orderID := NewOrderID(leg)
if !OrderBook.Place(client, sdk.OpSell, item.Code, volume, orderID) {
return
}
if leg == legAdded {
item.AddedOrderId = orderID
item.AddedStatus = StatusIng
} else {
item.BaseOrderId = orderID
item.BaseStatus = StatusIng
}
QuantState.Set(item)
logf("INFO", "[止盈] %s 卖出%d股盈利=%.2f%%", item.Code, volume, pnl)
}
func orderBusy(code, side string) bool {
OrderBook.mu.Lock()
defer OrderBook.mu.Unlock()
order := OrderBook.Data[side+"-"+code]
if order == nil {
return false
}
switch order.Status {
case "48", "49", "50", "51", "52", "55":
return true
default:
return false
}
}
func shouldSell(code, leg string, pnl float64) bool {
if pnl < config.Account.MinProfitPct {
return false
}
grid := int(math.Floor(pnl / config.Account.GridStepPct))
key := code + "|" + leg
peakMu.Lock()
defer peakMu.Unlock()
peak, tracked := peakGrids[key]
if !tracked || grid > peak {
peakGrids[key] = grid
return false
}
return grid < peak
}
func forget(code string) {
OpenWatch.mu.Lock()
delete(OpenWatch.Data, code)
OpenWatch.mu.Unlock()
PosbuyWatch.mu.Lock()
delete(PosbuyWatch.Data, code)
PosbuyWatch.mu.Unlock()
peakMu.Lock()
delete(peakGrids, code+"|"+legBase)
delete(peakGrids, code+"|"+legAdded)
peakMu.Unlock()
}

View File

@@ -8,7 +8,7 @@ import (
"syscall"
"time"
"big-qmt/go-client/apps/zt/logic"
"big-qmt/go-client/apps/trend/logic"
"big-qmt/go-client/config"
"big-qmt/go-client/libs"
"big-qmt/go-client/sdk"
@@ -17,7 +17,7 @@ import (
)
var (
StrategyName = "zt"
StrategyName = "trend"
)
func main() {
@@ -38,7 +38,7 @@ func main() {
return
}
// 初始化
// 第三步 初始化
logic.NewOrderBook()
logic.InitWatch()
if err := logic.InitState(StrategyName); err != nil {
@@ -47,15 +47,14 @@ func main() {
if err := logic.SyncPositions(positions); err != nil {
log.Panicln("ERROR", err.Error())
}
// 第三步:连接成功后接管首次持仓并打印账户概览。
logic.Overview(assets, positions)
signals, err := libs.FetchSignal(libs.Dcm_Signal, config.Account.HostKey)
signals, err := libs.InitSignals()
if err != nil {
log.Printf("[ERROR] [ZT] 获取开仓信号失败: %v", err)
signals = &libs.SignalResult{Data: map[string]libs.SignalItem{}}
log.Panicln("ERROR", err.Error())
}
log.Printf("[INFO] [ZT] 已加载 %d 个开仓信号", len(signals.Data))
// 打印启动信息
logic.Overview(assets, positions)
log.Printf("[INFO] 已加载 %d 个开仓信号", len(signals))
// 第四步:工作日每 30 秒触发,交易时段由 RunOnce 统一判断。
scheduler := cron.New(
@@ -68,11 +67,11 @@ func main() {
log.Fatalf("[ERROR] 创建计划任务失败: %v", err)
}
scheduler.Start()
log.Printf("[INFO] [ZT] 计划任务已启动")
log.Printf("[INFO] 计划任务已启动")
<-ctx.Done()
<-scheduler.Stop().Done()
log.Printf("[INFO] [ZT] 停止")
log.Printf("[INFO] 停止")
}
func waitForQMT(ctx context.Context, client *sdk.Client) (*sdk.Assets, []sdk.Position, bool) {
@@ -82,10 +81,10 @@ func waitForQMT(ctx context.Context, client *sdk.Client) (*sdk.Assets, []sdk.Pos
_, positions, positionsErr := client.Positions(attempt)
cancel()
if assetsErr == nil && positionsErr == nil {
log.Printf("[INFO] [ZT] QMT连接成功: %s", config.Global.QMTBaseURL)
log.Printf("[INFO] QMT连接成功: %s", config.Global.QMTBaseURL)
return assets, positions, true
}
log.Printf("[WARNING] [ZT] QMT未就绪5秒后重试: assets=%v positions=%v", assetsErr, positionsErr)
log.Printf("[WARNING] QMT未就绪5秒后重试: assets=%v positions=%v", assetsErr, positionsErr)
select {
case <-ctx.Done():
return nil, nil, false

View File

@@ -1,237 +0,0 @@
package logic
import (
"context"
"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(_ context.Context, client *sdk.Client, ticks map[string]sdk.Tick, positions []sdk.Position, marketOK bool, budget *float64) {
if QuantState == nil || OrderBook == nil || positions == nil {
return
}
if err := OrderBook.Refresh(client); err != nil {
logf("ERROR", "[持仓] 刷新委托失败: %v", err)
return
}
current := make(map[string]sdk.Position, len(positions))
for _, position := range positions {
if position.StockCode != "" {
current[position.StockCode] = position
}
}
for _, code := range stateCodes() {
syncPosition(code, current[code])
}
for code, position := range current {
managePosition(client, ticks[code], position, marketOK, budget)
}
saveState()
}
func syncPosition(code string, position sdk.Position) {
item, err := QuantState.Get(code)
if err != nil || orderBusy(code, "BUY") || orderBusy(code, "SELL") {
return
}
if position.Volume <= 0 {
QuantState.Delete(code)
forget(code)
return
}
if item.BaseStatus == StatusIng {
item.BaseQty = max(0, position.Volume-item.AddedQty)
item.BaseCost = position.OpenPrice
item.BaseStatus = StatusOk
}
if item.AddedStatus == StatusIng {
syncAdded(item, position)
}
QuantState.Set(item)
}
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
clearPeak(item.Code, legAdded)
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 managePosition(client *sdk.Client, tick sdk.Tick, position sdk.Position, marketOK bool, budget *float64) {
item, err := QuantState.Get(position.StockCode)
if err != nil || tick.LastPrice <= 0 || !positionReady(item, position) {
return
}
if item.AddedQty > 0 {
pnl := profit(tick.LastPrice, item.AddedCost)
if shouldSell(item.Code, legAdded, pnl) {
sell(client, item, position.CanUseVolume, item.AddedQty, legAdded, pnl)
}
return
}
pnl := profit(tick.LastPrice, item.BaseCost)
if shouldSell(item.Code, legBase, pnl) {
sell(client, item, position.CanUseVolume, item.BaseQty, legBase, pnl)
} else if pnl <= config.Account.LossTriggerPct {
buyAdded(client, item, tick.LastPrice, marketOK, budget)
}
}
func positionReady(item *StateItem, position sdk.Position) bool {
return item.BaseStatus != StatusIng && item.AddedStatus != StatusIng &&
position.Volume > 0 && position.Volume%100 == 0 &&
position.Volume == item.BaseQty+item.AddedQty
}
func buyAdded(client *sdk.Client, item *StateItem, price float64, marketOK bool, budget *float64) {
if !marketOK || budget == nil || PosbuyWatch == nil || !PosbuyWatch.Triggered("补仓", item.Code, price) {
return
}
volume := calcBuyVolume(price, config.Account.BuyValue)
amount := price * float64(volume)
if volume <= 0 || 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
saveState()
}
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)
saveState()
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 || config.Account.GridStepPct <= 0 {
return false
}
grid := int(math.Floor(pnl / config.Account.GridStepPct))
key := peakKey(code, leg)
peakMu.Lock()
defer peakMu.Unlock()
peak, tracked := peakGrids[key]
if !tracked || grid > peak {
peakGrids[key] = grid
return false
}
return grid < peak
}
func stateCodes() []string {
QuantState.mu.Lock()
defer QuantState.mu.Unlock()
return append([]string(nil), QuantState.Codes...)
}
func saveState() {
if err := QuantState.Save(); err != nil {
logf("ERROR", "%v", err)
}
}
func profit(price, cost float64) float64 {
if cost <= 0 {
return math.Inf(-1)
}
return (price - cost) / cost * 100
}
func calcBuyVolume(price, value float64) int {
return libs.CalcBuyVolume(price, value)
}
func peakKey(code, leg string) string { return code + "|" + leg }
func clearPeak(code, leg string) {
peakMu.Lock()
delete(peakGrids, peakKey(code, leg))
peakMu.Unlock()
}
func forget(code string) {
if OpenWatch != nil {
OpenWatch.mu.Lock()
delete(OpenWatch.Data, code)
OpenWatch.mu.Unlock()
}
if PosbuyWatch != nil {
PosbuyWatch.mu.Lock()
delete(PosbuyWatch.Data, code)
PosbuyWatch.mu.Unlock()
}
clearPeak(code, legBase)
clearPeak(code, legAdded)
}

View File

@@ -1,67 +0,0 @@
# 做 T 策略说明
## 启动准备
策略启动后,根据当前计算机选择对应的交易账户。账户连接成功后,展示总资产、可用资金和当前持仓,并读取一次当日开仓信号。运行期间一直使用这份内存信号,不再重复请求。
首次运行且没有历史策略状态时,账户中已有的全部持仓都作为底仓接管。后续运行以已保存的策略状态为准。
## 运行时间
策略仅在周一至周五运行,周末不执行交易计算。每天运行时段为:
- 09:30 至 11:30
- 13:00 至 15:00。
交易时段内每 30 秒计算一次。午间休市和收盘后不执行交易计算。
## 每轮计算流程
每轮读取账户资产、当前持仓、最新行情和委托情况,并处理已经超过等待时间的委托。
随后判断开仓信号中的股票是否已经持仓。未持仓信号和已有持仓可以在同一轮中分别处理,不会因为存在未开仓信号而停止管理已有持仓。
大盘信号只控制买入行为。大盘不允许开仓时,不新建底仓,也不补仓;止盈卖出、委托清理和持仓状态同步仍然正常进行。
## 底仓开仓
开仓信号对应的股票尚未持仓,且大盘允许开仓时,进入价格观察阶段。
观察期间持续记录最低价格。当价格从观察低点反弹达到设定幅度后,触发底仓买入。
买入数量根据配置的 `buy_value` 和当前股价计算,向下取整为整手。不足一手时按一手买入。
提交底仓买入后,策略记录正在开仓的状态,等待委托和持仓结果确认。
## 补仓
底仓亏损达到配置的补仓触发比例后,进入补仓价格观察阶段。
观察期间持续记录新的最低价格。当价格从低点反弹达到设定幅度,且大盘允许买入时,触发补仓。
补仓数量同样根据 `buy_value` 和补仓时的股价独立计算,因此补仓数量不要求与底仓数量相同。部分成交的数量按实际补仓数量接管。
## 网格止盈
底仓和补仓分别计算盈利比例,并分别记录本次运行期间达到的最高盈利网格。
盈利达到最低止盈比例后,策略开始跟踪最高网格。当盈利从最高网格回落时,触发对应仓位的卖出:
- 补仓达到回撤条件时,只卖出补仓部分;
- 底仓达到回撤条件时,可以卖出全部底仓。
最高盈利网格只在本次程序运行期间保留,程序重新启动后重新开始记录。
## 委托确认
每笔委托生成一个不超过 24 个字符的唯一订单号。策略根据账户回写的委托结果确认订单状态,并将结果同步到策略状态。
回写状态包括已提交、部分成交、全部成交、已撤销和已拒绝。没有回写结果的订单不会直接重复下单。
委托超过等待时间后,策略先检查当前委托和持仓。如果委托已经不存在,则释放该股票的等待状态,允许后续交易轮次重新判断,但不会在释放状态的同一轮自动重复下单。
## 状态保护
策略持续保存底仓数量与成本、补仓数量与成本、当前待确认动作和最近委托状态。
状态文件不存在时,启动前持仓全部作为底仓接管。状态文件内容异常时,策略停止交易处理,不会自动删除或重建异常文件。

View File

@@ -22,6 +22,13 @@ type GlobalConfig struct {
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 {
@@ -32,6 +39,7 @@ type AccountConfig struct {
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 映射加载当前计算机的账户配置。
@@ -68,6 +76,7 @@ func Load(etcDir string) error {
return fmt.Errorf("buy_value、grid_step_pct 和超时时间必须大于 0")
}
account.HostKey = strings.ToLower(account.HostKey)
Global = &global
Account = &account

View File

@@ -1,11 +1,11 @@
# 当前计算机使用的账户和策略参数。
account_id: CHANGE_ME
host_key: ""
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
order_timeout_seconds: 60
watch_timeout_seconds: 300
signal_allow:
- dcm

View File

@@ -1,9 +1,27 @@
# 系统公共参数。hosts 将 Windows 计算机名映射到账户配置文件。
qmt_base_url: http://127.0.0.1:10086
qmt_token: QMTbyYanweidong
api_host: http://go.apinb.com
api_host: http://139.224.247.176:13499
qmt_data_dir: D:/qmt_strategy_data
state_dir: D:/qmt_strategy_state
hosts:
DESKTOP-39H91QV: dev.yaml
signals:
dcm:
url: /a/dcm_signal
timezone: * # *代表全时间段9:30-10:30,13:30-14:30 代表2个时间段
gt_last_price_is_open: false # 大于信号的昨收价是否开仓
morning:
url: /a/morning_signal
timezone: 9:30-10:30 # *代表全时间段9:30-10:30,13:30-14:30 代表2个时间段
gt_last_price_is_open: true # 大于信号的昨收价是否开仓
tail:
url: /a/tail_signal
timezone: 14:30-14:55 # *代表全时间段9:30-10:30,13:30-14:30 代表2个时间段
gt_last_price_is_open: false # 大于信号的昨收价是否开仓
arbitrage:
url: /a/arbitrage_signal
timezone: * # *代表全时间段9:30-10:30,13:30-14:30 代表2个时间段
gt_last_price_is_open: false # 大于信号的昨收价是否开仓

View File

@@ -1,13 +1,9 @@
package libs
import (
"big-qmt/go-client/config"
"encoding/json"
"fmt"
"log"
)
var (
Dcm_Signal = "/a/dcm_signal"
)
type SignalResult struct {
@@ -19,6 +15,7 @@ type SignalResult struct {
}
type SignalItem struct {
SignalKey string `json:"signal_key"`
Code string `json:"code"`
Name string `json:"name"`
Desc string `json:"desc"`
@@ -26,21 +23,35 @@ type SignalItem struct {
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, host_key string) (*SignalResult, error) {
func FetchSignal(subUrl string) (*SignalResult, error) {
// gen url.
fullUrl := fmt.Sprintf("%s%s?host_key=%s&t=%s", API_HOST, subUrl, host_key, randStr(16))
fullUrl := fmt.Sprintf("%s%s?t=%s", API_HOST, subUrl, randStr(16))
// doing
payload, err := GetJSON(fullUrl, HTTPTimeout)
if err != nil {
log.Printf("[ERROR] 获取60m大盘信号失败: %s %v", fullUrl, err)
return nil, err
return nil, fmt.Errorf("开仓信号获取失败: %s %v", fullUrl, err)
}
var result SignalResult
err = json.Unmarshal(payload, &result)
if err != nil {
return nil, err
return nil, fmt.Errorf("开仓信号解析失败: %s %v", fullUrl, err)
}
return &result, nil
}