Compare commits

..

66 Commits

Author SHA1 Message Date
72a49bc741 fix bug 2026-09-09 18:40:57 +08:00
32460e37bc fix bug 2026-09-09 18:28:32 +08:00
91ad2dabdf fux bug 2026-09-09 18:24:09 +08:00
7adee94192 fix bug 2026-09-09 18:22:43 +08:00
71b1c8b2d5 fix args 2026-09-09 11:10:36 +08:00
e9cd816d4d fix args 2026-09-09 11:08:56 +08:00
983902d065 fix bug 2026-09-09 11:06:20 +08:00
8d3c32cd43 fix bug 2026-09-09 10:06:00 +08:00
bd7b6a37d8 fix etc 2026-09-09 09:28:24 +08:00
8a3a29268e optz print. 2026-09-08 15:18:09 +08:00
2a1458e91e fix bug 2026-09-07 21:43:58 +08:00
ba61ed5de7 optz 2026-09-07 21:22:51 +08:00
9778d54f3d fix bug 2026-09-07 21:01:36 +08:00
a4b637f90e fix bug 2026-09-07 20:15:50 +08:00
f9c4997658 fix bug 2026-09-07 19:54:49 +08:00
8eb44440d3 update etc/* 2026-09-07 18:18:00 +08:00
e320de3241 feat libs,sdk,trend 2026-09-07 14:04:26 +08:00
d37f9edefc fix bug 2026-09-07 00:27:33 +08:00
fdcbdc7869 fix bug 2026-09-06 14:49:46 +08:00
2eafbb8303 fix trend,zt 2026-09-06 13:12:48 +08:00
bcc6f02398 dev zt 2026-09-06 11:53:28 +08:00
ac9e9193ad fix bug 2026-09-06 11:34:23 +08:00
b38ff12f2a fix bug 2026-09-05 23:36:25 +08:00
2f93fe2ecb fix bug 2026-09-05 22:52:35 +08:00
af55324e2a fix bug 2026-09-05 20:39:58 +08:00
1fc3e119d3 fix state.py 2026-09-05 15:56:39 +08:00
ba1bd93afa fix bug 2026-09-05 14:30:16 +08:00
2059f87fe5 fix (frozen=True, slots=True) 2026-09-05 13:56:04 +08:00
58e1e3e291 fix bug 2026-09-05 13:53:26 +08:00
63ffc1329b fix bug 2026-09-05 11:46:31 +08:00
3689a7ad86 fix order busy 2026-09-05 11:32:10 +08:00
a53fc738cf fix(trend): reconcile state from actual fills 2026-09-05 00:29:50 +08:00
6c2eca8fdc fix bug 2026-09-05 00:23:55 +08:00
9f01eb8dc5 fix bug 2026-09-04 23:08:21 +08:00
8f97666d9e fix bug 2026-09-04 21:21:54 +08:00
6a94f67e82 fix bug 2026-09-04 21:04:34 +08:00
eb0bbcdddc fix bug 2026-09-04 19:48:47 +08:00
d793914b83 fix bug 2026-09-03 23:51:07 +08:00
e6c5c1023c fix qmt_api get_pass_codes 2026-09-03 19:58:04 +08:00
cf5f33ab9e fix bug 2026-09-03 15:27:11 +08:00
9ef063f4ce fix 2026-09-03 15:20:40 +08:00
409676745b fix bug 2026-09-03 15:19:18 +08:00
e6a5096353 fix bug 2026-09-03 11:33:43 +08:00
d5303cc22b fix bug 2026-09-03 00:59:56 +08:00
e2800fc193 fix bug 2026-09-03 00:54:57 +08:00
07e74d054e fix qmt_rest_new.py 2026-09-02 21:19:33 +08:00
9d8b913465 feat QMT_API.py 2026-09-02 21:03:45 +08:00
66ccd42d4e fix bug 2026-09-01 15:49:09 +08:00
556e21d624 feat dev6 2026-09-01 14:28:49 +08:00
dedbf63a92 fix bug 2026-08-31 23:18:04 +08:00
027d7e06eb Optimize 2026-08-31 15:33:39 +08:00
1b6f5a9f03 feat client. 2026-08-31 13:00:22 +08:00
a946c5b53d feat dev 2026-08-30 15:29:21 +08:00
6334f10904 feat order.py 2026-08-30 14:46:34 +08:00
cdccc48d8c update api,libs 2026-08-30 00:34:27 +08:00
9a43aaba23 fix bug 2026-08-29 12:00:51 +08:00
4e28182b3f fix bug 2026-08-29 02:19:40 +08:00
846d6c03e0 feat ipo 2026-08-29 01:50:09 +08:00
28e91366d6 dev 6 2026-08-29 00:44:41 +08:00
b72f99b4f8 feat dev 2026-08-28 22:56:35 +08:00
29fee85b3d refactor QMT client and optimize API 2026-08-28 22:46:04 +08:00
d09f271569 feat 2026-08-28 18:52:27 +08:00
e0ecdfba52 feat go-client 2026-08-28 08:48:06 +08:00
a4791b477a add CheckTimezone 2026-08-27 00:27:56 +08:00
cdbd03fe53 dev 5 2026-08-26 23:27:42 +08:00
c30c1541d2 dev 5 2026-08-26 16:37:06 +08:00
169 changed files with 6869 additions and 4856 deletions

1
.gitignore vendored
View File

@@ -17,6 +17,7 @@
# Dependency directories (remove the comment below to include it)
# vendor/
logs/
# Go workspace file
go.work

Binary file not shown.

445
api/qmt_rest_new.py Normal file
View File

@@ -0,0 +1,445 @@
# -*- coding: utf-8 -*-
import json
import locale
import os
import sys
from urllib.request import Request, urlopen
from tornado.web import Application, RequestHandler, HTTPError
from tornado.ioloop import IOLoop
import logging
# Configuration
ACCOUNT_ID = os.environ.get('QMT_ACCOUNT_ID', '')
DATA_DIR = os.environ.get('QMT_DATA_DIR', r'D:\qmt_strategy_data')
TOKEN="QMTbyYanweidong"
PORT = 10086
PASS_CODES_URL = "http://139.224.247.176:13499/a/pass_codes"
# ===================================
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
locale.setlocale(locale.LC_CTYPE, 'chinese')
def safe_call(func, *args, **kwargs):
try:
return func(*args, **kwargs)
except HTTPError:
raise
except Exception as e:
raise HTTPError(
500,
reason="QMT: %s call failed." % func.__name__,
) from e
def get_pass_codes(account_id):
request = Request(
PASS_CODES_URL,
headers={"Accept": "application/json", "User-Agent": "big-qmt/1"},
)
with urlopen(request, timeout=10) as response:
payload = json.load(response)
remote_codes = payload.get("data")
if not isinstance(remote_codes, list):
raise ValueError("pass_codes response data must be an array")
positions = safe_call(
get_trade_detail_data, account_id, 'stock', 'position'
) or []
position_codes = [
position.m_strInstrumentID + '.' + position.m_strExchangeID
for position in positions
]
codes = []
seen = set()
for code in remote_codes + position_codes:
code = str(code).strip()
if code and code not in seen:
seen.add(code)
codes.append(code)
return codes
# ============= BaseHandler =============
AUTH_EXEMPT = set()
def no_auth(cls):
AUTH_EXEMPT.add(cls)
return cls
class BaseHandler(RequestHandler):
def prepare(self):
if self.__class__ not in AUTH_EXEMPT:
token = self.request.headers.get('X-Token')
if token != TOKEN:
raise HTTPError(500, "Authentication failed: invalid or missing token")
def set_default_headers(self):
self.set_header("Content-Type", "application/json; charset=utf-8")
def write_error(self,status_code, **kwargs):
self.finish(self._reason)
def ctx(self):
return self.application.ContextInfo
def acc(self):
return self.application.accountID
def write_json(self, data, default=None):
self.write(json.dumps(
data,
separators=(',', ':'),
ensure_ascii=False,
default=default,
))
# ============= 1. ContextInfo properties =============
# "/api/v2/context/info"
class ContextInfoHandler(BaseHandler):
def get(self):
ctx = self.ctx()
data = {
"period": ctx.period,
"barpos": ctx.barpos,
"time_tick_size": ctx.time_tick_size,
"stockcode": ctx.stockcode,
"dividend_type": ctx.dividend_type,
"market": ctx.market,
"do_back_test": ctx.do_back_test,
"benchmark": ctx.benchmark,
"capital": ctx.capital,
"timetag":ctx.timetag,
"universe": ctx.get_universe(),
}
self.write_json(data)
# ============= 2. Data queries (ContextInfo get_*) =============
STOCK_HANDLER = {
# handler_type: (method_name, use_context)
"stock_name": ("get_stock_name", True),
"open_date": ("get_open_date", True),
"last_volume": ("get_last_volume", True),
"total_share": ("get_total_share", True),
"svol": ("get_svol", True),
"bvol": ("get_bvol", True),
"divid_factors": ("get_divid_factors", True),
"etf_info": ("get_etf_info", False),
"etf_iopv": ("get_etf_iopv", False),
"instrumentdetail": ("get_instrumentdetail", True),
"his_st_data": ("get_his_st_data", True),
}
# "/api/v2/get/*" Stock-related single-symbol queries
class StockGetHandler(BaseHandler):
def get(self, handler_type):
# 快速路径:配置查找
cfg = STOCK_HANDLER.get(handler_type)
if not cfg:
raise HTTPError(500, "Unknown API")
# 参数验证
query_vals = self.get_query_argument("stock_code", "").strip()
if not query_vals:
raise HTTPError(500, "stock_code required")
# 方法调用
method_name, use_context = cfg
method = getattr(self.ctx(), method_name) if use_context else globals()[method_name]
result = safe_call(method, query_vals)
# 响应
self.write_json({
"stock_code": query_vals,
"ref": result
}, default=str)
# Aggregate assets, positions, and orders in one request.
class PortfolioHandler(BaseHandler):
def get(self):
account_id = self.acc()
account_data = safe_call(get_trade_detail_data, account_id, 'stock', 'account')
positions = safe_call(get_trade_detail_data, account_id, 'stock', 'position') or []
orders = safe_call(get_trade_detail_data, account_id, 'stock', 'order') or []
result = {
"assets": format_assets(account_data),
"positions": format_holding(positions),
"orders": format_orders(orders),
}
self.write_json(result)
# get_trade_detail_data('position') - Query positions in the wrapped format
class HoldingHandler(BaseHandler):
def get(self):
positions = safe_call(get_trade_detail_data, self.acc(), 'stock', 'position') or []
self.write_json(format_holding(positions))
class OrgHandler(BaseHandler):
def get(self, handler_type):
result = safe_call(
get_trade_detail_data, self.acc(), 'stock', handler_type
) or []
data = []
for item in result:
fields = {}
for name in dir(item):
if name.startswith('_'):
continue
value = getattr(item, name)
if not callable(value):
fields[name] = value
data.append(fields)
self.write_json({"data": data})
# get_trade_detail_data('account') - Query account assets
class AssetsHandler(BaseHandler):
def get(self):
_data = safe_call(get_trade_detail_data, self.acc(), 'stock', 'account') or []
self.write_json(format_assets(_data))
class OrderHandler(BaseHandler):
def get(self):
ret = safe_call(get_trade_detail_data, self.acc(), 'stock', 'order') or []
self.write_json(format_orders(ret))
class DealHandler(BaseHandler):
def get(self):
deals = safe_call(get_trade_detail_data, self.acc(), 'stock', 'deal') or []
self.write_json(format_deals(deals))
# ContextInfo.get_full_tick() - Get full tick data
class FullTickHandler(BaseHandler):
def post(self):
data = json.loads(self.request.body)
stocks = data.get('stocks', [])
#if not stocks:
# raise HTTPError(400, "need args stocks")
ret = safe_call(self.ctx().get_full_tick, stocks)
if not ret:
raise HTTPError(500, "Failed to get tick data")
self.write_json(ret, default=str)
# passorder() - Submit a general trading order
class PassorderHandler(BaseHandler):
def post(self):
try:
data = json.loads(self.request.body)
opType = int(data['opType'])
orderType = int(data.get('orderType', 1101))
stockCode = data['stockCode']
pr_type = int(data.get('prType', 11))
price = float(data['price'])
volume = int(data['volume'])
quickTrade = int(data.get('quickTrade', 2))
strategy_name = str(data.get('strategyName', '')).strip()
order_id = str(data.get('orderId', '')).strip()
except (json.JSONDecodeError, KeyError, TypeError, ValueError) as e:
raise HTTPError(400, reason="Invalid order parameters: %s" % e) from e
# QMT stores strategyName in the order remark; preserve the signal key and local order ID.
try:
order_ref = passorder(opType, orderType, self.acc(), stockCode, pr_type, price, volume, strategy_name, quickTrade,order_id, self.ctx())
except HTTPError:
raise
except Exception as e:
logger.exception("passorder failed")
raise HTTPError(500, reason="QMT order submission failed") from e
self.write_json({
"status": "success",
"opType": opType,
"stockCode": stockCode,
"strategy_name": strategy_name,
"local_order_id": order_id,
"order_ref": str(order_ref)
})
class CancelByIdHandler(BaseHandler):
"""Cancel an order by its actual system order ID."""
def post(self):
data = json.loads(self.request.body)
order_id = str(data.get('order_id', '')).strip()
if not order_id:
raise HTTPError(400, "order_id cannot be empty")
cancelable = safe_call(can_cancel_order, order_id, self.acc(), 'stock')
if not cancelable:
self.write_json({
"status": "failed", "order_id": order_id,
"message": "Order does not exist or cannot currently be canceled"
})
return
result = safe_call(cancel, order_id, self.acc(), 'stock', self.ctx())
self.write_json({
"status": "success" if result is not False else "failed",
"order_id": order_id,
})
# get_ipo_data() - Get today's new stock and bond offerings
class IpoDataHandler(BaseHandler):
def post(self):
data = json.loads(self.request.body)
typ = data.get('type', 'STOCK')
ret = safe_call(get_ipo_data, typ)
self.write_json(ret)
# sys: Python version information
class PythonVersionHandler(BaseHandler):
def get(self):
version_info = {
"python_version": sys.version,
"python_version_info": {
"major": sys.version_info.major,
"minor": sys.version_info.minor,
"micro": sys.version_info.micro,
"releaselevel": sys.version_info.releaselevel,
"serial": sys.version_info.serial,
}
}
self.write_json(version_info)
def format_assets(account_data):
info = account_data[0] if account_data else None
if not info:
raise HTTPError(500, "Failed to get account data")
return {
"total": round(info.m_dBalance, 2),
"available": round(info.m_dAvailable, 2),
}
def format_holding(positions):
holding = {}
for position in positions:
stock = position.m_strInstrumentID + '.' + position.m_strExchangeID
holding[stock] = {
'stock_code': stock,
'stock_name': position.m_strInstrumentName,
'direction': position.m_nDirection,
'volume': position.m_nVolume,
'open_price': position.m_dOpenPrice,
'open_cost':position.m_dOpenCost,
'float_profit': position.m_dFloatProfit,
'market_value': position.m_dMarketValue,
'stock_holder': position.m_strStockHolder,
'frozen_volume': position.m_nFrozenVolume,
'can_use_volume': position.m_nCanUseVolume,
'on_road_volume': position.m_nOnRoadVolume,
'yesterday_volume': position.m_nYesterdayVolume,
'last_price': position.m_dLastPrice,
'profit_rate': position.m_dProfitRate,
'future_trade_type': position.m_eFutureTradeType,
'expire_date': position.m_strExpireDate
}
return holding
def format_orders(orders):
result = []
for order in orders:
result.append({
'stock_code': order.m_strInstrumentID + '.' + order.m_strExchangeID,
'order_sys_id': order.m_strOrderSysID,
'ref': order.m_nRef,
'order_ref': order.m_strOrderRef,
'direction': order.m_nDirection,
'offset_flag': order.m_nOffsetFlag,
'limit_price': order.m_dLimitPrice,
'volume_total_original': order.m_nVolumeTotalOriginal,
'volume_traded': order.m_nVolumeTraded,
'volume_total': order.m_nVolumeTotal,
'traded_price': order.m_dTradedPrice,
'trade_amount': order.m_dTradeAmount,
'insert_date': order.m_strInsertDate,
'insert_time': order.m_strInsertTime,
'remark': order.m_strRemark,
'order_status': order.m_nOrderStatus,
})
return result
def format_deals(deals):
result = []
for d in deals:
result.append({
'stock_code': d.m_strInstrumentID + '.' + d.m_strExchangeID,
'order_sys_id': d.m_strOrderSysID,
'ref': d.m_nRef,
'order_ref': d.m_strOrderRef,
'direction': d.m_nDirection,
'offset_flag': d.m_nOffsetFlag,
'price': d.m_dPrice,
'volume': d.m_nVolume,
'trade_amount': d.m_dTradeAmount,
'trade_date': d.m_strTradeDate,
'trade_time': d.m_strTradeTime,
'remark': d.m_strRemark,
'close_profit': d.m_dCloseProfit,
})
return result
# ============= Route registration =============
def make_app():
return Application([
# ContextInfo properties
(r"/api/context/info", ContextInfoHandler),
(r"/api/get/(stock_name|open_date|last_volume|total_share|svol|bvol|divid_factors|etf_info|etf_iopv|instrumentdetail|his_st_data)", StockGetHandler),
# Portfolio
(r"/api/portfolio", PortfolioHandler),
(r"/api/portfolio/positions", HoldingHandler),
(r"/api/portfolio/org/(account|order|deal|position)",OrgHandler),
(r"/api/portfolio/assets", AssetsHandler),
(r"/api/portfolio/order", OrderHandler),
(r"/api/portfolio/deal", DealHandler),
(r"/api/data/full_tick", FullTickHandler),
(r"/api/trade/ipo_data", IpoDataHandler),
(r"/api/trade/cancel_by_id", CancelByIdHandler),
(r"/api/trade/passorder", PassorderHandler),
# System
(r"/api/sys/python_version", PythonVersionHandler),
], debug=False)
def init(ContextInfo):
if not (ACCOUNT_ID or "").strip():
msg = "ACCOUNT_ID is empty; startup aborted"
logger.error(msg)
raise ValueError(msg)
if not (DATA_DIR or "").strip():
msg = "DATA_DIR is empty; startup aborted"
logger.error(msg)
raise ValueError(msg)
try:
ContextInfo.accountID = ACCOUNT_ID
ContextInfo.set_account(ACCOUNT_ID)
codes = get_pass_codes(ContextInfo.accountID)
ContextInfo.set_universe(list(codes))
# Api App
app = make_app()
app.ContextInfo = ContextInfo
app.accountID = ContextInfo.accountID
app.listen(PORT, address='0.0.0.0')
logger.info(f"ACCOUNT_ID: {ACCOUNT_ID}")
logger.info(f"DATA_DIR: {DATA_DIR}")
logger.info(f"TOKEN: {TOKEN}")
logger.info(f"Initialized symbol universe with {len(codes)} instruments")
logger.info(f"QMT HTTP Server started at http://0.0.0.0:{PORT} (all APIs loaded)")
IOLoop.current().start()
except Exception as e:
logger.exception(f"server start failed: {e}")

File diff suppressed because it is too large Load Diff

438
api/qmt_rest_rele.py Normal file
View File

@@ -0,0 +1,438 @@
# -*- coding: utf-8 -*-
import json
import locale
import os
import sys
from urllib.request import Request, urlopen
from tornado.web import Application, RequestHandler, HTTPError
from tornado.ioloop import IOLoop
import logging
# Configuration
ACCOUNT_ID = os.environ.get('QMT_ACCOUNT_ID', '')
DATA_DIR = os.environ.get('QMT_DATA_DIR', r'D:\qmt_strategy_data')
TOKEN="QMTbyYanweidong"
PORT = 10086
PASS_CODES_URL = "http://139.224.247.176:13499/a/pass_codes"
# ===================================
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
locale.setlocale(locale.LC_CTYPE, 'chinese')
def safe_call(func, *args, **kwargs):
try:
return func(*args, **kwargs)
except HTTPError:
raise
except Exception as e:
raise HTTPError(
500,
reason="QMT: %s call failed." % func.__name__,
) from e
def get_pass_codes(account_id):
request = Request(
PASS_CODES_URL,
headers={"Accept": "application/json", "User-Agent": "big-qmt/1"},
)
with urlopen(request, timeout=10) as response:
payload = json.load(response)
remote_codes = payload.get("data")
if not isinstance(remote_codes, list):
raise ValueError("pass_codes response data must be an array")
positions = safe_call(
get_trade_detail_data, account_id, 'stock', 'position'
) or []
position_codes = [
position.m_strInstrumentID + '.' + position.m_strExchangeID
for position in positions
]
codes = []
seen = set()
for code in remote_codes + position_codes:
code = str(code).strip()
if code and code not in seen:
seen.add(code)
codes.append(code)
return codes
# ============= BaseHandler =============
AUTH_EXEMPT = set()
def no_auth(cls):
AUTH_EXEMPT.add(cls)
return cls
class BaseHandler(RequestHandler):
def prepare(self):
if self.__class__ not in AUTH_EXEMPT:
token = self.request.headers.get('X-Token')
if token != TOKEN:
raise HTTPError(500, "Authentication failed: invalid or missing token")
def set_default_headers(self):
self.set_header("Content-Type", "application/json; charset=utf-8")
def write_error(self,status_code, **kwargs):
self.finish(self._reason)
def ctx(self):
return self.application.ContextInfo
def acc(self):
return self.application.accountID
def write_json(self, data, default=None):
self.write(json.dumps(
data,
separators=(',', ':'),
ensure_ascii=False,
default=default,
))
# ============= 1. ContextInfo properties =============
# "/api/v2/context/info"
class ContextInfoHandler(BaseHandler):
def get(self):
ctx = self.ctx()
data = {
"period": ctx.period,
"barpos": ctx.barpos,
"time_tick_size": ctx.time_tick_size,
"stockcode": ctx.stockcode,
"dividend_type": ctx.dividend_type,
"market": ctx.market,
"do_back_test": ctx.do_back_test,
"benchmark": ctx.benchmark,
"capital": ctx.capital,
"timetag":ctx.timetag,
"universe": ctx.get_universe(),
}
self.write_json(data)
# ============= 2. Data queries (ContextInfo get_*) =============
STOCK_HANDLER = {
# handler_type: (method_name, use_context)
"stock_name": ("get_stock_name", True),
"open_date": ("get_open_date", True),
"last_volume": ("get_last_volume", True),
"total_share": ("get_total_share", True),
"svol": ("get_svol", True),
"bvol": ("get_bvol", True),
"divid_factors": ("get_divid_factors", True),
"etf_info": ("get_etf_info", False),
"etf_iopv": ("get_etf_iopv", False),
"instrumentdetail": ("get_instrumentdetail", True),
"his_st_data": ("get_his_st_data", True),
}
# "/api/v2/get/*" Stock-related single-symbol queries
class StockGetHandler(BaseHandler):
def get(self, handler_type):
# 快速路径:配置查找
cfg = STOCK_HANDLER.get(handler_type)
if not cfg:
raise HTTPError(500, "Unknown API")
# 参数验证
query_vals = self.get_query_argument("stock_code", "").strip()
if not query_vals:
raise HTTPError(500, "stock_code required")
# 方法调用
method_name, use_context = cfg
method = getattr(self.ctx(), method_name) if use_context else globals()[method_name]
result = safe_call(method, query_vals)
# 响应
self.write_json({
"stock_code": query_vals,
"ref": result
}, default=str)
# Aggregate assets, positions, and orders in one request.
class PortfolioHandler(BaseHandler):
def get(self):
account_id = self.acc()
account_data = safe_call(get_trade_detail_data, account_id, 'stock', 'account')
positions = safe_call(get_trade_detail_data, account_id, 'stock', 'position') or []
orders = safe_call(get_trade_detail_data, account_id, 'stock', 'order') or []
result = {
"assets": format_assets(account_data),
"positions": format_holding(positions),
"orders": [fixed_fields(order) for order in orders],
}
self.write_json(result)
# get_trade_detail_data('position') - Query positions in the wrapped format
class HoldingHandler(BaseHandler):
def get(self):
positions = safe_call(get_trade_detail_data, self.acc(), 'stock', 'position') or []
holding = format_holding(positions)
self.write_json({"data": holding})
class OrgHandler(BaseHandler):
def get(self, handler_type):
result = safe_call(
get_trade_detail_data, self.acc(), 'stock', handler_type
) or []
data = []
for item in result:
fields = {}
for name in dir(item):
if name.startswith('_'):
continue
value = getattr(item, name)
if not callable(value):
fields[name] = value
data.append(fields)
self.write_json({"data": data})
# get_trade_detail_data('account') - Query account assets
class AssetsHandler(BaseHandler):
def get(self):
_data = safe_call(get_trade_detail_data, self.acc(), 'stock', 'account')
self.write_json(format_assets(_data))
class OrderHandler(BaseHandler):
def get(self):
ret = safe_call(get_trade_detail_data, self.acc(), 'stock', 'order')
if ret is None:
ret = []
result = [fixed_fields(obj) for obj in ret]
self.write_json(result)
class DealHandler(BaseHandler):
def get(self):
deals = safe_call(get_trade_detail_data, self.acc(), 'stock', 'deal') or []
rets = [fixed_fields(deal) for deal in deals]
self.write_json({"deals": rets})
# ContextInfo.get_full_tick() - Get full tick data
class FullTickHandler(BaseHandler):
def post(self):
data = json.loads(self.request.body)
stocks = data.get('stocks', [])
#if not stocks:
# raise HTTPError(400, "need args stocks")
ret = safe_call(self.ctx().get_full_tick, stocks)
if not ret:
raise HTTPError(500, "Failed to get tick data")
self.write_json(ret, default=str)
# passorder() - Submit a general trading order
class PassorderHandler(BaseHandler):
def post(self):
try:
data = json.loads(self.request.body)
opType = int(data['opType'])
orderType = int(data.get('orderType', 1101))
stockCode = data['stockCode']
pr_type = int(data.get('prType', 11))
price = float(data['price'])
volume = int(data['volume'])
quickTrade = int(data.get('quickTrade', 2))
strategy_name = str(data.get('strategyName', '')).strip()
order_id = str(data.get('orderId', '')).strip()
except (json.JSONDecodeError, KeyError, TypeError, ValueError) as e:
raise HTTPError(400, reason="Invalid order parameters: %s" % e) from e
# QMT stores strategyName in the order remark; preserve the signal key and local order ID.
try:
order_ref = passorder(opType, orderType, self.acc(), stockCode, pr_type, price, volume, strategy_name, quickTrade,order_id, self.ctx())
except HTTPError:
raise
except Exception as e:
logger.exception("passorder failed")
raise HTTPError(502, reason="QMT order submission failed") from e
self.write_json({
"status": "success",
"opType": opType,
"stockCode": stockCode,
"strategy_name": strategy_name,
"local_order_id": order_id,
"order_ref": str(order_ref)
})
class CancelByIdHandler(BaseHandler):
"""Cancel an order by its actual system order ID."""
def post(self):
data = json.loads(self.request.body)
order_id = str(data.get('order_id', '')).strip()
if not order_id:
raise HTTPError(400, "order_id cannot be empty")
cancelable = safe_call(can_cancel_order, order_id, self.acc(), 'stock')
if not cancelable:
self.write_json({
"status": "failed", "order_id": order_id,
"message": "Order does not exist or cannot currently be canceled"
})
return
result = safe_call(cancel, order_id, self.acc(), 'stock', self.ctx())
self.write_json({
"status": "success" if result is not False else "failed",
"order_id": order_id,
})
# get_ipo_data() - Get today's new stock and bond offerings
class IpoDataHandler(BaseHandler):
def post(self):
data = json.loads(self.request.body)
typ = data.get('type', 'STOCK')
ret = safe_call(get_ipo_data, typ)
self.write_json(ret)
# sys: Python version information
class PythonVersionHandler(BaseHandler):
def get(self):
version_info = {
"python_version": sys.version,
"python_version_info": {
"major": sys.version_info.major,
"minor": sys.version_info.minor,
"micro": sys.version_info.micro,
"releaselevel": sys.version_info.releaselevel,
"serial": sys.version_info.serial,
}
}
self.write_json(version_info)
def format_assets(account_data):
info = account_data[0] if account_data else None
if not info:
raise HTTPError(500, "Failed to get account data")
return {
"total": round(info.m_dBalance, 2),
"available": round(info.m_dAvailable, 2),
}
def format_holding(positions):
holding = {}
for position in positions:
stock = position.m_strInstrumentID + '.' + position.m_strExchangeID
holding[stock] = {
'StockCode': stock,
'TradeID':position.m_strTradeID,
'StockName': position.m_strInstrumentName,
'Direction': position.m_nDirection,
'Volume': position.m_nVolume,
'OpenPrice': position.m_dOpenPrice,
'OpenCost':position.m_dOpenCost,
'FloatProfit': position.m_dFloatProfit,
'MarketValue': position.m_dMarketValue,
'StockHolder': position.m_strStockHolder,
'FrozenVolume': position.m_nFrozenVolume,
'CanUseVolume': position.m_nCanUseVolume,
'OnRoadVolume': position.m_nOnRoadVolume,
'YesterdayVolume': position.m_nYesterdayVolume,
'LastPrice': position.m_dLastPrice,
'ProfitRate': position.m_dProfitRate,
'FutureTradeType': position.m_eFutureTradeType,
'ExpireDate': position.m_strExpireDate
}
return holding
TRADE_DETAIL_FIELDS = (
'm_strOrderSysID', 'm_strInstrumentID', 'm_strExchangeID',
'm_strInstrumentName', 'm_nOffsetFlag', 'm_nOrderStatus',
'm_nVolumeTotal', 'm_nVolumeTraded', 'm_nOrderTime',
'm_strInsertDate', 'm_strInsertTime', 'm_strRemark',
'm_dPrice', 'm_dTradePrice', 'm_dTradeAmount',
)
MISSING = object()
def fixed_fields(obj, fields=TRADE_DETAIL_FIELDS):
result = {}
for field in fields:
try:
value = getattr(obj, field, MISSING)
except TypeError:
continue
if value is MISSING:
continue
if not callable(value):
result[field] = str(value)
if not result:
attrs = getattr(obj, '__dict__', {})
result = {
key: str(value) for key, value in attrs.items()
if not key.startswith('_') and not callable(value)
}
return result
# ============= Route registration =============
def make_app():
return Application([
# ContextInfo properties
(r"/api/context/info", ContextInfoHandler),
(r"/api/get/(stock_name|open_date|last_volume|total_share|svol|bvol|divid_factors|etf_info|etf_iopv|instrumentdetail|his_st_data)", StockGetHandler),
# Portfolio
(r"/api/portfolio", PortfolioHandler),
(r"/api/portfolio/positions", HoldingHandler),
(r"/api/portfolio/org/(account|order|deal|position)",OrgHandler),
(r"/api/portfolio/assets", AssetsHandler),
(r"/api/portfolio/order", OrderHandler),
(r"/api/portfolio/deal", DealHandler),
(r"/api/data/full_tick", FullTickHandler),
(r"/api/trade/ipo_data", IpoDataHandler),
(r"/api/trade/cancel_by_id", CancelByIdHandler),
(r"/api/trade/passorder", PassorderHandler),
# System
(r"/api/sys/python_version", PythonVersionHandler),
], debug=False)
def init(ContextInfo):
if not (ACCOUNT_ID or "").strip():
msg = "ACCOUNT_ID is empty; startup aborted"
logger.error(msg)
raise ValueError(msg)
if not (DATA_DIR or "").strip():
msg = "DATA_DIR is empty; startup aborted"
logger.error(msg)
raise ValueError(msg)
try:
ContextInfo.accountID = ACCOUNT_ID
ContextInfo.set_account(ACCOUNT_ID)
codes = get_pass_codes(ContextInfo.accountID)
ContextInfo.set_universe(list(codes))
# Api App
app = make_app()
app.ContextInfo = ContextInfo
app.accountID = ContextInfo.accountID
app.listen(PORT, address='0.0.0.0')
logger.info(f"ACCOUNT_ID: {ACCOUNT_ID}")
logger.info(f"DATA_DIR: {DATA_DIR}")
logger.info(f"TOKEN: {TOKEN}")
logger.info(f"Initialized symbol universe with {len(codes)} instruments")
logger.info(f"QMT HTTP Server started at http://0.0.0.0:{PORT} (all APIs loaded)")
IOLoop.current().start()
except Exception as e:
logger.exception(f"server start failed: {e}")

9
buf.gen.yaml Normal file
View File

@@ -0,0 +1,9 @@
version: v2
clean: true
inputs:
- directory: proto
plugins:
- remote: buf.build/protocolbuffers/python:v3.14.0
out: api/gen
- remote: buf.build/grpc/python:v1.62.1
out: api/gen # 必须与上面保持同一输出目录

View File

@@ -1,257 +1,43 @@
# QMT HTTP API
# QMT REST API 文档
将迅投 QMTMiniQMT / 投研版)策略进程内的 `ContextInfo`、行情、财务与交易函数,封装为 JSON HTTP 服务,供外部程序远程调用
当前实现:[`api/qmt_rest_new.py`](../api/qmt_rest_new.py)。完整接口、请求参数、返回字段及错误行为见 [API 参考](api.md)。更新日期2026-09-07
源码:[`api/QMT_API.py`](../api/QMT_API.py)
## 运行方式
完整接口清单、请求/响应字段与 curl 示例见 [api.md](./api.md)
文件运行在 QMT Python 策略宿主中,由宿主调用 `init(ContextInfo)` 并提供交易和查询内置函数,不能作为普通独立 HTTP 脚本启动
---
启动流程:
## 1. 它是什么
1. 检查账户和数据目录配置非空,调用 `ContextInfo.set_account()`
2.`PASS_CODES_URL` 获取股票池,响应的 `data` 必须是数组。
3. 合并远端股票代码与账户当前持仓,去重后设置 `ContextInfo.set_universe()`
4. 启动 Tornado监听 `0.0.0.0:10086`
`QMT_API.py` **不是**可独立 `python QMT_API.py` 启动的普通脚本。它是一份 QMT Python 策略:
远端请求超时为 10 秒,股票池请求、解析或持仓查询失败会阻止服务启动。当前不读取本地 `pass_codes.json`
- QMT 加载策略后调用 `init(ContextInfo)`
- `init` 绑定资金账号、加载股票池、创建 Tornado `Application`,并在当前进程里 `listen` + `IOLoop.start()`
- 此后外部 HTTP 客户端通过 `X-Token` 鉴权,调用本机(或同网段)上的 REST 接口。
- 接口内部再转调 QMT 内置对象:`ContextInfo.*``passorder``get_trade_detail_data` 等。
## 配置与依赖
因此:服务生命周期 = 策略生命周期。策略停止HTTP 一并停止。
```
外部程序 --HTTP JSON--> Tornado (0.0.0.0:10086)
|
v
QMT 策略进程
ContextInfo / 交易账户
```
---
## 2. 运行环境
| 项 | 要求 |
| --- | --- |
| 宿主 | 迅投 QMT需启用 Python 策略) |
| 解释器 | QMT 自带的 Python源码文件编码为 **GBK** |
| 第三方库 | `tornado`(需在 QMT Python 环境中可用) |
| 标准库 | `json` / `os` / `datetime` / `pathlib` / `logging` / `locale` |
| 操作系统 | 源码调用 `locale.setlocale(locale.LC_CTYPE, 'chinese')`,面向 **Windows 中文环境** |
QMT 内置符号(由策略宿主注入,源码中未 import
- `ContextInfo` 及其方法(`get_market_data``get_universe` 等)
- 交易:`passorder``algo_passorder``smart_algo_passorder``order_*``buy_open` / `sell_open`
- 查询:`get_trade_detail_data``get_value_by_order_id``can_cancel_order``cancel`
- 其它:`get_open_date``timetag_to_datetime``ext_data``get_etf_info`
---
## 3. 配置
源码顶部与 `init()` 使用的配置如下。
| 名称 | 来源 | 默认值 | 说明 |
| --- | --- | --- | --- |
| `QMT_ACCOUNT_ID` | 环境变量 | `''` | 资金账号,写入 `ContextInfo.accountID``set_account` |
| `QMT_DATA_DIR` | 环境变量 | `D:\qmt_strategy_data` | **意图**上的数据目录;见下方「已知问题」 |
| `TOKEN` | 源码硬编码 | `QMTbyYanweidong` | HTTP 鉴权口令,请求头 `X-Token` 必须与之相等 |
| `PORT` | 源码硬编码 | `10086` | 监听端口;绑定地址为 `0.0.0.0` |
启动时还会读取:
```
{数据目录}/pass_codes.json
```
内容须为 JSON 数组(股票代码列表),用于 `ContextInfo.set_universe(...)`。该文件缺失或无法解析会导致 `init` 失败HTTP 服务起不来。
---
## 4. 接入步骤
1. 在 QMT 中配置 Python 策略,入口文件指向 `api/QMT_API.py`
2. 准备数据目录,放入 `pass_codes.json`,例如:
```json
["000001.SZ", "600000.SH"]
```
3. 设置环境变量 `QMT_ACCOUNT_ID`(以及你实际使用的数据目录变量,见已知问题)。
4. 启动策略。日志出现类似:
```
QMT HTTP Server 启动于 http://0.0.0.0:10086 (全部API已加载)
```
5. 用任意 HTTP 客户端调用。所有业务接口默认需要鉴权:
```http
X-Token: < TOKEN >
Content-Type: application/json
```
快速探活(需 Token
```bash
curl -s -H "X-Token: QMTbyYanweidong" http://127.0.0.1:10086/api/context/period
```
关闭服务:
```bash
curl -s -X POST -H "X-Token: QMTbyYanweidong" http://127.0.0.1:10086/api/sys/shutdown
```
`ShutdownHandler` 会在响应后再 `IOLoop.stop()`Tornado 事件循环退出。
---
## 5. 鉴权与协议约定
### 5.1 鉴权
`BaseHandler.prepare()`
- 请求头 `X-Token` 必须等于源码中的 `TOKEN`
- 否则抛出 `HTTPError(401, "认证失败token 无效或缺失")`
- 源码定义了 `@no_auth` 装饰器,但 **没有任何 Handler 使用它**,包括 `/api/sys/python_version``/api/sys/shutdown`
### 5.2 请求
- GET无 Body参数都在路径中本服务 GET 接口目前均无 Query
- POSTBody 必须是 **合法 JSON 对象**。多数 POST 一上来就 `json.loads(self.request.body)`,空 Body 会直接异常。
- 多标的字段(如 `stock_code``stocks``stock_list``fieldList`)一般为 **逗号分隔字符串**,服务端再 `split(',')` + `strip()`
### 5.3 响应
- 默认 `Content-Type: application/json; charset=utf-8`
- 成功:各接口自定义 JSON见 [api.md](./api.md))。
- 失败:`write_error` 统一为:
```json
{"error": "<reason>", "status_code": 401}
```
常见状态码:
| 码 | 场景 |
| --- | --- |
| 400 | 缺参、下单参数不合法 |
| 401 | Token 缺失或错误 |
| 500 | QMT 调用失败(部分接口在 `safe_call` 返回 `None` 后主动抛出) |
`safe_call` 会吞掉底层异常并打日志,返回 `None`。调用方看到的可能是 `null` 字段,也可能是 500取决于该 Handler 有没有对 `None` 再处理。
### 5.4 HTTP 方法习惯
- 只读、无参的 Context / 判定 / 系统信息:多数为 **GET**
- 带 JSON Body 的查询与全部交易: **POST**
- 同一资源没有 REST 语义上的 PUT/PATCH/DELETE。
---
## 6. 接口分组
路由在 `make_app()` 中注册,当前约 **100+** 条。按前缀划分:
| 前缀 | 用途 | 文档 |
| 配置 | 来源 | 默认 / 行为 |
| --- | --- | --- |
| `/api/v2/*` | 持仓 / 资产(与旧接口共用 Handler | [api.md §1](./api.md#1-兼容层--v2) |
| `/api/holding` `/api/money/*` `/api/order/*` | 旧版买卖、资金、撤单、成交 | 同上 |
| `/api/context/*` | 策略上下文属性 | [§2](./api.md#2-策略上下文-apicontext) |
| `/api/data/*` | 行情、财务、期权、订阅 | [§3](./api.md#3-数据查询-apidata) |
| `/api/check/*` | 停牌、板块、K 线判定 | [§4](./api.md#4-判定-apicheck) |
| `/api/trade/*` | 股票/算法/期货下单、任务、账户查询 | [§5](./api.md#5-交易-apitrade) |
| `/api/ext/*` | 扩展数据与因子引用 | [§6](./api.md#6-扩展引用-apiext) |
| `/api/sys/*` | Python 版本、关停服务 | [§7](./api.md#7-系统-apisys) |
| `ACCOUNT_ID` | 环境变量 `QMT_ACCOUNT_ID` | 默认空,必须配置 |
| `DATA_DIR` | 环境变量 `QMT_DATA_DIR` | 默认 `D:\qmt_strategy_data`;当前只检查非空并记录日志,不读写目录 |
| `TOKEN` | 源码常量 | 请求头 `X-Token` 必须与其一致 |
| `PORT` | 源码常量 | `10086` |
| `PASS_CODES_URL` | 源码常量 | 远端股票池地址,见实现文件 |
兼容层买卖是对 `passorder` 的薄封装:
源码编码为 UTF-8依赖 `tornado`。启动时设置 `locale.LC_CTYPE``chinese`,需要环境支持该 locale。
- `POST /api/order/buy``passorder(23, 1101, ...)`(买入)
- `POST /api/order/sell``passorder(24, 1101, ...)`(卖出)
- 完整下单请用 `POST /api/trade/passorder`(可自定义 `opType` / `orderType` / `prType` / `quickTrade`
## 接入
账户查询里的 `account` 字段默认 `"stock"`,也会传到 `get_trade_detail_data` 的账户类型参数。
在 QMT 中配置账户环境变量并加载策略,启动后可用 PowerShell 查询:
---
## 7. 回调与落盘(当前未挂接)
源码后半定义了主推回调,用于把账户/委托/成交/持仓写成 JSON 文件:
| 函数 | 意图文件名 |
| --- | --- |
| `account_callback` | `acount_%s.json`(拼写为 acount |
| `order_callback` | `order_%s.json` |
| `deal_callback` | `deal_%s.json` |
| `position_callback` | `position_%s.json` |
| `orderError_callback` | 仅 `print` |
`init()` **没有** 调用 `ContextInfo` 的回调注册接口,因此这些函数默认不会执行。即便注册,`write_json` 本身也存在未定义变量问题(见下节),落盘路径目前不可靠。
---
## 8. 源码审视(使用前必读)
以下为对照 `QMT_API.py` 的事实,不是「建议优化清单」。接入前应按此理解行为边界。
### 8.1 数据目录变量不一致
```python
DATA_DIR = os.environ.get('QMT_DATA_DIR', 'D:\\qmt_strategy_data')
# ...
Path(QMT_DATA_DIR) / "pass_codes.json"
```powershell
$apiHeaders = @{ 'X-Token' = '<服务端 TOKEN>' }
Invoke-RestMethod -Uri 'http://127.0.0.1:10086/api/context/info' -Headers $apiHeaders
```
环境变量读入的是 `DATA_DIR``init` / `write_json` 使用的是 **从未赋值的** `QMT_DATA_DIR`。在普通 Python 里会 `NameError`。若你的 QMT 环境没有额外注入同名全局量,策略会在启动阶段失败
当前没有 `/api/v2` 前缀,也没有 HTTP 关停路由。服务运行在策略进程中
### 8.2 `write_json` 不可用
资产、持仓、委托、成交使用小写下划线字段;行情和原始查询保留 QMT 字段;下单请求仍用驼峰字段。
- 使用未定义的 `current.strftime`(应为 `now`
- `order_id` 无默认值,但 `account_callback` / `position_callback` 只传了两个参数。
- `file_key` 模板与实参个数不一定匹配。
### 8.3 Token 硬编码且监听全网卡
`TOKEN` 写死在源码里;`listen(..., address='0.0.0.0')` 对所有网卡开放。任何能打到 `10086` 且知道 Token 的客户端都可以下单、撤单、关停服务。不要把该端口暴露到公网。
### 8.4 错误被吞掉
`safe_call` 捕获全部异常后返回 `None`。部分查询接口仍会把 `null` 当成功响应返回调用方不易区分「没数据」和「QMT 抛错」。
### 8.5 编码
文件头 `# -*- coding: gbk -*-`。用 UTF-8 无 BOM 保存可能在 QMT 中出现中文注释/字符串解码问题。
### 8.6 规则撤单语义很窄
`POST /api/order/cancel_order` 不是「按委托号撤单」,而是:
- 股票代码(`代码.市场`)完全匹配,且
- `m_nVolumeTotal + m_nVolumeTraded == volume`,且
- `can_cancel_order` 为真
才发出 `cancel`。按委托号查询/判断请用 `/api/trade/value_by_order_id``/api/trade/can_cancel_order`。源码里没有单独的「按 orderId 撤单」HTTP 封装(全部撤单走 `/api/order/cancel_all`)。
---
## 9. 仓库结构
```
big-qmt/
├── api/
│ └── QMT_API.py # QMT 策略 + HTTP 服务(唯一实现)
├── docs/
│ ├── README.md # 本文件:架构、接入、约定、风险
│ └── api.md # 全量 HTTP 接口说明
└── README.md # 仓库占位
```
---
## 10. 相关文档
- [HTTP API 参考](./api.md)
- 迅投 QMT Python 策略官方函数手册(`passorder``opType` / `prType` 等枚举以官方文档为准;本仓库只记录本封装实际传入的值)
鉴权失败目前返回 HTTP 500接口错误正文通常是文本撤单失败可能返回 HTTP 200 并标记 `status=failed`。详见 [API 参考](api.md)

File diff suppressed because it is too large Load Diff

190
docs/arch/state.py Normal file
View File

@@ -0,0 +1,190 @@
"""底仓、补仓记录与待确认订单的 JSON 存储。"""
from __future__ import annotations
import json
import logging as log
import time
from dataclasses import asdict, dataclass, field, replace
from pathlib import Path
from threading import RLock
from typing import Any
from sdk import OrderItem, PositionItem
PENDING_TIME_OUT = 3600
@dataclass(slots=True)
class StateItem:
code: str
base_order_id: str = ""
base_qty: int = 0
base_cost: float = 0.0
added_order_id: str = ""
added_num: int = 0
added_qty: int = 0
added_cost: float = 0.0
@dataclass(slots=True)
class PendingOrder:
order_id: str
code: str
kind: str
pre_qty: int
submit_at: int = field(default_factory=lambda: int(time.time()))
class State:
def __init__(self, data_dir: str | Path, strategy: str, account_id: str) -> None:
self.path = Path(data_dir) / f"{strategy}_{account_id}_state.json"
self.lock = RLock()
self.items: dict[str, StateItem] = {}
self.pending: list[PendingOrder] = []
self.IsModify = False
self._load()
def _load(self) -> None:
"""启动时读取 JSON 中的持仓记录和待确认订单。"""
if self.path.is_file():
raw = json.loads(self.path.read_text(encoding="utf-8"))
self.items = {code: StateItem(**item) for code, item in raw["items"].items()}
self.pending = [PendingOrder(**item) for item in raw["pending"]]
now = int(time.time())
for pending in list(self.pending):
if now - pending.submit_at >= PENDING_TIME_OUT:
self.pending.remove(pending)
self.IsModify = True
log.info("[状态] 清理超时 pending代码=%s,订单=%s", pending.code, pending.order_id)
self.save()
def get(self, code: str) -> StateItem:
with self.lock:
return self.items[code]
def new_order(self, order: PendingOrder) -> None:
"""提交前保存待确认订单,同一证券已有 pending 时跳过。"""
with self.lock:
if order:
self.pending.append(order)
self.IsModify = True
self.save()
def merged_order(self, orders: list[OrderItem]) -> dict[str, dict[str, Any]]:
"""按证券代码、本地订单号合并,返回成交数量、金额、均价和状态。"""
merged: dict[str, dict[str, Any]] = {}
for order in orders:
if not order.local_order_id:
continue
if not order.local_order_id in merged.keys():
strStatus = "ING"
if order.status == "56":
strStatus = "OK"
merged[order.local_order_id] = {
"code":order.code,
"qty": order.traded_volume,
"cost": order.trade_price,
"status": order.status,
"merged_status":strStatus
}
continue
old = merged[order.local_order_id]
strStatus = "ING"
if old["status"] == order.status == "56":
strStatus = "OK"
totalQty = old["qty"]+order.traded_volume
# 合计成交数量为零,跳过,避免除零。
if totalQty == 0:
continue
# 有成交数量但缺少成交金额,跳过,避免拉低成本。
if order.traded_volume > 0 and not order.trade_amount:
continue
cost = ((old["qty"]*old["cost"])+order.trade_amount) / totalQty
merged[order.local_order_id]["qty"] = totalQty
merged[order.local_order_id]["cost"] = cost
merged[order.local_order_id]["merged_status"] = strStatus
return merged
def reconcile(self, positions: list[PositionItem], orders: list[OrderItem]) -> None:
"""合并订单 → 对齐 pending 和持仓 → 保存 JSON。"""
# 1. 合并同一本地订单的成交数据。
merged = self.merged_order(orders)
with self.lock:
# 2. 按本地订单号查找合并结果,核对证券代码后写入底仓或补仓。
# 只留下尚未完成确认的订单,避免循环中反复查找、删除列表元素。
remaining: list[PendingOrder] = []
for pending in self.pending:
code = pending.code
result = merged.get(pending.order_id)
if result is None or result["code"] != code:
# 按现有规则:本轮快照中没有对应订单,就清理 pending。
self.IsModify = True
log.info("[状态] 清理无对应订单的 pending代码=%s,订单=%s", code, pending.order_id)
continue
if result["merged_status"] != "OK" or result["qty"] != pending.pre_qty:
# 未全部成功,或拆单快照的数量尚未齐全,留到下轮确认。
remaining.append(pending)
continue
qty, cost = result["qty"], result["cost"]
# 只记录实际成交。
if qty:
item = self.items.get(code, StateItem(code))
if pending.kind == "base":
# 底仓:记录本次成交数量和实际均价。
item = replace(item, base_order_id=pending.order_id,
base_qty=qty, base_cost=cost)
else:
# 补仓:次数加一,数量累加,成本按成交数量加权。
total_qty = item.added_qty + qty
total_amount = item.added_qty * item.added_cost + qty * cost
item = replace(item, added_order_id=pending.order_id,
added_num=item.added_num + 1,
added_qty=total_qty, added_cost=total_amount / total_qty)
self.items[code] = item
self.IsModify = True
log.info("[状态] 对账结束,代码=%s,订单=%s,计划=%d,成交=%d",
code, pending.order_id, pending.pre_qty, qty)
self.pending = remaining
# 未记录且没有待确认订单的持仓,作为首次接管的底仓导入。
pending_codes = {pending.code for pending in remaining}
for position in positions:
code = position.stock_code
if not code or position.volume <= 0:
continue
item = self.items.get(code)
if item is None:
if code not in pending_codes:
self.items[code] = StateItem(code=code, base_order_id=position.trade_id,
base_qty=position.volume, base_cost=position.open_price)
self.IsModify = True
continue
# 已有记录只报告数量差异,不用总持仓覆盖底仓/补仓的划分。
recorded_qty = item.base_qty + item.added_qty
if recorded_qty != position.volume:
log.info("[状态] 持仓数量差异,代码=%s,记录=%d,实际=%d",
code, recorded_qty, position.volume)
# 3. 本轮统一保存;没有修改时 save() 不写文件。
self.save()
def save(self) -> None:
with self.lock:
if not self.IsModify:
return
self.path.parent.mkdir(parents=True, exist_ok=True)
temporary = self.path.with_suffix(self.path.suffix + ".tmp")
payload = dict(items={code: asdict(item) for code, item in self.items.items()},
pending=[asdict(item) for item in self.pending])
temporary.write_text(json.dumps(payload, ensure_ascii=False, indent=2, allow_nan=False) + "\n", encoding="utf-8")
temporary.replace(self.path)
self.IsModify = False

0
docs/bug.md Normal file
View File

View File

@@ -0,0 +1,68 @@
# IPO 策略审计
日期2026-09-05。范围当前 `py-client/strategy/ipo`,以及直接相关的 SDK、文件锁、调度入口和 `api/qmt_rest_new.py`。仅审计,不修改策略、不调用真实交易接口。
## 结论与分级
本次发现 **P00 项P12 项P23 项**。只列当前问题,不沿用历史报告的已修复项。
- P0需立即处理的全面故障或迫切严重风险。本次未确认此类问题。
- P1会影响申购防重或漏申购建议实盘前优先处理。
- P2特定部署或异常数据条件下影响可靠性安排修复。
## P1-1提交结果不确定时没有防重记录下一次任务可能再次申购
- 位置:`py-client/strategy/ipo/boot.py:47``:51``:59``py-client/libs/lockfile.py:9`
- 证据:先检查文件,再调用 `passorder()`,仅在正常返回后写锁;没有提交前记录、本地订单号或券商订单查询。异常只记录后继续。
- 触发:券商已收到申购,但响应超时或无法解码;或者提交后进程退出、写锁失败。下次 14:00 调度或重启后的任务仍看不到标记。
- 影响:再次发出申购请求;是否拒绝重复申购由柜台决定,不能认为必然重复成交。
- 验证:模拟“已提交但响应超时”,连续执行两次,提交调用为 2 次,锁记录为 0。
- 建议以账户、证券和申购批次生成稳定标识提交前记录待确认结果不确定时先查券商委托不直接重发。SDK 对下单 POST 本身没有自动重试,但不能解决跨任务重发。
## P1-2接口正常返回即永久锁定没有申购结果核对
- 位置:`boot.py:51``:66``api/qmt_rest_new.py:237` 起的 `PassorderHandler`
- 证据:策略不检查返回内容,正常返回就写入 `LOCK`。服务端在底层 `passorder()` 没有抛异常时返回 `status=success`,这不是后续券商成交或有效申购状态。任务不查委托、拒单或可申购额度变化。
- 触发:请求返回正常,但后续发生柜台拒单、数量不符合要求等;或者响应内容未能证明有效申购。
- 影响:标记一直存在,后续任务直接跳过,无法补救当天漏申购。
- 验证:模拟正常响应后第二次执行,提交次数仍为 1即便后续申购失败现有流程也无状态入口解除锁。
- 建议:区分“待确认”与“申购确认成功”;对明确拒绝可恢复尝试,对结果未知继续保留待确认。仅检查 HTTP 成功或服务端 success 字段不足以替代券商对账。
## P2-1锁键没有账户隔离且检查和创建不是原子操作
- 位置:`boot.py:47``libs/lockfile.py:9``:14``main.py``check_single_instance()` 与调度配置。
- 证据:路径仅为 `qmt_data_dir / 股票代码.lock`,不包含账户;`is_file()` 与普通 `write_text()` 分离,也没有互斥。
- 触发:不同账户共用同一数据目录,一个账户的标记使另一个账户跳过;多个项目副本或其他入口同时执行时,也可能都通过检查后提交。
- 影响:跨账户漏申购,或并发重复请求。
- 边界:主入口已有项目路径级单实例保护,调度器设置 `max_instances=1`,因此不把正常单入口运行报告为必然并发;这些保护不能覆盖不同项目路径或独立调用。
- 建议:锁键至少包含账户与证券,明确批次生命周期;如果支持多个进程共享目录,使用原子占位并结合待确认对账。
## P2-2异常 IPO 响应被 SDK 静默当作无候选
- 位置:`py-client/sdk/trade.py:47``:54``api/qmt_rest_new.py:278` 起的 `IpoDataHandler`
- 证据:`ipo_data()` 对非列表返回值直接返回空列表;服务端直接转发底层 IPO 查询结果。策略也不记录候选数量或响应结构异常。
- 触发:接口发生结构变化、返回对象或空值,而不是约定的 `list[dict]`
- 影响:查询异常与正常“今天无新股”无法区分,任务表面正常结束但可能遗漏申购。
- 建议:严格区分合法空列表与错误结构;错误结构记录或抛异常交给任务级边界,避免静默吞掉。
## P2-3候选字段转换不能保证代码及数值有效
- 位置:`boot.py:38``:45`
- 证据:`stock=None` 被转成字符串 `"None"`;代码直接用于文件路径,没有验证是否包含路径分隔符。`float('nan') <= 0` 为假,非有限价格可越过正数检查;`int()` 会截断非整数浮点额度。
- 影响:异常数据产生错误代码请求、非有限价格编码异常、错误数量或非预期锁路径。逐项捕获可隔离异常,但不能让错误数据变正确。
- 建议:对代码做最小格式约束,价格要求有限正数,数量要求正整数而非静默截断。不要把 API 数据直接视为安全路径片段。
- 边界:该风险依赖异常接口数据,不断言当前接口实际返回这些值。
## 待确认行为(不计入问题数量)
- 仅在 10:00、14:00 调度14:00 后启动不补跑当天任务,短暂接口失败也没有同一时段内重试。是否需要启动补跑或有限重试取决于业务要求。
- `trading_time()` 只检查工作日及时间段,不检查交易所节假日;不是完整交易日历。本次不恢复历史上已明确不需要的交易日校验。
- `.BJ` 被主动跳过,仅请求 `STOCK`;视为当前范围选择,不将未参与北交所或债券申购直接判为缺陷。
- `maxPurchaseNum` 被直接用作委托数量,没有结合账户专属可申购额度进一步计算。其究竟是发行上限还是账户可用额度,需要接口实际契约确认,本次不猜测外部业务含义。
## 验证范围
- 两个 IPO 源文件语法检查通过。
- 使用提取的真实任务函数及内存模拟客户端、锁集合,验证响应超时重发和正常响应后跳过;四次模拟均执行客户端退出方法。
- 列表逐行解析、候选级异常隔离、任务级异常捕获、客户端上下文释放均已检查。
- 未执行真实申购、撤单、外部网络请求或交易所规则核验;未创建测试锁文件。模拟验证不代表端到端实盘验收。

164
docs/ipo-trend-audit.md Normal file
View File

@@ -0,0 +1,164 @@
# IPO / Trend 当前代码审计
> 后续实现更新:已按要求精简为 JSON v3订单终态后一次记账见 [trend-state.md](trend-state.md)。T12 的持仓缺席自动删除已移除T13 的提交前写盘失败已增加占位回滚并验证。下方对应复现保留为 v2 历史证据。其余未修复项仍需单独处理v3 不再保存子单历史及成交增量,旧版本迁移与清仓后重新开仓的操作边界见新说明。
审计日期2026-09-05本轮重新读取当前工作树重点复核 JSON v2 状态机。范围为 `py-client/strategy/{ipo,trend}` 及直接依赖,不包含 ZT 策略。以下路径相对于 `py-client/`,服务端路径从仓库根目录起算。
本轮只更新审计文档,未修改策略源码。已清理的 tests/*.py 不重建;采用临时目录和 mock 进行隔离复现,没有调用真实柜台。本文取代上一轮联合审计的当前结论;状态格式说明见 [trend-state.md](trend-state.md)。
## 结论
新的 pending 与业务账本分离、补仓首次成交计数、预开仓数量落盘、完整订单快照对账均已接入。但尚不能据此认定状态恢复与异常生命周期完整:空持仓快照仍可清空历史补仓记录;确定未提交/未受理的订单可能永久占用 pending撤单异常仍可阻断整轮状态对账。
优先处理 T12、T13、T14、T15。下文沿用原编号新发现从 T12 起编号,不把已解决的问题重复列为当前缺陷。
## 新发现Trend
### T12【高】一次空持仓快照会清除已完成账本恢复后补仓次数归零
位置:`strategy/trend/state.py:212``state.py:124`;入口 `boot.py:182`
证据:对所有“曾出现于持仓、当前不在持仓、无 pending”的证券立即删除 items 与 seen_positions。持仓下次恢复时按首次接管重新建立底仓added_num 从零开始。SDK 的组合解析还会将缺失 positions 字段视为空字典。
复现 R1已有底仓并完成一次补仓 → reconcile([], []) → 原记录被删除 → 持仓恢复 → added_num 从 1 变成 0。
影响:历史补仓量价丢失,亏损补仓档位可能重新使用。这与已修复的“空订单快照丢 pending”是不同路径。当前文档已声明依赖持仓快照完整但代码没有验证此条件。
建议:持仓缺席进入核查流程,不立即删除账本;结合卖出成交、明确清仓或可靠的持仓二次查询确认后结束证券生命周期。缺失字段不能自动视为有效空账户。
### T13【高】提交前写盘失败会留下实际上未发送的 pending
位置:`strategy/trend/state.py:79``order.py:114`
证据begin 先修改 items、pending、索引和 dirty再调用 save。save 抛错后没有回滚place 因异常没有调用柜台。后续 reconcile 的 save 可以把这个未发送记录写入磁盘。
复现 R2模拟 Path.write_text 抛出磁盘错误;柜台调用次数为 0但 busy(A) 为真;磁盘恢复后执行 reconcile 并重载文件busy(A) 仍为真。
影响该证券持续被阻止买入后续没有真实订单可供终态对账180 秒日志也不会解除锁定。
建议:将提交前登记视为内存/磁盘事务,明确写盘失败时回滚本次占位,或记录“确定未发送”并走安全撤销流程;与请求发出后结果不确定的情形区分。
### T14【高】服务端明确 HTTP 400 拒绝也被永久保留为待确认
位置:`strategy/trend/order.py:127``sdk/client.py:67`、服务端 `api/qmt_rest_new.py:235`
证据:服务端参数解析失败时在实际 passorder 调用前抛 HTTP 400SDK 转为 APIErrorplace 仅记录并返回 False未调用 state.reject。当前 reject 分支只识别响应字典中的 failed/rejected而本服务端下单处理器正常只返回 success失败主要通过 HTTP 错误表达。
复现 R3mock APIError(400),清除 SimpleCache 并重新加载 State 后,证券仍 busy。
影响:确定未受理的请求无法自动恢复;纠正参数后仍不能下单。
建议:针对已确认的服务端契约区分“明确未受理”和“不确定”。明确的参数拒绝可结束 pending网络超时、502 等不能一概按失败释放。
### T15【高】撤单异常仍会阻断完整状态对账及整轮持仓管理
位置:`strategy/trend/order.py:86``boot.py:132``boot.py:182`
证据refresh 在遍历订单中直接调用 cancel_by_id一次异常会使 refresh 退出RunOnce 在组合刷新异常分支直接 return已经拿到的其他订单成交快照也不会进入 reconcile。启动阶段同类异常会导致启动失败。
复现 R5快照包含超时委托撤单接口抛错state.reconcile 调用次数为 0。
影响单个撤单故障可以持续阻断全部证券的成交更新、止盈和补仓。T6 的逐持仓隔离在这个阶段尚未执行。
建议:先完成快照采集及状态对账;撤单采用逐订单异常边界,失败保持 busy并记录业务失败结果不阻断其他订单和证券。
## 新状态机的条件性风险与恢复边界
### T16【中条件性】结束 pending 后不能处理迟到的成交金额修正
位置:`strategy/trend/state.py:107``state.py:197`
pending 结束即删除子单数据和已记账基准,后续只遍历 pending不再处理该订单。复现 R4100 股终态成交金额 1000 元完成后,再收到金额 1050 元的同订单快照base_cost 仍为 10 元。
是否实际触发取决于柜台终态金额是否可能修正,本轮没有实盘证据。若上游保证终态价格最终不变,可将其作为明确契约;否则应短期保留已完成订单的记账基准并支持差额修正。
### 迁移与核查:已知限制,不认定为新回归
位置:`strategy/trend/state.py:233`
- 旧 ING 的 expected_qty 迁移为 0完成条件要求大于 0因此必须人工核实后补齐当前没有专门的核查/解除接口。
- 旧文件可能只保存最后一次补仓量价,新累计 added_amount 不能凭空恢复全部历史;迁移有备份和告警,但数值不能直接解释为完整历史成本。
- pending 超时仅告警,未实现按订单号主动远程查询。提交意图落盘后、请求真正发出前崩溃,也需要人工核实;这与 T13 已知写盘失败未回滚不同。
- 终态识别要求系统订单号、正确的本地标识、可汇总申报量和成交价格。如果拒单没有系统订单号,或 QMT 取消态数量语义不同于模型推定pending 可能无法自动结束。需要以真实回报核对,不能仅凭 mock 宣布兼容。
- 仅提供单进程互斥,不支持同账户同状态文件的多进程并发写入。
## 仍存在的既有问题
### T4【高】开仓和补仓缺少统一预算
位置:`strategy/trend/boot.py:191``open.py:51``positions.py:41`
两条线程各自使用资金,开仓每个信号仍按完整 buy_value 计算。补仓的不确定委托只预留本轮估算金额,下轮又按新的 available 开始,未统一扣除未确认订单的潜在占款。市场价格变化也可能超出快照估算。
建议:账户级共享预算,明确券商已冻结资金和本地尚未反映的预留,避免漏计或重复扣减。
### T5【高】自动撤单不区分策略归属
位置:`strategy/trend/order.py:74`
超时撤单遍历全账户订单,没有本地订单前缀/策略归属限制。R5 同时确认手工来源订单会被传给撤单接口。建议防重参考全账户,自动撤单仅限明确归属本策略的订单。
### T10【中策略取舍】止盈门槛会阻断已激活网格回撤
位置:`strategy/trend/positions.py:108``libs/grid_take_profit.py`
低于 minimum_profit 即返回,不再观察峰值回撤;进程重启峰值也丢失。若要求激活后持续追踪,应保存激活/峰值;若最低利润是硬性卖出门槛,应明确此行为。
### T11【中】信号仅启动加载
位置:`strategy/trend/boot.py:69``libs/signal.py:17`
首次请求失败会得到空结果,此后不刷新;运行中的新增/撤销不生效。建议按业务时效定期刷新,并区分失败与正常空信号。
## IPO 当前问题
### I1【高】下单结果未检查就写完成锁
位置:`strategy/ipo/boot.py:53`
passorder 返回值被忽略随后无条件写锁。R6 使用 failed 业务响应,仍生成 A.SH.lock。当前服务端通常将失败表达为 HTTP 异常,该路径会被捕获;但 success 仅说明底层调用未抛异常,服务端未验证字符串化的订单引用,客户端也没有确认受理。
建议依据真实受理契约核查结果;不确定时保存待核查记录,不直接标记完成。
### I2【高】检查锁—下单—写锁不原子无券商对账
位置:`strategy/ipo/boot.py:49``libs/lockfile.py:9`
并发调用可同时下单;受理后超时/写锁失败会使后续重新提交。建议账户/发行事件级原子占位与幂等标识,不确定结果先核查。
### I3【高共享目录场景】标记不区分账户或发行事件
位置:`strategy/ipo/boot.py:49`
只有证券代码作为文件名;多账户共享目录时相互阻止申购,旧标记也无法区分新的发行事件。建议加入账户与发行标识。
### I4【中】数据校验不完整
位置:`strategy/ipo/boot.py:40`
str(None) 不是空代码;非有限价格可绕过 <=0int 浮点数量会截断;证券代码直接进入路径。建议有限正价格、严格整数数量、代码格式与路径字符限制。
IPO 已正确逐条处理 list[dict],使用 with Client候选异常不终止后续候选本轮未发现这些路径退化。
## 已确认的有效修复
- T1 原空订单快照丢 pending已修复本地待确认记录独立持久化缺席不删除。T12 是持仓账本清理的另一条路径。
- T2 原过滤取消子单导致误判:已修复;启动和每轮对账均使用 portfolio.orders 完整数据。
- T3 原受理后才写订单关联:已修复;当前提交前保存计划与本地 ID。提交前失败的恢复问题另列 T13。
- T6 下单网络/解码异常与逐证券异常边界已接入。
- T7 统一 finally先关闭线程池再关闭 Client初始化异常可释放资源。
- T8 excluded_codes 在开仓与持仓管理均检查。
- T9 缺失金额导致均价低估:当前保留未定价子单,延后数量/金额记账,不按零金额算均价。
- busy_keys 与 SimpleCache 都在 busy/place 验证;补仓首次成交计一次,已完成首次接管规则与 expected_qty 持久化。
## 保留配置与性能观察
此前要求跳过的行为仍保留:大盘恒允许、不足一手强制 100 股、现金安全线仅限开仓、固定亏损档位与未生效的两个风控配置。时间退出条件仍为 >=15:00交易时间只判断工作日/时段。
状态查找使用字典/集合,无变化不写盘;但每个新买单都在 OrderBook.mutex 内序列化整个状态文件并同步写盘,同时持有 State 锁。批量下单的耗时随账本大小和磁盘延迟增长;“最高性能”没有基准测试支持。可先测每轮耗时、文件大小、持锁时间,再考虑批量意图预留与一次落盘;不可为减少写入而取消提交前持久化保障。
## 本轮验证
6 组隔离复现已执行R1 空持仓重置次数、R2 未发送订单残留、R3 HTTP 400 锁定、R4 终态后金额修正忽略、R5 撤单异常中断对账/误触手工委托、R6 IPO failed 响应写锁。
R4 属于有条件的上游契约风险;其余复现验证代码在所述输入/故障下的行为,不等价于断言柜台必然出现该故障。没有新增 tests 文件或真实网络交易请求。

99
docs/state.md Normal file
View File

@@ -0,0 +1,99 @@
# 趋势策略状态机审计
审计日期2026-08-31
## 结论
当前状态机的职责明确为:按账户和策略持久化每只证券的底仓信息与**补仓业务数据**。补仓委托提交成功后立即写入状态机是必要且正确的30 秒轮询中的对账用于用券商快照校正订单状态和接管持仓,不能替代补仓次数等业务数据的记录。
状态机主链路可运行,但补仓单被撤销、拒绝或部分成交时,没有完整的状态回写规则;这会使 `added_status``added_num` 与实际成交结果不一致。该问题应在实盘前明确处理。
## 数据模型与持久化
状态文件路径为:`{qmt_data_dir}/{strategy}_{account_id}_state.json`
每个证券对应一个 `StateItem`
| 数据 | 含义 | 写入来源 |
| --- | --- | --- |
| `base_qty``base_cost``base_status` | 首次接管时的底仓数量、成本及状态 | `sync_positions()` |
| `added_num` | 已提交的补仓次数 | 补仓委托提交成功后立即递增 |
| `added_order_id``added_qty``added_cost``added_status` | 最近一笔补仓委托及状态 | 补仓提交后写入30 秒对账尝试校正状态 |
`State.save()` 先写临时文件,再以 `replace()` 原子替换正式 JSON能避免半写入文件但不构成“券商下单 + 本地持久化”的跨系统事务。
## 运行流程
```text
启动
├─ 刷新券商订单 → OrderBook.data
├─ 查询持仓
└─ State.reconcile(持仓, 订单快照)
每 30 秒的 RunOnce
├─ 刷新订单;失败则本轮退出
├─ 查询资产、市场、持仓
├─ State.reconcile(持仓, OrderBook.data)
├─ 开仓OrderBook 方向锁防重;不写 State
└─ 补仓:下单成功 → 立即写 StateItem 并持久化
```
`OrderBook``State` 的边界如下:
- `OrderBook.lock`:进程内的同方向未决委托锁,防止同轮或相邻轮重复提交。
- `OrderBook.data`:本轮的进行中或完成订单快照,提供给 `State.reconcile()`
- `State`:跨进程保存持仓补仓层级及最近补仓记录;它不是开仓防重的唯一来源。
## 对账和清理规则
`State.reconcile()` 在每轮执行以下操作:
1. 对尚未接管的真实持仓创建状态,记录为已完成底仓。
2.`local_order_id` 匹配本地订单号;若拆单全部为状态 `56`,将底仓或补仓状态更新为 `OK`,否则保持 `ING`
3. 删除不再存在真实持仓的证券状态。
4. 保存状态文件。
这意味着:持仓是状态项是否保留的最终依据;补仓计数是业务状态,不会由当前持仓反推或重置。
## 发现的问题
### P1撤销、拒绝和部分成交没有完整回写规则
`OrderBook.refresh()` 仅将进行中状态和完成状态 `56` 放入 `OrderBook.data`。撤销、拒绝等订单会被过滤;超时订单发出 `cancel_by_id()` 后也会直接跳过。`State.reconcile()` 因此找不到对应 `added_order_id`,只能保持原有 `added_status=ING`
影响:状态文件可能长期显示“处理中”,而 `added_num` 已经递增。系统目前未定义以下情况是否消耗补仓档位:
- 委托完全拒绝;
- 撤单且零成交;
- 部分成交后撤单。
建议:使对账可获得终态订单,或在订单簿中显式传递终态映射;为上述三种情况定义 `added_status``added_qty``added_num` 的最终规则,并增加回归测试。
### P1下单成功但状态文件保存失败时重启恢复会丢失补仓层级
补仓路径顺序是 `orders.place()` 成功后,再执行 `state.set()``state.save()`。若保存失败,当前进程仍有订单簿方向锁,但程序重启后状态文件不含本次补仓。`sync_positions()` 只能接管当前持仓,不能从持仓推导历史补仓次数,因此存在重复使用补仓档位的风险。
建议:捕获并明确处理 `state.save()` 失败;至少将其作为不可忽略的交易一致性故障告警。若要求重启后绝不重复补仓,需要可恢复的补仓事件记录或可查询的成交历史作为补偿来源。
### P2每轮对账会执行两次状态文件写入
`reconcile()` 调用 `sync_positions()`,而 `sync_positions()` 无条件 `save()``reconcile()` 结束时又再次 `save()`。这不改变正确性,但每 30 秒至少两次磁盘原子替换。
建议:让 `sync_positions()` 返回是否发生变化,由 `reconcile()` 统一进行一次保存。
### P2底仓订单字段与当前开仓流程不一致
开仓路径不再创建 `StateItem`,而首次出现真实持仓时由 `sync_positions()` 建立底仓状态。因此新开仓的 `base_order_id` 通常为空,底仓订单状态对账主要只适用于遗留/外部写入的数据。
建议:保留该字段前,应明确它是否仍承担审计用途;否则可在后续数据模型整理时移除无效的底仓订单状态分支。
## 测试覆盖
现有测试覆盖了拆单全成后状态改为 `OK`、补仓档位边界和订单簿方向锁,但没有覆盖:
- 补仓单撤销、拒绝、部分成交后的状态和次数;
- `state.save()` 在下单成功后失败的恢复处理;
- 启动恢复后的补仓层级保持;
- 状态项随平仓删除的边界。
`py-client` 目录运行 `python -m compileall -q .` 通过。现有全量单测仍有趋势替身接口不匹配及 IPO 调用契约问题,无法作为状态机全绿的证明。

363
docs/todo.md Normal file
View File

@@ -0,0 +1,363 @@
### 4.1 发出撤单请求后立即丢弃订单和方向锁,未确认撤单成功
位置:
- `py-client/strategy/trend/order.py:63-72`
- `py-client/strategy/trend/order.py:85-88`
- `py-client/sdk/trade.py:75`
- `api/qmt_api_new.py:1001-1021`
`OrderBook.refresh()` 对过期订单调用 `cancel_by_id()` 后立即 `continue`,因此该订单不会进入新 `data``lock`。服务端对于“不可撤”或撤单返回 `False` 的情况仍返回 HTTP 200只在 JSON 中给出 `status=failed`;客户端既不解析该业务状态,订单簿也不复查券商状态。
影响:原订单可能仍可成交,但本地方向锁已经释放;同一股票、同一方向可以再次下单,形成重复仓位或超额卖出风险。撤单已受理但尚未确认时也存在相同窗口。
建议:撤单请求后保留订单和锁,直到下一次券商快照确认订单进入终态;客户端应将 `status=failed` 转成明确业务失败。为“不可撤”“撤单返回失败”“撤单请求异常”“撤单已受理但状态未更新”分别增加测试。
### 4.2 API 认证令牌硬编码、提交到仓库并写入日志
位置:
- `api/qmt_api_new.py:14,50,1526,1529`
- `api/qmt_api_rele.py:14,50,1548,1551`
- `py-client/etc/_global.yaml:2`
两份服务端文件包含相同的固定令牌,客户端配置也把该令牌提交到版本库。服务监听 `0.0.0.0`,启动时还会把完整令牌写入日志。
影响:任何能读取仓库或日志的人都可获得交易 API 凭据;在端口可达的网络范围内,可调用下单、撤单和关闭服务等接口。令牌已进入版本历史时,仅删除当前文本并不能完成处置。
建议:立即轮换现有令牌;从环境变量或受控密钥存储读取;配置库只保留占位符;停止记录令牌;默认绑定回环地址,确需远程访问时增加网络访问控制和 TLS。检查 Git 历史及已分发日志中的泄露范围。
### 4.2 IPO 没有校验真实交易日,只判断周一至周五和盘中时间
位置:
- `py-client/strategy/ipo/boot.py:27`
- `py-client/libs/calc.py:5-7`
`trading_time()` 只排除周末,法定节假日、临时休市仍会进入申购逻辑。模块中已有 `TRADING_CALENDAR_SYMBOL` 常量,但没有实际查询交易日接口。
建议:调用 SDK 的 `trading_dates()` 验证当天交易日;接口失败时按安全策略跳过,不应猜测为交易日。
### 4.8 启动脚本仍会强制终止机器上的所有 Python 进程
位置:`run.bat:1-7`
`taskkill /IM python.exe /F` 不区分项目和 PID`python main.py` 又依赖调用时工作目录。它可能杀死无关任务后仍因找不到根目录下的 `main.py` 而启动失败。
建议:只管理本项目 PID脚本先切换至 `%~dp0py-client`;生产启动不要无条件 `git pull`
## 5. P2代码冗余与过度验证
### 5.1 两份 API 文件逐字节重复,正式入口被删除
位置:
- `api/qmt_api_new.py`
- `api/qmt_api_rele.py`
- 当前处于删除状态的 `api/QMT_API.py`
两个新文件 SHA-256 相同,属于完整重复;原正式文件被删除,启动和部署入口不清晰。继续维护会造成修复只落在其中一份的风险。
建议:保留唯一正式文件,版本差异交给 Git 管理。
### 5.2 IPO 模块保留大量未使用的设计残留
位置:`py-client/strategy/ipo/boot.py:5-19`
`json``Any``IPO_STRATEGY_NAME``IPO_REMARKS``IPO_SESSIONS``TRADING_CALENDAR_SYMBOL` 当前均未使用。它们与测试要求一起表明完整对账实现被缩减,但残留符号没有同步整理。
建议:先恢复交易日、券商对账和统计逻辑,再清除确认无用的符号,不要只做表面删减。
### 3.5 开仓金额可能超过配置值,同轮多信号会重复使用现金
位置:
- `py-client/libs/calc.py:10-12`
- `py-client/strategy/trend/boot.py:119-164`
- `py-client/strategy/trend/open.py:41-70`
`calc_buy_volume()` 在预算不足一手时仍强制返回 100 股。趋势开仓仅检查一次现金比例,没有校验单笔预计金额,也没有为同一轮后续信号扣减已提交订单占用的资金。
影响:高价股单笔超出 `buy_value`;多信号集中出现时可能发送总额超过可用资金的委托。
建议:不足一手返回 0开仓入口维护本轮共享 `remaining_cash`,成功提交后立即预留资金,并考虑价格滑点。
### 3.2 IPO 仅依赖本地空文件防重,锁丢失或落盘失败会重复申购
位置:
- `py-client/strategy/ipo/boot.py:37-51`
- `py-client/libs/lockfile.py:9-18`
当前实现没有查询 `trade_detail_data("order")``deals()`,也没有按账户、交易日和申购备注对账。本地锁文件被清理、数据目录切换、写入失败或下单成功后进程崩溃时,下一次任务会再次提交同一新股。
影响:产生重复申购请求;实际结果取决于券商拦截,不能把安全性寄托在券商拒单上。
建议:本地记录只能作为快速幂等缓存,最终防重必须以“账户 + 交易日 + 证券代码”的券商委托/成交记录为准;下单前后二次核对。
### 3.4 开仓可能超过单笔配置金额,多信号还会重复使用同一份现金
位置:
- `py-client/libs/calc.py:10-12`
- `py-client/strategy/trend/boot.py:119-164`
- `py-client/strategy/trend/open.py:41-70`
`calc_buy_volume()` 使用 `max(1, floor(...)) * 100`。当 `buy_value < price * 100` 时,它不是返回 0而是强制购买 100 股,实际金额必然超过 `buy_value`。趋势开仓只检查一次总账户现金比例,没有检查单笔预计金额是否小于可用现金,也没有在同一轮多个信号之间扣减已预留资金。
影响:高价股或同轮多信号可能导致单笔超预算、连续发送超过可用资金的委托,产生券商拒单或非预期仓位。
建议:预算不足一手时返回 0像补仓路径一样维护本轮 `remaining_cash`;每次成功提交后立即预留 `price * volume`,并给价格滑点留安全余量。
### 2.1 每次启动都会无条件执行新股申购
整改状态:已于 2026-08-29 按本节方案完成,新增 4 项专项测试。
位置:
- `py-client/main.py:127-130`
- `py-client/strategy/ipo/boot.py:4-22`
现状:`main()` 在启动趋势策略前固定调用 `AutoBuyIpo()`。该流程没有配置开关、交易日期校验、执行记录或订单对账,会对接口返回的每只新股直接按最大额度申购。程序同日重启时会再次提交;申购接口异常还会阻止趋势策略启动。函数创建的 HTTP Client 也没有关闭。
影响同日重启可能重复申报IPO 服务短暂失败可能导致主策略完全无法启动。
解决方案:
1. 增加明确的 `enable_auto_ipo` 配置,默认关闭。
2.`账户 + 交易日 + 证券代码` 建立本地幂等记录。
3. 提交前查询当日委托或成交,已有申购记录时跳过。
4. 仅在交易日和申购允许时段执行。
5. 单只申购失败不能中止其他标的,也不能阻止趋势策略启动。
6. 使用上下文管理器或 `finally` 关闭 Client。
验收标准同一账户同一交易日无论重启多少次每只证券最多提交一次IPO 接口失败时趋势策略仍可启动。
### 2.4 开仓和补仓没有共享同一个本轮资金预算
位置:
- `py-client/strategy/trend/boot.py:133-140`
- `py-client/strategy/trend/boot.py:182-187`
- `py-client/strategy/trend/open.py:15-53`
- `py-client/strategy/trend/positions.py:44-84`
现状:开仓前只检查一次现金比例,`open_signal()` 可对多个信号分别按 `buy_value` 下单,但不扣减本轮可用资金。随后 `manage_positions()` 又以最初的 `assets.available` 作为补仓预算。
影响:同一轮多笔开仓与多笔补仓的总金额可能超过真实可用资金,导致集中拒单或资金计划失控。
解决方案:
1. `RunOnce()` 创建唯一的本轮 `remaining_cash`
2. 开仓、补仓共享该预算对象。
3. 每笔订单提交成功后立即预留预计金额。
4. 预算应保留 `min_cash_ratio` 对应的安全现金,不能把全部 available 用完。
5. 下单结果未知时也应暂时占用预算,直到对账明确失败。
验收标准:模拟多个开仓和补仓信号时,全部订单预计金额与保留现金之和不超过本轮资产快照的可用资金。
### 2.8 服务端与客户端可能把失败下单当成成功
位置:
- 服务端:`api/QMT_API.py:652-669`
- 客户端:`py-client/strategy/trend/order.py:90-102`
现状:服务端在 `order_ref` 为空时仍返回 `status=success``order_ref=unknown`;客户端不检查响应,直接返回 `True`
影响:真实订单未提交,本地状态却进入 `ING`,后续可能长期锁仓或重复判断错误。
解决方案:
1. 服务端只有在获得有效订单引用或明确成功码时返回成功。
2. 无订单引用时返回非 2xx或返回 `status=failed` 并包含错误原因。
3. SDK 将下单响应解析成明确的 `OrderResult` dataclass。
4. `OrderBook.place()` 验证 `status``order_ref` 后才能写入锁并返回成功。
5. 下单异常不能更新 `StateItem`
验收标准:
- `order_ref=None`、空字符串、`unknown` 均被识别为失败。
- 失败时本地订单锁和状态文件均不发生变化。
- 成功时保存服务端返回的真实订单引用。
### 3.1 同步 QMT 调用阻塞 Tornado 主线程
位置:所有同步 Handler例如
- `api/QMT_API.py:235`,行情查询
- `api/QMT_API.py:279`FullTick
- `api/QMT_API.py:1095`,持仓查询
- `api/QMT_API.py:1124`,资产查询
- `api/QMT_API.py:1547-1553`,单 IOLoop 启动
影响:任意一个慢请求都会阻塞其他资产、行情和交易请求。
解决方案:
1. 首先确认 QMT API 是否允许跨线程调用,以及是否要求在策略主线程执行。
2. 如果 QMT 要求固定线程:建立单一 QMT Worker 和任务队列HTTP Handler 异步等待任务结果。
3. 如果部分查询允许跨线程:仅将线程安全的查询放到受控线程池。
4. 下单、撤单等有顺序要求的操作仍通过单一串行交易队列执行。
5. 给每类任务设置超时、最大队列长度和请求标识。
6. 不允许无限堆积;队列满时返回明确的 503。
推荐结构:
```text
HTTP Handler
-> Query Worker Pool线程安全的只读查询
-> Trade Command Queue串行下单/撤单)
-> Short TTL Snapshot Cache
```
验收标准:
- 一个耗时 2 秒的历史行情请求不会阻塞资产接口。
- 下单和撤单仍保持提交顺序。
- 压测期间队列长度和超时可观测。
### 3.2 资产、持仓和订单被重复查询
影响:客户端每轮会分别查询订单、资产、持仓和行情,产生多次 HTTP 与 QMT 往返。
解决方案:
1. 增加账户快照接口,一次返回资产、持仓和活动订单。
2. 对同一账户的查询建立 100500ms 短周期缓存。
3. 交易命令执行后主动使相关缓存失效。
4. 缓存只用于查询,不能缓存下单和撤单结果。
5. 快照中返回统一的 `snapshot_time`,客户端可以判断数据新鲜度。
### 3.4 大行情响应在主线程转换和编码
位置:`api/QMT_API.py:235-276`
解决方案:
1. 限制股票数量、字段数量、日期跨度和最大响应体。
2. 使用明确的 DataFrame 转换方向和紧凑 JSON 格式。
3. 大结果支持分页、分批或文件下载。
4. 启用 gzip/br 压缩,但要衡量 QMT 机器 CPU。
5. 移除生产接口中的泛化 `default=str`,避免无意返回巨型对象字符串。
6. 将允许异步处理的转换和 JSON 编码移出 IOLoop。
验收标准:
- 超出范围的请求快速返回 400不拖垮服务。
- 大行情接口有响应大小和耗时指标。
- 资产、下单等小请求的 P95 不受大查询明显影响。
### 4.4 信号配置被硬编码且运行期间不刷新
位置:`py-client/strategy/trend/boot.py:83-84`
解决方案:
1. 使用账户配置中的 `signal_allow`,不要硬编码信号名。
2. 明确刷新周期,例如每 15 分钟重新拉取。
3. 拉取失败时保留最近一次成功快照,并记录快照时间。
4. 信号按 `(signal_key, code)` 去重。
5. 过期信号必须根据服务端 `updated` 或有效期淘汰。
验收标准:修改 YAML 后重启即可生效,长时间运行能获取新信号且不会重复下单。
### 5.1 服务端 Handler 重复代码过多
现状:每个接口重复执行 JSON 解码、默认值转换、异常捕获和 JSON 编码。
解决方案:
1. `BaseHandler` 增加 `read_json()`、参数校验和 `write_json()`
2. 使用 dataclass 或轻量 schema 定义请求参数。
3. 统一异常映射:参数错误 400、认证错误 401、业务冲突 409、服务不可用 503、未知错误 500。
4. 抽取固定字段对象转换函数。
5. 不要让 `safe_call()` 把所有错误统一变成 `None`
收益:减少接口行为差异,降低维护成本,并使性能监控更容易统一接入。
### 5.2 客户端 SDK 过度使用单行函数和动态字典
解决方案:
1. 高频账户、持仓、订单和交易接口优先使用明确 dataclass。
2. 长单行函数拆成可读的请求构造、发送和响应解析步骤。
3.`Client` 增加统一响应校验。
4. 区分查询异常、业务失败、订单结果未知和明确拒单。
5. 给所有交易方法增加输入校验:代码、方向、整手数量和金额。
### 5.3 止盈逻辑存在两套状态实现
现状:项目同时存在 `GridTrailingTracker``Runtime.peak_grids` 的设计痕迹。
解决方案:保留 `GridTrailingTracker` 作为唯一实现,将其放入 `Runtime`;删除旧字典逻辑和重复函数。
### 5.4 入口和配置使用全局可变状态
解决方案:
1. `config.load()` 返回配置后,由 `main()` 显式传给策略启动器。
2. `StartTrend(global_cfg, account_cfg)` 不直接读取模块全局变量。
3. 测试时可注入临时配置和模拟客户端。
### 5.5 缺少正式自动化测试
当前 `test.py` 是人工连通性脚本,不是完整测试套件。
建议至少建立:
- SDK 请求载荷和响应解析测试。
- `Position``Tick``StateItem` 模型测试。
- 时间段和交易时间测试。
- 开仓去重测试。
- 下单失败不更新状态测试。
- 过期撤单测试。
- 网格止盈状态机测试。
- 补仓次数和预算测试。
- 崩溃重启后的订单对账测试。
- 服务端账户快照和固定字段序列化测试。
### 阶段 1建立基线
1. 为每个 Handler 记录请求总耗时、QMT 调用耗时、序列化耗时和响应大小。
2. 记录并发请求数、任务队列长度、超时数和错误率。
3. 分别测量资产、持仓、订单、FullTick、历史行情接口的 P50/P95/P99。
### 阶段 2低风险优化
1. 固定字段序列化,删除 `dir()` 反射。
2. 合并账户快照接口。
3. 添加短 TTL 查询缓存。
4. 限制大查询范围和响应大小。
5. 客户端启用连接池。
### 阶段 3并发模型优化
1. 先验证 QMT 的线程安全和线程亲和性。
2. 建立查询 Worker 或单 QMT Worker 队列。
3. 下单、撤单保持串行和幂等保护。
4. 对大数据转换使用独立执行资源。
### 阶段 4压力验证
1. 同时执行慢历史行情与高频资产查询。
2. 在压力期间提交模拟下单和撤单。
3. 验证交易请求延迟不会因数据查询无限增长。
4. 验证服务端重启、超时和队列满时行为。

View File

@@ -0,0 +1,89 @@
# Trend 当前工作树重新审计
审计日期2026-09-05。审计基线`2f93fe2`,本轮读取时工作树干净。以当前磁盘文件为准,仅审计 Trend 及直接依赖;不修改策略代码,不连接交易接口。
## 结论
当前发现 **P00 项P14 项P23 项**。本报告只保留当前存在的问题与待确认行为,不保留已修复问题清单。不建议仅凭语法检查通过直接运行实盘。
- **P0紧急**:无需特定边界条件即可造成全面故障或迫切严重损害,需要立即处理。本轮未发现符合此定义的问题。
- **P1优先修复**:在明确场景下影响资金约束、订单权限或交易决策,建议实盘前处理。
- **P2常规修复**:异常场景下影响局部执行或可靠性,需要安排修复。
工作树中 `strategy/trend/state.py` 已删除,`Runtime` 也已移除 `state` 字段;`docs/arch/state.py` 是归档,不是当前运行模块。因此此前围绕 `merged_order()` 的结论不作为当前策略结论。
## P1-1清仓后没有清理峰值新持仓会继承旧回撤基准
- 位置:`positions.py:105``:182``boot.py:122` 起的快照处理;`libs/grid_take_profit.py:71`
- 证据:峰值键只有账户和证券代码;当前 Trend 没有调用 `clear()`,也没有在持仓消失时清理对应键。
- 触发:同一进程内清仓后重新买入同一证券。
- 影响:新持仓达到最低收益门槛后,可能按上一笔持仓的高峰立即触发止盈,而不是建立自己的峰值;补仓改变成本时也未明确重置基准。
- 验证:真实网格组件以同一键记录 12% 峰值,再输入新持仓的 9% 盈利,返回 `RETREAT`;代码中没有清仓清理步骤隔离两次持仓。
- 建议:根据后续快照确认持仓消失后清理;撤单或部分成交继续保留峰值,成本变化另行明确重置规则。
## P1-2自动撤单未限定为本策略订单
- 位置:`order.py:60``:68``:78``boot.py:57``sdk/portfolio.py:9`
- 证据:将组合接口的订单列表直接传入 `refresh()`,只检查状态与时间,没有检查本地订单号前缀、策略归属或排除名单。
- 触发:组合接口返回同账户其他策略或手工订单,且符合超时条件。
- 影响:启动及每轮刷新都可能撤销非 Trend 订单,包括排除证券的订单。
- 建议:账户级活动订单可用于防重,但主动撤单必须单独限定所有权。若业务确实授权管理整个账户,需明确记录该权限。
## P1-3大盘过滤实际始终放行
- 位置:`py-client/libs/market.py``market_allow_open()``boot.py:151``positions.py:67`
- 证据:真实状态比较被注释,函数直接 `return True`
- 影响:下跌或未知状态仍允许开仓及补仓,外层看似存在的风险限制不生效。该问题属于 Trend 直接依赖,不是 Trend 文件内的新修改。
- 建议:恢复真实状态判断,或以明确、可见的配置表示主动关闭过滤。
- 验证:将本地测试状态设为 `DOWN`,仍返回 `True`;未请求网络。
## P1-4买入没有统一资金预算最小整手还可能突破单笔额度
- 位置:`boot.py:146``:176``open.py:51``positions.py:160``libs/calc.py:10`
- 证据:开仓只依据本轮起始资金比例决定是否启动,不逐单扣减预算;补仓线程独立使用相同快照资金。`calc_buy_volume()``max(1, ...)` 强制至少买一手。
- 影响:多个开仓与补仓可能同时消耗同一份可用资金;可能跌破配置的现金安全线,或产生资金不足拒单。并非断言券商一定允许超额成交。
- 示例:价格 100 元、单笔额度 5,000 元,计算出 100 股,即 10,000 元。
- 建议:开仓和补仓共享本轮可预留预算;不足一手返回零;预留考虑手续费和行情变化,保留最小现金要求。
## P2-1撤单异常会中断整轮刷新和交易管理
- 位置:`order.py:78``:86``boot.py:122` 起的快照处理。
- 证据:撤单在遍历中直接调用,未逐单隔离;`busy_keys``data` 在全部遍历结束后才赋值。
- 影响:某笔撤单失败即退出 `refresh()`,快照不更新;常规轮次返回而跳过全部交易管理,初始化阶段则退出启动(客户端会关闭)。持续失败的订单可能持续阻断后续轮次。
- 建议:撤单逐项记录异常,同时发布完整活动订单快照;撤单失败的订单继续作为在途订单防重。
## P2-2信号前置处理没有逐项异常隔离
- 位置:`open.py:16``:55`
- 证据:异常捕获仅包围 `do_open()`;配置、价格、数量计算、观察器调用不在逐候选保护边界中。
- 触发:一个候选包含异常数据,例如非有限价格导致数量计算异常,或配置字段类型不符合预期。
- 影响:该候选之后的所有开仓信号本轮不再处理;工作线程最外层只能记录整个任务失败。
- 建议:把一个候选的完整处理放入同一异常边界,记录证券与信号键后继续;保持必要校验即可。
## P2-3补仓请求结果不确定时未保留资金预算
- 位置:`positions.py:175``:75``order.py:119` 起的异常处理。
- 证据:`place()` 对请求超时等异常返回 `False`,保留证券方向缓存,但 `handle_loss()` 返回的 `reserved_cash` 默认为零。
- 触发:券商已受理补仓但响应丢失,本轮继续处理后续证券。
- 影响:方向缓存只能防同证券重复下单,不能阻止其他证券重复使用这笔可能已消耗的资金。该问题即使只启用持仓管理、不开新仓也存在,与 P1-4 的跨线程预算问题不同。
- 验证:模拟 `place()` 返回 `False`,补仓结果 `reserved_cash == 0.0`
- 建议:区分明确未提交与结果不确定;不确定时保留本轮预计金额,或停止本轮后续买入并刷新资金。
## 需要确认的行为(不直接判为错误)
- `positions.py:102` 在盈利低于最低收益门槛时不再观察网格:若从高峰直接跌破该门槛,不会触发回撤卖出。需要确认门槛是仅用于首次激活,还是任何时候都禁止低于门槛卖出。
- `positions.py:122` 向下取整到百股,可用持仓不足一手将一直跳过。是否需要支持零股清仓取决于交易标的及产品要求,本次未核验外部市场规则。
- 当前 `LOSS_TIERS = [-50.0]`,且 `get_add_num()` 依据手数/当前市值判断,不再记录真实补仓次数。若是主动放弃状态机后的新设计,应更新策略说明;不能等同于历史成交次数。
- `boot.py:84` 使用 `>= 15:00:00`;与此前“严格大于 15:00”的表述存在边界差别。启动初始化发生在此检查之前仍可能查询或撤单。
## 已确认的保护与验证范围
- `busy()``place()` 都检查券商快照的 `busy_keys` 及本地 `SimpleCache``place()` 在互斥区内检查并写入,不再报告旧版“下单未使用防重”的问题。
- 开仓与持仓管理都检查排除名单;未知信号配置有独立日志后跳过。
- `place()` 捕获 API、HTTP 请求和解码类异常;持仓管理有逐证券异常边界。
- 启动生命周期有 `finally`,等待线程池后关闭客户端。
- 当前 7 个 Trend 源文件全部重新通过 AST 语法检查。本轮真实网格组件的最小测试验证同一键沿用旧峰值;重新执行资金数量计算、大盘状态判断,分别得到 10,000 元买入金额和 DOWN 状态仍放行。
- 资金预算、大盘过滤、撤单和信号异常边界本轮已重新读取;失败补仓零预算预留的实现与此前最小测试对应代码一致。
- 未执行真实下单、撤单、完整 SDK 联调或券商回报测试;语法通过不代表策略可运行。归档文件和历史审计测试统计不计入当前验证。
优先顺序:先处理撤单权限、市场过滤、资金预算及止盈峰值生命周期,再完善异常边界。

151
docs/trend-audit.md Normal file
View File

@@ -0,0 +1,151 @@
# Trend 策略代码审计报告
> 历史报告:当前 IPO / Trend 复核结果见 [ipo-trend-audit.md](ipo-trend-audit.md)。下述问题状态与测试统计不代表当前版本;尤其 busy_keys 防重已实现,旧测试文件已按要求清理。
审计日期2026-09-05
审计对象:`py-client/strategy/trend` 当前工作树版本。
关联范围:仅核对直接影响 Trend 行为的 `sdk``libs``config` 和 Trend 测试。
审计方法:重新读取当前代码,不沿用历史审计结论;本次只更新审计文档,不修改策略代码。
## 结论摘要
当前版本不建议直接用于无人值守实盘。发现 2 个严重问题、6 个高风险问题、6 个中风险问题和 2 个低风险/测试问题。最高优先级是本地防重与券商实际活动委托脱节,以及空订单快照会清除未决状态。另有 5 项 Trend 测试在目标断言前报错,当前测试结果不能为下单路径提供有效回归保障。
## 严重问题
### S1. 防重只依赖进程内 TTL完全忽略券商活动委托
- 位置:`strategy/trend/order.py:45-48,80-92,97-104`
- 证据:`refresh()` 虽统计 `busy_keys`,但没有写入 `SimpleCache``busy()``place()` 都只查询 `busy_cache`,没有检查 `self.data`。缓存也不会跨进程恢复。
- 触发场景:程序重启后券商仍有活动订单;或本地 180 秒 TTL 到期但订单仍未终结。
- 影响:同一证券、同一方向可能重复开仓、补仓或止盈。卖出路径可能再次按全部可用持仓提交委托。
- 建议:`busy()``place()` 在同一互斥区内同时检查 TTL 缓存及 `self.data` 中的 `BUSY_STATUSES`
### S2. 一次空订单快照会不可逆地清除待成交状态
- 位置:`strategy/trend/order.py:57-92``state.py:101-160`
- 证据:`refresh()` 每轮用当前券商结果覆盖 `OrderBook.data`。订单刚提交但暂未出现在快照时,`reconcile()` 会把对应 `ING` 改为空字符串;若尚无持仓,随后还会删除整个 `StateItem`
- 影响:底仓状态可能丢失,补仓次数可能不增加;后续轮次可能重新开仓或重复使用同一亏损档位。
- 建议:本地 pending 应保留至明确终态或确认超时;短暂缺席不能立即视为失败。
## 高风险问题
### H1. 大盘风控被固定为允许开仓
- 位置:`libs/market.py:34-38`;调用位置:`strategy/trend/boot.py:153,185-188`
- 证据:`market_allow_open()` 无条件返回 `True`,不读取 `_market_status`
- 影响:市场状态为下跌、未知或刷新失败时,策略仍可开仓和亏损补仓。
- 测试证据:`test_market.MarketCacheTests.test_refresh_failure_blocks_open` 失败。
- 建议:仅在缓存状态明确为 `UP` 时允许开仓;未知状态采用 fail-closed。
### H2. 过滤异常订单后再对账,会把拆单结果判错
- 位置:`strategy/trend/order.py:62-65,80-92``state.py:112-139,211-220`
- 证据:`refresh()` 丢弃取消、拒绝和异常订单,只把处理态及完成态写入 `data`。同一本地订单若一笔完成、一笔取消,传给 `State` 的只剩完成记录,`_order_status()` 会返回 `OK`
- 影响:部分成交可能被视为完整成交;状态数量、成本和补仓次数与真实结果不一致。
- 建议:展示/防重列表可以过滤,但状态对账必须使用完整原始订单快照。
### H3. 柜台受理与状态落盘之间存在崩溃窗口
- 位置:`strategy/trend/open.py:78-98``positions.py:174-193`
- 证据:开仓和补仓都先执行 `orders.place()`,成功返回后才写入并保存 `StateItem`
- 影响:委托受理后若进程退出或状态写入失败,本地没有对应状态;重启后容易重复下单。
- 建议:提交前持久化订单意图,提交后记录柜台结果;不确定结果保留为可对账状态。
### H4. 开仓与补仓并发,资金预算彼此隔离
- 位置:`strategy/trend/boot.py:180-190``open.py:15-74``positions.py:28-91`
- 证据:持仓管理与开仓并发执行。补仓只在自身循环扣减余额;每个开仓信号均独立使用完整 `buy_value`,两条路径没有账户级资金预留。
- 影响:同轮总委托金额可能超过真实可用资金,成交组合取决于柜台顺序。
- 建议:统一生成订单意图,用账户级单一预算预留资金后再提交。
### H5. 撤单结果未校验,活动订单却立即从跟踪列表删除
- 位置:`strategy/trend/order.py:67-78`
- 证据:超过 10 秒即调用 `cancel_by_id()`,不检查响应是否成功,随后无条件 `continue`
- 影响:撤单失败或结果未知时,本地已不再跟踪仍有效的订单;结合 S1 可能重复提交。
- 建议:仅在券商明确返回取消终态后移除;失败或不确定时继续保留活动状态。
### H6. 单笔预算不足一手时仍强制买入 100 股
- 位置:`libs/calc.py:10-12`;调用位置:`strategy/trend/open.py:49``positions.py:168`
- 证据:`calc_buy_volume()` 使用 `max(1, floor(...)) * 100`
- 影响:`buy_value < price * 100` 时委托金额必然超过预算,并放大 H4。
- 建议:不足一手时返回 `0`,由调用方记录并跳过。
## 中风险问题
### M1. 现金安全线只限制新开仓,不限制亏损补仓
- 位置:`strategy/trend/boot.py:145-148,183-188``positions.py:68-80`
- 证据:`allow_open_by_cash` 只控制 `open_signal()``manage_positions()` 始终获得全部 `assets.available`
- 影响:账户已低于最小现金比例时仍可能增加亏损仓位。
- 建议:若安全线也约束补仓,应只传递扣除安全储备后的预算。
### M2. 两个风控配置未参与 Trend 决策
- 位置:`config/__init__.py:48-50``strategy/trend/positions.py:16,52-59,157-160`
- 证据:配置提供 `loss_trigger_pct``min_profit_pct`,但补仓使用固定 `LOSS_TIERS`,止盈门槛按股价区间硬编码。
- 影响:修改配置不会改变实盘行为,运维人员可能误判实际参数。
- 建议:让策略明确使用配置,或删除无效配置并输出最终生效参数。
### M3. 非 `APIError` 下单异常会终止整批持仓管理
- 位置:`strategy/trend/order.py:105-116``positions.py:38-91`
- 证据:`OrderBook.place()` 只捕获 `APIError``manage_positions()` 没有逐持仓异常边界。
- 影响:一只证券发生连接、超时等异常后,其余持仓当轮不再处理。
- 建议:订单层捕获明确的传输异常,持仓循环增加逐证券隔离。
### M4. 启动阶段异常不会可靠释放 HTTP Client
- 位置:`strategy/trend/boot.py:47-84`
- 证据Client 创建后的组合查询、撤单、状态读取和信号加载不在统一 `try/finally` 中。
- 影响:初始化失败时连接池不能确定及时释放。
- 建议:建立统一资源生命周期,在 `finally` 中关闭已创建资源。
### M5. 信号只在启动时加载一次
- 位置:`strategy/trend/boot.py:67-71,88-110`
- 证据:`init_signals()` 位于永久循环外。
- 影响:运行期间新增、撤销或修正的远程信号不会生效。
- 建议:按业务时效定期刷新,或明确“启动快照整日有效”的约束。
### M6. 明确拒单后仍保留 180 秒缓存锁
- 位置:`strategy/trend/order.py:97-126`
- 证据:柜台调用前设置缓存,但 API 明确失败、响应格式无效或抛出 `APIError` 时均不删除键。
- 影响:可立即纠正或重试的订单被无条件抑制 180 秒,可能错过窗口。
- 建议:明确未受理时释放缓存;结果不确定时保留保护并等待对账。
## 低风险与测试问题
### L1. 结束时间条件与“大于 15:00”不一致
- 位置:`strategy/trend/boot.py:90-91`
- 证据:当前使用 `>= (15, 0, 0)`,在恰好 `15:00:00` 时退出,而约定是大于 15:00 后退出。
- 影响:边界秒行为与需求不一致。
- 建议:使用严格大于比较,并让日志描述与条件一致。
### L2. Trend 测试夹具已与生产接口漂移
- 位置:`tests/test_trend.py:19-42,291-352`
- 证据:生产代码调用 `client.passorder(...)`,但测试替身只实现已删除的 `passorder_latest_tagged()`;两个 `RunOnce` 夹具缺少采集任务需要的 `account_id`
- 影响:核心下单和调度测试在目标断言前报错,无法验证真实调用契约。
- 建议:测试替身实现当前 `passorder` 签名并补齐 `account_id`增加券商活动订单、TTL、空快照、撤单失败和并发预算测试。
## 回归结果
执行:`cd py-client && python -B -m unittest tests.test_trend tests.test_market -v`
结果17 项测试中 11 项通过、1 项失败、5 项错误。
- 失败:市场状态刷新失败后仍允许开仓。
- 错误3 项下单测试使用旧接口替身2 项 `RunOnce` 测试缺少 `account_id`
## 建议处理顺序
1. S1、S2恢复可靠的订单防重和未决状态对账。
2. H2、H3、H5确保订单生命周期、撤单和成交状态可信。
3. H1、H4、H6、M1恢复风控并统一账户预算。
4. M2、M3、M4、M5、M6处理配置、异常隔离、资源和刷新策略。
5. 修复测试夹具并补齐跨轮、重启、空快照、撤单失败和并发预算回归。

37
docs/trend-state.md Normal file
View File

@@ -0,0 +1,37 @@
# Trend 精简状态记录JSON v3
## 记录内容
- `items[code]`:底仓数量、底仓均价、补仓次数、累计补仓数量和金额。补仓均价由金额除以数量得到。
- `pending[code]`每只证券最多一个待确认买单仅存订单号、类型base/add、预开仓数量 expected_qty 和提交时间。
- 文件仍为 `{strategy}_{account_id}_state.json`,内存字典访问,数据变化时原子写 JSON。
没有子单历史缓存、增量游标、counted 标记、待确认计数器或 seen_positions。
## 处理规则
1. 首次接管的持仓全部作为底仓;存在 pending 的证券等待自身订单对账,不从部分持仓重复导入。
2. 提交前保存 pending 和预开仓数量。保存失败回滚占位,阻止发送;异常响应保留待确认状态。
3. 订单处理中不更新账本。空快照、部分快照和缺少成交价格时继续等待;超过 180 秒定期提示核查,不自动释放。
4. 当前完整快照必须覆盖预开仓数量,所有子单明确结束,且成交金额完整,才一次记账。系统订单号用于本轮去重,不跨轮缓存子单。
5. 有成交的补仓计一次,累加实际数量和金额;部分成交后撤单也如此。完全未成交不增加补仓次数。
6. 更新账本和删除 pending 在同一次文件替换中保存,重复快照/重启不会重复记账。
7. 账本不因持仓缺席而删除。清仓后若需要同证券重新开底仓,必须先核实清仓、停机备份并清理对应 items 记录;当前未实现自动清仓判定。
SimpleCache 与 busy_keys 继续用于快速防重,本地 pending 同样阻止买入。底仓数量/补仓数量是买入成交记录,不自动分摊卖出;实际可用持仓仍以券商为准。
## 迁移
自动支持 v2 → v3保存前备份为 `.json.v2.bak`
v2 尚未结束的订单可能已经记入部分成交和一次补仓计数。迁移先撤回该 pending 的 applied_qty、applied_amount、counted 对账本的贡献;最终完整快照到达后再一次计入,避免重复。已完成订单的账本保持原值。原子保存失败时原 v2 文件仍可重试。
v2 子单缓存保存在备份中v3 不继续使用需要之后提供完整订单快照才能结束对账。expected_qty=0 的旧迁移记录仍需要核实计划量。若同一证券存在多个旧 pending停止迁移并保留原文件避免静默丢掉订单。
更早的无版本 JSON 不再由精简状态机自动猜测转换,会报错且不修改原文件,需先核实转换。旧文件未保存的历史累计补仓成本无法恢复。
## 验证与限制
已使用临时目录和替身验证:首次接管、处理中不记账、部分成交撤单、完整拆单、重复记录/重启、空持仓保留、累计成本、无变化不写盘、写盘失败回滚、拒单/零成交清理,以及 v2 增量撤回后只记一次终态成交。没有新增 tests 文件,也没有真实交易调用。
依赖柜台提供完整终态快照、正确的本地订单标识及申报量/成交字段。超时只提示人工核查,不自动查询或重发。单进程使用,未提供多进程文件互斥。

40
docs/zt.md Normal file
View File

@@ -0,0 +1,40 @@
# ZT 日内做 T 策略
启用时在账户 YAML 中设置:
```yaml
strategy: zt
signal_allow: ["dcm"]
zt_sell_ratio: 0.5
zt_buy_fall_pct: 1.0
zt_max_price: 200
```
-`dcm` 信号可建立底仓;建仓使用反弹确认,跳过价格高于 `zt_max_price` 的股票。
- 对 dcm 底仓,盈利网格出现回撤时卖出 `zt_sell_ratio` 对应的可用整手;不卖出超过记录底仓的数量。
- 每笔卖出成交直接累计做 T 数量;活动委托结束后,价格较卖出均价回落 `zt_buy_fall_pct`,并经反弹确认,买回实际卖出数量。
- 每只股票每日只做一轮14:50 后不再开新卖单,已卖未买的仓位强制按市价买回,避免隔夜净减仓。
- 仅支持标准 A 股的先卖后买,不把当日新买入股票作为可卖库存。
## SQLite 状态存储
`libs/orderbook.py` 使用标准库 `sqlite3`,数据库路径为
`{qmt_data_dir}/zt_{account_id}_state.db`,每个账户/策略由单个实例串行更新。
启动时仅创建当前表结构和索引,不执行迁移或旧 JSON 导入。
两表使用 SDK 同名字段;另有自增主键 `id`,成交表增加从 `remark` 提取的 `order_local_id`
| 表 | 数据模型 | 索引 |
| --- | --- | --- |
| `positions` | `PositionItem``stock_code``stock_name``direction``volume``open_price``open_cost``float_profit``market_value``stock_holder``frozen_volume``can_use_volume``on_road_volume``yesterday_volume``last_price``profit_rate``future_trade_type``expire_date` | `stock_code` 唯一索引 |
| `deals` | `DealItem``stock_code``order_sys_id``ref``order_ref``direction``offset_flag``price``volume``trade_amount``trade_date``trade_time``remark``close_profit`,以及 `order_local_id` | `order_sys_id` 唯一索引;`order_local_id``stock_code``trade_date` |
`load()` 只更新 `positions``deals``deals_sys_ids` 缓存,无返回值。
`sync_positions(list[PositionItem])` 保存完整持仓快照,同一证券更新时保留自增 ID。
`sync_deals(list[DealItem])` 按系统订单号去重后批量写入;同批重复记录仅写一次。
`order_local_id``DealItem.local_order_id``remark` 首段),为空时拒绝写入。
成交日期规范为 `YYYY-MM-DD`,金额缺失时用成交价格乘数量补足。
ZT 使用 `volume/open_price` 保存底仓数量与成本,做 T 轮次从成交历史恢复,
不再使用持仓表的旧状态、底仓订单或补仓字段。买卖方向由 `offset_flag` 计算,
本地订单号从 `remark` 提取。持仓与新增成交在同一事务提交,失败时回滚并恢复内存。

View File

@@ -1,125 +0,0 @@
package main
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
"big-qmt/go-client/sdk"
)
var (
BaseURL = "http://127.0.0.1:10086"
Token = "QMTbyYanweidong"
AccountType = "stock"
Timeout = 15 * time.Second
)
func main() {
client := sdk.New(BaseURL, Token, Timeout).SetAccountType(AccountType)
ctx, cancel := context.WithTimeout(context.Background(), Timeout)
defer cancel()
assets, err := client.Assets(ctx)
if err != nil {
fatal("获取资产失败: %v", err)
}
positions, err := client.Positions(ctx)
if err != nil {
fatal("获取持仓失败: %v", err)
}
fmt.Println(strings.Repeat("=", 80))
fmt.Printf("【时间】%s\n", time.Now().Format("2006-01-02 15:04:05"))
fmt.Printf("【服务】%s accountType=%s\n", BaseURL, AccountType)
fmt.Printf("【资金】总资产:%.2f元,可用资金:%.2f元\n", assets.Total, assets.Available)
fmt.Printf("【持仓】%d只\n", len(positions))
fmt.Println(strings.Repeat("=", 80))
sort.Slice(positions, func(i, j int) bool {
return positions[i].StockCode < positions[j].StockCode
})
for _, p := range positions {
if p.Volume <= 0 {
continue
}
fmt.Printf(
"【持仓】%s %s 持仓=%d 可用=%d 冻结=%d 在途=%d 昨仓=%d 成本=%.3f 现价=%.3f 市值=%.2f 浮盈=%.2f 盈亏比例=%.2f%%\n",
p.StockCode, p.StockName, p.Volume, p.CanUseVolume, p.FrozenVolume, p.OnRoadVolume, p.YesterdayVolume,
p.OpenPrice, p.LastPrice, p.MarketValue, p.FloatProfit, p.ProfitRate*100,
)
}
codes := loadPassCodes()
printTicks(client, codes)
// if _, err := client.Shutdown(ctx); err != nil {
// fatal("关闭服务失败: %v", err)
// }
// fmt.Println("【服务】已关闭")
}
func loadPassCodes() []string {
dir := strings.TrimSpace(os.Getenv("QMT_DATA_DIR"))
if dir == "" {
fatal("环境变量 QMT_DATA_DIR 为空")
}
path := filepath.Join(dir, "pass_codes.json")
raw, err := os.ReadFile(path)
if err != nil {
fatal("读取 %s 失败: %v", path, err)
}
var codes []string
if err := json.Unmarshal(raw, &codes); err != nil {
fatal("解析 %s 失败: %v", path, err)
}
return codes
}
func printTicks(client *sdk.Client, codes []string) {
fmt.Println(strings.Repeat("-", 80))
if len(codes) == 0 {
fmt.Println("【行情】pass_codes.json 为空,跳过")
return
}
ctx, cancel := context.WithTimeout(context.Background(), Timeout)
defer cancel()
ticks, err := client.FullTick(ctx, codes)
if err != nil {
fatal("获取行情失败: %v", err)
}
fmt.Printf("【行情】请求 %d 只,返回 %d 只\n", len(codes), len(ticks))
keys := make([]string, 0, len(ticks))
for code := range ticks {
keys = append(keys, code)
}
sort.Strings(keys)
for _, code := range keys {
t := ticks[code]
fmt.Printf("【Tick】%s last=%.3f close=%.3f open=%s high=%s low=%s volume=%s\n",
code, t.LastPrice, t.LastClose,
rawStr(t.Raw, "open", "lastOpen", "Open"),
rawStr(t.Raw, "high", "High"),
rawStr(t.Raw, "low", "Low"),
rawStr(t.Raw, "volume", "Volume"),
)
}
}
func rawStr(m map[string]any, names ...string) string {
for _, name := range names {
if v, ok := m[name]; ok && v != nil {
return fmt.Sprint(v)
}
}
return "-"
}
func fatal(format string, args ...any) {
fmt.Fprintf(os.Stderr, format+"\n", args...)
os.Exit(1)
}

View File

@@ -1,96 +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, books *OrderBook, signals *libs.SignalResult) {
if !libs.TradingTime(time.Now()) {
return
}
// 1 取消过期订单
books.CancelExpired(ctx, 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 code, item := range signals.Data {
if !slices.Contains(pos_codes, code) {
allowOpen = append(allowOpen, item)
}
}
// 6 获取行情tick
ticks, err := client.FullTick(ctx, allCodes)
if err != nil {
logf("ERROR", "获取行情失败: %v", err)
return
}
// 7 执行开仓:有开仓信号 && 大盘指数允许开仓
if len(allowOpen) > 0 && IsAllow {
openSignal(ctx, client, books, ticks, allowOpen)
}
// 8 持仓计算
buyBudget := assets.Available
managePositions(ctx, client, books, ticks, positions, IsAllow, &buyBudget)
}

View File

@@ -1,43 +0,0 @@
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, books *OrderBook, ticks map[string]sdk.Tick, openSignals []libs.SignalItem) {
for _, item := range openSignals {
// 是否有锁
if _, err := QuantState.Get(item.Code); err == nil {
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 := newOrderTag("base")
if !books.place(ctx, client, sideBuy, 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)
}
}

View File

@@ -1,418 +0,0 @@
package logic
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"big-qmt/go-client/config"
"big-qmt/go-client/sdk"
)
type orderReceipt struct {
OrderID string `json:"order_id"`
QMTOrderID string `json:"qmt_order_id"`
StockCode string `json:"stock_code"`
Side string `json:"side"`
Status string `json:"status"`
RequestedVolume int `json:"requested_volume"`
TradedVolume int `json:"traded_volume"`
}
const (
opBuyStock = 23
opBuyAlt = 48
sideBuy = "buy"
sideSell = "sell"
)
var activeStatuses = map[int]struct{}{
48: {}, 49: {}, 50: {}, 51: {}, 52: {}, 55: {},
}
type parsedOrder struct {
OrderID string
StockCode string
Side string
Active bool
OrderTime int64
RemarkOwned bool
VolumeOrig int
VolumeLeft int
VolumeTraded int
Tag string
}
func (o parsedOrder) cancelVolume() int {
n := o.VolumeLeft + o.VolumeTraded
if n > 0 {
return n
}
return o.VolumeOrig
}
type submission struct {
Code string
Side string
}
type OrderBook struct {
mu sync.Mutex
cached []parsedOrder
hasCache bool
buyLocks map[string]time.Time
sellLocks map[string]time.Time
subs []submission
receipts map[string]time.Time
}
func NewOrderBook() *OrderBook {
return &OrderBook{
buyLocks: map[string]time.Time{},
sellLocks: map[string]time.Time{},
receipts: map[string]time.Time{},
}
}
// readReceipts 读取 QMT 回写并同步委托状态。
func (o *OrderBook) readReceipts() {
paths, _ := filepath.Glob(filepath.Join(config.Global.QMTDataDir, "order_*.json"))
for _, path := range paths {
info, err := os.Stat(path)
if err != nil {
continue
}
o.mu.Lock()
last := o.receipts[path]
o.mu.Unlock()
if !info.ModTime().After(last) {
continue
}
receipt, err := loadReceipt(path)
if err != nil || !strings.HasPrefix(receipt.OrderID, "zt-") || receipt.StockCode == "" || receipt.Status == "" {
continue
}
o.mu.Lock()
o.receipts[path] = info.ModTime()
o.mu.Unlock()
status := strings.ToLower(receipt.Status)
if (status == "filled" || status == "cancelled" || status == "rejected") && (receipt.Side == sideBuy || receipt.Side == sideSell) {
o.unlockSide(receipt.StockCode, receipt.Side)
o.invalidate()
}
logf("INFO", "[ZT][回写] %s status=%s traded=%d/%d", receipt.OrderID, status, receipt.TradedVolume, receipt.RequestedVolume)
}
}
func loadReceipt(path string) (*orderReceipt, error) {
raw, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var receipt orderReceipt
err = json.Unmarshal(raw, &receipt)
return &receipt, err
}
func (o *OrderBook) invalidate() {
o.mu.Lock()
defer o.mu.Unlock()
o.hasCache = false
o.cached = nil
}
func (o *OrderBook) query(ctx context.Context, client *sdk.Client) ([]parsedOrder, error) {
o.mu.Lock()
if o.hasCache {
out := append([]parsedOrder(nil), o.cached...)
o.mu.Unlock()
return out, nil
}
o.mu.Unlock()
raw, err := client.TradeDetailData(ctx, "order")
if err != nil {
logf("ERROR", "[ZT][委托] 查询失败: %v", err)
return nil, err
}
orders := make([]parsedOrder, 0, len(raw))
for _, item := range raw {
orders = append(orders, parseOrder(item))
}
o.mu.Lock()
o.cached = orders
o.hasCache = true
o.mu.Unlock()
return orders, nil
}
func (o *OrderBook) activeSets(ctx context.Context, client *sdk.Client) (buys, sells map[string]struct{}, ok bool) {
orders, err := o.query(ctx, client)
if err != nil {
return nil, nil, false
}
buys, sells = map[string]struct{}{}, map[string]struct{}{}
for _, item := range orders {
if !item.Active || item.StockCode == "" {
continue
}
if item.Side == sideBuy {
buys[item.StockCode] = struct{}{}
} else {
sells[item.StockCode] = struct{}{}
}
}
return buys, sells, true
}
func (o *OrderBook) CancelExpired(ctx context.Context, client *sdk.Client) bool {
o.invalidate()
orders, err := o.query(ctx, client)
if err != nil {
return false
}
state := QuantState
now := time.Now()
timeout := time.Duration(config.Account.OrderTimeoutSec) * time.Second
seen := map[string]struct{}{}
cancelled := false
for _, order := range orders {
if !order.Active || order.StockCode == "" {
continue
}
if !o.claimed(state, order) {
continue
}
if order.OrderTime <= 0 || now.Sub(time.Unix(order.OrderTime, 0)) <= timeout {
continue
}
vol := order.cancelVolume()
if vol <= 0 {
logf("WARNING", "[ZT][委托] 超时单缺少数量,跳过 %s %s", order.OrderID, order.StockCode)
continue
}
key := order.StockCode + "|" + strconv.Itoa(vol)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
if order.OrderID != "" {
can, err := client.CanCancelOrder(ctx, order.OrderID)
if err != nil {
logf("ERROR", "[ZT][委托] 查询是否可撤失败 %s: %v", order.OrderID, err)
continue
}
if !truthy(can) {
logf("INFO", "[ZT][委托] 不可撤 %s %s", order.OrderID, order.StockCode)
continue
}
}
ret, err := client.CancelByRule(ctx, order.StockCode, vol)
if err != nil {
logf("ERROR", "[ZT][委托] 撤单失败 %s %s: %v", order.OrderID, order.StockCode, err)
continue
}
if ret == nil || ret.Status != "success" {
msg := ""
if ret != nil {
msg = ret.Message
}
logf("WARNING", "[ZT][委托] 规则撤单未命中 %s %s volume=%d %s", order.OrderID, order.StockCode, vol, msg)
continue
}
o.unlockSide(order.StockCode, order.Side)
cancelled = true
logf("INFO", "[ZT][委托] 撤销超时单 %s %s %s volume=%d", order.OrderID, order.StockCode, order.Side, vol)
}
if cancelled {
o.invalidate()
}
return true
}
func (o *OrderBook) claimed(state *State, order parsedOrder) bool {
if order.RemarkOwned {
return true
}
o.mu.Lock()
for _, s := range o.subs {
if s.Code == order.StockCode && s.Side == order.Side {
o.mu.Unlock()
return true
}
}
o.mu.Unlock()
if state == nil {
return false
}
item, err := state.Get(order.StockCode)
if err != nil {
return false
}
return order.OrderID == item.BaseOrderId || order.OrderID == item.AddedOrderId || item.BaseStatus == StatusIng || item.AddedStatus == StatusIng
}
func (o *OrderBook) unlockSide(code, side string) {
o.mu.Lock()
defer o.mu.Unlock()
delete(o.locks(side), code)
n := 0
for _, s := range o.subs {
if s.Code == code && s.Side == side {
continue
}
o.subs[n] = s
n++
}
o.subs = o.subs[:n]
}
func (o *OrderBook) sideBusy(code, side string, active map[string]struct{}) bool {
if _, ok := active[code]; ok {
return true
}
return o.locked(code, side)
}
func (o *OrderBook) locked(code, side string) bool {
o.mu.Lock()
defer o.mu.Unlock()
ts, ok := o.locks(side)[code]
return ok && time.Since(ts) < time.Duration(config.Account.OrderTimeoutSec)*time.Second
}
func (o *OrderBook) locks(side string) map[string]time.Time {
if side == sideBuy {
return o.buyLocks
}
return o.sellLocks
}
func (o *OrderBook) hasActive(ctx context.Context, client *sdk.Client, code, side string) bool {
orders, err := o.query(ctx, client)
if err != nil {
return true
}
for _, item := range orders {
if item.StockCode == code && item.Active && item.Side == side {
return true
}
}
return false
}
func (o *OrderBook) place(ctx context.Context, client *sdk.Client, side, code string, volume int, tag string) bool {
if volume <= 0 || volume%100 != 0 {
logf("ERROR", "[ZT][委托] %s 拒绝非整手数量=%d", code, volume)
return false
}
if o.locked(code, side) {
logf("INFO", "[ZT][委托] %s %s锁定中", code, side)
return false
}
if o.hasActive(ctx, client, code, side) {
logf("INFO", "[ZT][委托] %s 已有%s在途委托", code, side)
return false
}
_, err := client.PassorderLatestTagged(ctx, side == sideBuy, code, volume, tag)
if err != nil {
logf("ERROR", "[ZT][委托] %s 异常: %v", code, err)
return false
}
o.mu.Lock()
o.locks(side)[code] = time.Now()
o.subs = append(o.subs, submission{Code: code, Side: side})
o.mu.Unlock()
logf("INFO", "[ZT][委托] 已提交 %s %s %d股 tag=%s", side, code, volume, tag)
return true
}
func parseOrder(item map[string]string) parsedOrder {
operation, _ := strconv.Atoi(item["m_nOffsetFlag"])
status, _ := strconv.Atoi(item["m_nOrderStatus"])
tag := item["m_strRemark"]
orderTime, _ := strconv.ParseInt(item["m_nOrderTime"], 10, 64)
if orderTime > 1e11 {
orderTime /= 1000
}
if orderTime <= 0 {
date := item["m_strInsertDate"]
clock := strings.ReplaceAll(item["m_strInsertTime"], ":", "")
if date != "" {
if len(clock) < 6 {
clock = strings.Repeat("0", 6-len(clock)) + clock
}
if t, err := time.ParseInLocation("20060102150405", date+clock, time.Local); err == nil {
orderTime = t.Unix()
}
}
}
side := sideSell
if operation == opBuyStock || operation == opBuyAlt {
side = sideBuy
}
left, _ := strconv.Atoi(item["m_nVolumeTotal"])
traded, _ := strconv.Atoi(item["m_nVolumeTraded"])
orig, _ := strconv.Atoi(item["m_nVolumeTotalOriginal"])
_, active := activeStatuses[status]
return parsedOrder{
OrderID: item["m_strOrderSysID"],
StockCode: item["m_strInstrumentID"],
Side: side,
Active: active,
OrderTime: orderTime,
RemarkOwned: strings.HasPrefix(tag, "zt-"),
VolumeOrig: orig,
VolumeLeft: left,
VolumeTraded: traded,
Tag: tag,
}
}
func truthy(v any) bool {
if v == nil {
return false
}
switch x := v.(type) {
case bool:
return x
case string:
s := strings.ToLower(strings.TrimSpace(x))
return s == "true" || s == "1" || s == "yes"
case float64:
return x != 0
case int:
return x != 0
default:
s := strings.ToLower(strings.TrimSpace(fmt.Sprint(v)))
return s == "true" || s == "1"
}
}
func newOrderTag(leg string) string {
legCode := map[string]string{"base": "b", "add": "a", "take_profit": "t", "all": "s"}[leg]
if legCode == "" {
legCode = "x"
}
var buf [6]byte
_, _ = rand.Read(buf[:])
// 订单号同时用于 Windows 回写文件名,因此只使用文件名安全字符。
tag := fmt.Sprintf("zt-%s-%s", legCode, hex.EncodeToString(buf[:]))
if len(tag) > 24 {
return tag[:24]
}
return tag
}
func parseHM(now time.Time) int {
n, _ := strconv.Atoi(now.Format("1504"))
return n
}

View File

@@ -1,242 +0,0 @@
package logic
import (
"context"
"math"
"sync"
"big-qmt/go-client/config"
"big-qmt/go-client/libs"
"big-qmt/go-client/sdk"
)
var peakMu sync.Mutex
var peakGrids = map[string]int{}
func peakKey(code, leg string) string { return code + "|" + leg }
func calcBuyVolume(price, value float64) int {
return libs.CalcBuyVolume(price, value)
}
func stateCodes(state *State) []string {
state.mu.Lock()
defer state.mu.Unlock()
return append([]string(nil), state.Codes...)
}
func managePositions(ctx context.Context, client *sdk.Client, books *OrderBook, ticks map[string]sdk.Tick, positions []sdk.Position, marketOK bool, buyBudget *float64) {
if positions == nil || QuantState == nil {
logf("ERROR", "[ZT][持仓] 持仓或状态不可用,本轮跳过")
return
}
buys, sells, ok := books.activeSets(ctx, client)
if !ok {
return
}
before := map[string]struct{}{}
for _, code := range stateCodes(QuantState) {
before[code] = struct{}{}
}
if ticks == nil {
ticks = map[string]sdk.Tick{}
}
type row struct {
volume, usable int
avg, price float64
stock string
item *StateItem
}
rows := make([]row, 0, len(positions))
seen := map[string]struct{}{}
for _, pos := range positions {
code := pos.StockCode
if code == "" {
continue
}
seen[code] = struct{}{}
item := syncItem(QuantState, code, pos.Volume, pos.OpenPrice, buys, sells, books)
if pos.Volume > 0 {
rows = append(rows, row{stock: code, volume: pos.Volume, usable: pos.CanUseVolume, avg: pos.OpenPrice, price: ticks[code].LastPrice, item: item})
}
}
for _, code := range stateCodes(QuantState) {
if _, ok := seen[code]; !ok {
syncItem(QuantState, code, 0, 0, buys, sells, books)
}
}
after := map[string]struct{}{}
for _, code := range stateCodes(QuantState) {
after[code] = struct{}{}
}
for code := range before {
if _, ok := after[code]; !ok {
forget(code)
}
}
for _, r := range rows {
if r.item == nil || r.item.BaseStatus == StatusIng || r.item.AddedStatus == StatusIng || r.avg <= 0 || r.price <= 0 || r.volume%100 != 0 {
continue
}
if r.volume != r.item.BaseQty+r.item.AddedQty {
logf("INFO", "[ZT][持仓] %s 数量异常,底仓=%d 补仓=%d 现有=%d", r.stock, r.item.BaseQty, r.item.AddedQty, r.volume)
continue
}
if r.item.AddedQty > 0 {
addPnL := -999.0
if r.item.AddedCost > 0 {
addPnL = (r.price - r.item.AddedCost) / r.item.AddedCost * 100
}
if retreated(r.item, "add", addPnL) {
sellLeg(ctx, client, books, r.item, r.usable, r.item.AddedQty, "add", addPnL)
}
continue
}
basePnL := -999.0
if r.item.BaseCost > 0 {
basePnL = (r.price - r.item.BaseCost) / r.item.BaseCost * 100
}
if retreated(r.item, "base", basePnL) {
sellLeg(ctx, client, books, r.item, r.usable, r.item.BaseQty, "base", basePnL)
} else if basePnL <= config.Account.LossTriggerPct {
addOnRebound(ctx, client, books, r.item, r.price, marketOK, buyBudget)
}
}
if err := QuantState.Save(); err != nil {
logf("ERROR", "%v", err)
}
}
func syncItem(state *State, code string, volume int, avgPrice float64, buys, sells map[string]struct{}, books *OrderBook) *StateItem {
item, err := state.Get(code)
if err != nil {
if volume > 0 {
logf("ERROR", "[ZT][持仓] %s 无本地状态,跳过", code)
}
return nil
}
if item.BaseStatus == StatusIng {
syncBase(state, item, volume, avgPrice, buys, sells, books)
} else if item.AddedStatus == StatusIng {
syncAdded(state, item, volume, avgPrice, buys, sells, books)
} else if volume <= 0 {
state.Delete(code)
return nil
}
item, _ = state.Get(code)
return item
}
func syncBase(state *State, item *StateItem, volume int, avgPrice float64, buys, sells map[string]struct{}, books *OrderBook) {
if books.sideBusy(item.Code, sideBuy, buys) || books.sideBusy(item.Code, sideSell, sells) {
return
}
if volume <= 0 {
state.Delete(item.Code)
return
}
item.BaseQty = volume - item.AddedQty
if item.BaseQty < 0 {
item.BaseQty, item.AddedQty, item.AddedCost, item.AddedStatus = volume, 0, 0, StatusNone
}
item.BaseCost = avgPrice
item.BaseStatus = StatusOk
state.Set(item)
}
func syncAdded(state *State, item *StateItem, volume int, avgPrice float64, buys, sells map[string]struct{}, books *OrderBook) {
if books.sideBusy(item.Code, sideBuy, buys) || books.sideBusy(item.Code, sideSell, sells) {
return
}
if volume <= 0 {
state.Delete(item.Code)
return
}
if volume > item.BaseQty {
item.AddedQty = volume - item.BaseQty
item.AddedCost = math.Max(0, (avgPrice*float64(volume)-item.BaseCost*float64(item.BaseQty))/float64(item.AddedQty))
item.AddedStatus = StatusOk
} else {
item.BaseQty, item.BaseCost = volume, avgPrice
item.AddedQty, item.AddedCost, item.AddedStatus = 0, 0, StatusNone
peakMu.Lock()
delete(peakGrids, peakKey(item.Code, "add"))
peakMu.Unlock()
}
state.Set(item)
}
func addOnRebound(ctx context.Context, client *sdk.Client, books *OrderBook, item *StateItem, price float64, marketOK bool, buyBudget *float64) {
if !marketOK || PosbuyWatch == nil || !PosbuyWatch.Triggered("补仓", item.Code, price) {
return
}
volume := libs.CalcBuyVolume(price, config.Account.BuyValue)
estimated := price * float64(volume)
if volume <= 0 || buyBudget == nil || estimated > *buyBudget {
return
}
orderID := newOrderTag("add")
if books.place(ctx, client, sideBuy, item.Code, volume, orderID) {
item.AddedOrderId, item.AddedQty, item.AddedCost, item.AddedStatus = orderID, volume, price, StatusIng
item.AddedNum++
QuantState.Set(item)
*buyBudget -= estimated
if err := QuantState.Save(); err != nil {
logf("ERROR", "%v", err)
}
}
}
func retreated(item *StateItem, leg string, pnl float64) bool {
if pnl < config.Account.MinProfitPct {
return false
}
grid := int(math.Floor(pnl / config.Account.GridStepPct))
key := peakKey(item.Code, leg)
peakMu.Lock()
defer peakMu.Unlock()
peak, ok := peakGrids[key]
if !ok || grid > peak {
peakGrids[key] = grid
return false
}
return grid < peak
}
func sellLeg(ctx context.Context, client *sdk.Client, books *OrderBook, item *StateItem, usable, volume int, leg string, pnl float64) {
volume -= volume % 100
if volume <= 0 || usable < volume {
return
}
orderID := newOrderTag(leg)
if !books.place(ctx, client, sideSell, item.Code, volume, orderID) {
return
}
if leg == "add" {
item.AddedOrderId, item.AddedStatus = orderID, StatusIng
} else {
item.BaseOrderId, item.BaseStatus = orderID, StatusIng
}
QuantState.Set(item)
if err := QuantState.Save(); err != nil {
logf("ERROR", "%v", err)
}
logf("INFO", "[ZT][止盈] %s 卖出 %d 股,%s腿盈利=%.2f%%", item.Code, volume, leg, pnl)
}
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()
}
peakMu.Lock()
delete(peakGrids, peakKey(code, "base"))
delete(peakGrids, peakKey(code, "add"))
peakMu.Unlock()
}

View File

@@ -1,152 +0,0 @@
package logic
import (
"encoding/json"
"fmt"
"os"
"path"
"slices"
"sync"
"big-qmt/go-client/config"
"big-qmt/go-client/sdk"
)
var (
StatusNone = ""
StatusIng = "ING" // 处理中
StatusOk = "OK" // 成功
QuantState *State
)
type State struct {
AbsPath string
mu sync.Mutex
Items map[string]*StateItem
Codes []string
}
type StateItem struct {
Code string `json:"code"`
BaseOrderId string `json:"base_order_id"`
BaseQty int `json:"base_qty"`
BaseCost float64 `json:"base_cost"`
BaseStatus string `json:"base_status,omitempty"`
AddedOrderId string `json:"added_order_id"`
AddedNum int `json:"add_num"`
AddedQty int `json:"add_qty"`
AddedCost float64 `json:"add_cost"`
AddedStatus string `json:"added_status,omitempty"`
}
func InitState(sn string) error {
absPath := path.Join(config.Global.QMTDataDir, fmt.Sprintf("%s_%s_state.json", sn, config.Account.AccountID))
items, err := loadStateFile(absPath)
if err != nil {
return err
}
var codes []string
for code, _ := range items {
codes = append(codes, code)
}
QuantState = &State{
AbsPath: absPath,
Items: items,
Codes: codes,
}
return nil
}
func loadStateFile(fp string) (map[string]*StateItem, error) {
raw, err := os.ReadFile(fp)
if err != nil {
return nil, fmt.Errorf("[状态] 读取失败: %v", err)
}
var items map[string]*StateItem
if err := json.Unmarshal(raw, &items); err != nil {
return nil, fmt.Errorf("[状态] 解析失败:%s", err)
}
return items, nil
}
func SyncPositions(positions []sdk.Position) error {
for _, pos := range positions {
code := pos.StockCode
if code == "" || pos.Volume <= 0 || pos.OpenPrice <= 0 {
continue
}
if !slices.Contains(QuantState.Codes, code) {
item := &StateItem{
Code: code,
BaseQty: pos.Volume,
BaseCost: pos.OpenPrice,
BaseStatus: StatusOk,
}
QuantState.Append(item)
logf("WARNING", "[状态] %s 首次接管为底仓 数量=%d 成本=%.2f", code, pos.Volume, pos.OpenPrice)
}
}
return QuantState.Save()
}
func (s *State) Append(i *StateItem) {
s.mu.Lock()
defer s.mu.Unlock()
s.Items[i.Code] = i
s.Codes = append(s.Codes, i.Code)
}
func (s *State) Get(code string) (*StateItem, error) {
s.mu.Lock()
defer s.mu.Unlock()
if i, ok := s.Items[code]; ok {
return i, nil
} else {
return nil, fmt.Errorf("%s not found.", code)
}
}
func (s *State) Set(i *StateItem) {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.Items[i.Code]; !ok {
s.Codes = append(s.Codes, i.Code)
}
s.Items[i.Code] = i
}
func (s *State) Delete(code string) {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.Items, code)
if index := slices.Index(s.Codes, code); index >= 0 {
s.Codes = slices.Delete(s.Codes, index, index+1)
}
}
func (s *State) Save() error {
s.mu.Lock()
defer s.mu.Unlock()
// 写入AbsPath文件
f, err := os.OpenFile(s.AbsPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644)
if err != nil {
return fmt.Errorf("[状态] 打开文件失败: %v", err)
}
defer f.Close()
encoder := json.NewEncoder(f)
encoder.SetIndent("", " ")
if err := encoder.Encode(s.Items); err != nil {
return fmt.Errorf("[状态] 写入失败: %v", err)
}
return nil
}

View File

@@ -1,39 +0,0 @@
package logic
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestCalcBuyVolume(t *testing.T) {
for _, tt := range []struct {
price, value float64
want int
}{{10, 5000, 500}, {33, 5000, 100}, {100, 5000, 100}, {0, 5000, 0}} {
if got := calcBuyVolume(tt.price, tt.value); got != tt.want {
t.Fatalf("calcBuyVolume(%v,%v)=%d, want %d", tt.price, tt.value, got, tt.want)
}
}
}
func TestNewOrderTagIsShortAndFileSafe(t *testing.T) {
tag := newOrderTag("base")
if len(tag) > 24 || !strings.HasPrefix(tag, "zt-") || strings.ContainsAny(tag, `<>:"/\\|?*`) {
t.Fatalf("订单号不符合约束: %q", tag)
}
}
func TestLoadReceipt(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "order_zt.json")
raw := []byte(`{"order_id":"zt-b-123","qmt_order_id":"9","stock_code":"000001.SZ","side":"buy","requested_volume":500,"traded_volume":500,"status":"filled","updated_at":"2026-08-25T10:00:00+08:00"}`)
if err := os.WriteFile(path, raw, 0o644); err != nil {
t.Fatal(err)
}
got, err := loadReceipt(path)
if err != nil || got.Status != "filled" || got.TradedVolume != 500 {
t.Fatalf("loadReceipt()=%+v, %v", got, err)
}
}

View File

@@ -1,66 +0,0 @@
package logic
import (
"sync"
"time"
)
var (
WatchExpireTime = 5 * time.Minute
WatchReThreshold = 0.61
OpenWatch *WatchMu
PosbuyWatch *WatchMu
)
type dipWatch struct {
LastClose float64
ExpiresAt time.Time
}
type WatchMu struct {
mu sync.Mutex
Data map[string]dipWatch
}
func InitWatch() {
OpenWatch = &WatchMu{
Data: make(map[string]dipWatch),
}
PosbuyWatch = &WatchMu{
Data: make(map[string]dipWatch),
}
}
func (w *WatchMu) Triggered(tag, code string, price float64) bool {
if price <= 0 {
return false
}
w.mu.Lock()
defer w.mu.Unlock()
now := time.Now()
watch, ok := w.Data[code]
if !ok || now.After(watch.ExpiresAt) || now.Equal(watch.ExpiresAt) {
w.Data[code] = dipWatch{LastClose: price, ExpiresAt: now.Add(WatchExpireTime)}
logf("INFO", "[%s-观察] %s 现价=%.2f", tag, code, price)
return false
}
if price < watch.LastClose {
watch.LastClose = price
watch.ExpiresAt = now.Add(WatchExpireTime)
w.Data[code] = watch
logf("INFO", "[%s-下跌] %s 刷新低点=%.2f", tag, code, price)
return false
}
rebound := (price - watch.LastClose) / watch.LastClose * 100
if rebound <= 0 {
return false
}
if rebound < WatchReThreshold {
logf("INFO", "[%s-等待] %s 反弹=%.2f%% 阈值=%.2f%%", tag, code, rebound, WatchReThreshold)
return false
}
delete(w.Data, code)
logf("INFO", "[%s-触发] %s 反弹=%.2f%% 低点=%.2f", tag, code, rebound, watch.LastClose)
return true
}

View File

@@ -1,95 +0,0 @@
package main
import (
"context"
"log"
"os"
"os/signal"
"syscall"
"time"
"big-qmt/go-client/apps/zt/logic"
"big-qmt/go-client/config"
"big-qmt/go-client/libs"
"big-qmt/go-client/sdk"
"github.com/robfig/cron/v3"
)
var (
StrategyName = "zt"
)
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.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())
}
// 第三步:连接成功后接管首次持仓并打印账户概览。
logic.Overview(assets, positions)
signals, err := libs.FetchSignal(libs.Dcm_Signal, config.Account.HostKey)
if err != nil {
log.Printf("[ERROR] [ZT] 获取开仓信号失败: %v", err)
signals = &libs.SignalResult{Data: map[string]libs.SignalItem{}}
}
log.Printf("[INFO] [ZT] 已加载 %d 个开仓信号", len(signals.Data))
// 第四步:工作日每 30 秒触发,交易时段由 RunOnce 统一判断。
books := logic.NewOrderBook()
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, books, signals)
}); err != nil {
log.Fatalf("[ERROR] 创建计划任务失败: %v", err)
}
scheduler.Start()
log.Printf("[INFO] [ZT] 计划任务已启动")
<-ctx.Done()
<-scheduler.Stop().Done()
log.Printf("[INFO] [ZT] 停止")
}
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] [ZT] QMT连接成功: %s", config.Global.QMTBaseURL)
return assets, positions, true
}
log.Printf("[WARNING] [ZT] QMT未就绪5秒后重试: assets=%v positions=%v", assetsErr, positionsErr)
select {
case <-ctx.Done():
return nil, nil, false
case <-time.After(5 * time.Second):
}
}
}

View File

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

View File

@@ -1,96 +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"`
}
type AccountConfig struct {
AccountID string `yaml:"account_id"`
HostKey string `yaml:"host_key"`
OrderTimeoutSec int `yaml:"order_timeout_seconds"`
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"`
}
// 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")
}
Global = &global
Account = &account
return nil
}
func readYAML(path string, dest any) error {
raw, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("读取配置 %s 失败: %w", path, err)
}
if err := yaml.Unmarshal(raw, dest); err != nil {
return fmt.Errorf("解析配置 %s 失败: %w", path, err)
}
return nil
}
func hostAccountFile(hosts map[string]string, hostname string) string {
for host, file := range hosts {
if strings.EqualFold(strings.TrimSpace(host), strings.TrimSpace(hostname)) {
return strings.TrimSpace(file)
}
}
return ""
}

View File

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

View File

@@ -1,9 +0,0 @@
# 系统公共参数。hosts 将 Windows 计算机名映射到账户配置文件。
qmt_base_url: http://127.0.0.1:10086
qmt_token: QMTbyYanweidong
api_host: http://go.apinb.com
qmt_data_dir: D:/qmt_strategy_data
state_dir: D:/qmt_strategy_state
hosts:
DESKTOP-39H91QV: dev.yaml

View File

@@ -1,15 +0,0 @@
module big-qmt/go-client
go 1.26.5
require (
git.apinb.com/bsm-sdk/core v0.2.1
github.com/robfig/cron/v3 v3.0.1
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/google/uuid v1.6.0 // indirect
github.com/oklog/ulid/v2 v2.1.2 // indirect
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect
)

View File

@@ -1,15 +0,0 @@
git.apinb.com/bsm-sdk/core v0.2.1 h1:1kpbdij3qOlf1DmKTq3coIXSgLth5iJHJ3LvVZnjaXM=
git.apinb.com/bsm-sdk/core v0.2.1/go.mod h1:BL/aGHujCWdxrKZrWaiebmLx69J0OrTVv5XfugbbyhE=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/oklog/ulid/v2 v2.1.2 h1:IEclFb9JNvzYA6MW2SCxbLzcHTVsfqm3PrqGQJH5zec=
github.com/oklog/ulid/v2 v2.1.2/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ=
github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -1,38 +0,0 @@
package libs
import (
"math"
"math/rand"
"time"
)
var letters = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
func randStr(n int) string {
b := make([]rune, n)
for i := range b {
b[i] = letters[rand.Intn(len(letters))]
}
return string(b)
}
func TradingTime(t time.Time) bool {
if t.Weekday() == time.Saturday || t.Weekday() == time.Sunday {
return false
}
second := t.Hour()*3600 + t.Minute()*60 + t.Second()
return (second >= 9*3600+30*60 && second <= 11*3600+30*60) ||
(second >= 13*3600 && second <= 15*3600)
}
func CalcBuyVolume(price, buyValue float64) int {
if price <= 0 || buyValue <= 0 {
return 0
}
// 不足一手时仍按最低一手委托。
hands := int(math.Floor(buyValue / (price * 100)))
if hands == 0 {
hands = 1
}
return hands * 100
}

View File

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

View File

@@ -1,33 +0,0 @@
package libs
import (
"fmt"
"io"
"net/http"
"strings"
"time"
)
// GetJSON 请求 JSON 接口并返回对象。
func GetJSON(rawURL string, timeout time.Duration) ([]byte, error) {
req, err := http.NewRequest(http.MethodGet, rawURL, nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", "big-qmt-go/1")
client := &http.Client{Timeout: timeout}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("http %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
return body, nil
}

View File

@@ -1,67 +0,0 @@
package libs
import (
"encoding/json"
"fmt"
"log"
"strings"
)
var (
MarketUrl = "/a/market"
Period = "60m"
)
// AllowOpen 每次开仓或补仓前取 60 分钟大盘信号,只有 UP 才放行。
func MarketAllowOpen() bool {
// gen url.
fullUrl := fmt.Sprintf("%s%s?period=%s&t=%s", API_HOST, MarketUrl, Period, randStr(16))
payload, err := GetJSON(fullUrl, HTTPTimeout)
if err != nil {
log.Printf("[ERROR] 获取大盘指数失败: %s %v", fullUrl, err)
return false
}
var result map[string]any
err = json.Unmarshal(payload, &result)
if err != nil {
log.Printf("[ERROR] 获取大盘指数解析: %v", err)
return false
}
status := Status(result)
log.Printf("[INFO] 大盘信号: url=%s status=%s", fullUrl, status)
return status == "UP"
}
// Status 兼容常见响应结构;无法识别的值统一按 UNKNOWN 处理。
func Status(payload map[string]any) string {
var value any = payload
if m, ok := value.(map[string]any); ok {
if d, exists := m["data"]; exists {
value = d
}
}
if arr, ok := value.([]any); ok {
if len(arr) == 0 {
value = nil
} else {
value = arr[len(arr)-1]
}
}
if m, ok := value.(map[string]any); ok {
if v, exists := m["action"]; exists {
value = v
} else if v, exists := m["status"]; exists {
value = v
} else if v, exists := m["signal"]; exists {
value = v
}
}
s := strings.ToUpper(strings.TrimSpace(fmt.Sprint(value)))
switch s {
case "UP", "DOWN", "NEUTRAL":
return s
default:
return "UNKNOWN"
}
}

View File

@@ -1,46 +0,0 @@
package libs
import (
"encoding/json"
"fmt"
"log"
)
var (
Dcm_Signal = "/a/dcm_signal"
)
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 {
Code string `json:"code"`
Name string `json:"name"`
Desc string `json:"desc"`
LastClose float64 `json:"last_close"`
TechIndicator map[string]float64 `json:"tech_indicator"`
}
// FetchSignals 启动时读取信号,运行期间直接使用内存数据。
func FetchSignal(subUrl, host_key string) (*SignalResult, error) {
// gen url.
fullUrl := fmt.Sprintf("%s%s?host_key=%s&t=%s", API_HOST, subUrl, host_key, randStr(16))
// doing
payload, err := GetJSON(fullUrl, HTTPTimeout)
if err != nil {
log.Printf("[ERROR] 获取60m大盘信号失败: %s %v", fullUrl, err)
return nil, err
}
var result SignalResult
err = json.Unmarshal(payload, &result)
if err != nil {
return nil, err
}
return &result, nil
}

View File

@@ -1,179 +0,0 @@
package sdk
import (
"context"
"encoding/json"
"fmt"
)
// Position 对应 HoldingHandler 封装后的持仓。
type Position struct {
StockCode string `json:"StockCode"`
StockName string `json:"StockName"`
Direction any `json:"Direction"`
Volume int `json:"Volume"`
OpenPrice float64 `json:"OpenPrice"`
FloatProfit float64 `json:"FloatProfit"`
MarketValue float64 `json:"MarketValue"`
StockHolder string `json:"StockHolder"`
FrozenVolume int `json:"FrozenVolume"`
CanUseVolume int `json:"CanUseVolume"`
OnRoadVolume int `json:"OnRoadVolume"`
YesterdayVolume int `json:"YesterdayVolume"`
LastPrice float64 `json:"LastPrice"`
ProfitRate float64 `json:"ProfitRate"`
FutureTradeType any `json:"FutureTradeType"`
ExpireDate string `json:"ExpireDate"`
}
type Assets struct {
Total float64 `json:"total"`
Available float64 `json:"available"`
}
func (c *Client) Positions(ctx context.Context) ([]string, []Position, error) {
return c.decodePositions(ctx, "/api/v2/positions")
}
func (c *Client) Holding(ctx context.Context) ([]string, []Position, error) {
return c.decodePositions(ctx, "/api/holding")
}
func (c *Client) decodePositions(ctx context.Context, path string) ([]string, []Position, error) {
raw := map[string]json.RawMessage{}
if err := c.post(ctx, path, map[string]any{"account": c.accountType}, &raw); err != nil {
return nil, nil, err
}
codes := make([]string, 0, len(raw))
out := make([]Position, 0, len(raw))
for code, blob := range raw {
var p Position
if err := json.Unmarshal(blob, &p); err != nil {
return nil, nil, fmt.Errorf("position %s: %w", code, err)
}
if p.StockCode == "" {
p.StockCode = code
}
codes = append(codes, code)
out = append(out, p)
}
return codes, out, nil
}
func (c *Client) Assets(ctx context.Context) (*Assets, error) {
var out Assets
if err := c.post(ctx, "/api/v2/assets", map[string]any{"account": c.accountType}, &out); err != nil {
return nil, err
}
return &out, nil
}
func (c *Client) TotalMoney(ctx context.Context) (float64, error) {
var out struct {
TotalMoney float64 `json:"total_money"`
}
if err := c.post(ctx, "/api/money/total", map[string]any{"account": c.accountType}, &out); err != nil {
return 0, err
}
return out.TotalMoney, nil
}
func (c *Client) AvailableMoney(ctx context.Context) (float64, error) {
var out struct {
AvailableMoney float64 `json:"available_money"`
}
if err := c.post(ctx, "/api/money/available", map[string]any{"account": c.accountType}, &out); err != nil {
return 0, err
}
return out.AvailableMoney, nil
}
type OrderRefResult struct {
Status string `json:"status"`
Action string `json:"action"`
Stock string `json:"stock"`
OpType int `json:"opType"`
OrderRef string `json:"order_ref"`
}
func (c *Client) Buy(ctx context.Context, stock string, price float64, volume int, prType int) (*OrderRefResult, error) {
body := map[string]any{"stock": stock, "price": price, "volume": volume}
if prType != 0 {
body["prType"] = prType
}
var out OrderRefResult
if err := c.post(ctx, "/api/order/buy", body, &out); err != nil {
return nil, err
}
return &out, nil
}
func (c *Client) Sell(ctx context.Context, stock string, price float64, volume int, prType int) (*OrderRefResult, error) {
body := map[string]any{"stock": stock, "price": price, "volume": volume}
if prType != 0 {
body["prType"] = prType
}
var out OrderRefResult
if err := c.post(ctx, "/api/order/sell", body, &out); err != nil {
return nil, err
}
return &out, nil
}
type OrderStatus struct {
OrderSysID string `json:"order_sys_id"`
Status int `json:"status"`
VolumeLeft int `json:"volume_left"`
VolumeTraded int `json:"volume_traded"`
}
func (c *Client) OrderStatusList(ctx context.Context) ([]OrderStatus, error) {
var out struct {
Orders []OrderStatus `json:"orders"`
}
if err := c.post(ctx, "/api/order/status", map[string]any{"account": c.accountType}, &out); err != nil {
return nil, err
}
return out.Orders, nil
}
type CanceledOrder struct {
OrderSysID string `json:"order_sys_id"`
Stock string `json:"stock"`
VolumeLeft int `json:"volume_left"`
}
type CancelAllResult struct {
Status string `json:"status"`
Message string `json:"message"`
CanceledOrders []CanceledOrder `json:"canceled_orders"`
CanceledSysIDs []string `json:"canceled_sys_ids"`
}
func (c *Client) CancelAll(ctx context.Context) (*CancelAllResult, error) {
var out CancelAllResult
if err := c.post(ctx, "/api/order/cancel_all", map[string]any{"account": c.accountType}, &out); err != nil {
return nil, err
}
return &out, nil
}
// CancelByRule 按「代码.市场 + (剩余+已成)」匹配撤单。HTTP 没有按委托号撤单。
func (c *Client) CancelByRule(ctx context.Context, stock string, volume int) (*CancelAllResult, error) {
var out CancelAllResult
body := map[string]any{"stock": stock, "volume": volume, "account": c.accountType}
if err := c.post(ctx, "/api/order/cancel_order", body, &out); err != nil {
return nil, err
}
return &out, nil
}
func (c *Client) Deals(ctx context.Context) ([]map[string]string, error) {
var out struct {
Deals []map[string]string `json:"deals"`
}
if err := c.post(ctx, "/api/order/deal", map[string]any{"account": c.accountType}, &out); err != nil {
return nil, err
}
return out.Deals, nil
}

View File

@@ -1,30 +0,0 @@
package sdk
import "context"
func (c *Client) IsLastBar(ctx context.Context) (any, error) {
return c.getField(ctx, "/api/check/is_last_bar", "is_last_bar")
}
func (c *Client) IsNewBar(ctx context.Context) (any, error) {
return c.getField(ctx, "/api/check/is_new_bar", "is_new_bar")
}
func (c *Client) IsSuspendedStock(ctx context.Context, stockcode string) (any, error) {
return c.postField(ctx, "/api/check/is_suspended_stock", map[string]any{"stockcode": stockcode}, "is_suspended")
}
func (c *Client) IsSectorStock(ctx context.Context, sectorname, market, stockcode string) (any, error) {
body := map[string]any{"sectorname": sectorname, "market": market, "stockcode": stockcode}
return c.postField(ctx, "/api/check/is_sector_stock", body, "is_in_sector")
}
func (c *Client) IsTypedStock(ctx context.Context, stocktypenum int, market, stockcode string) (any, error) {
body := map[string]any{"stocktypenum": stocktypenum, "market": market, "stockcode": stockcode}
return c.postField(ctx, "/api/check/is_typed_stock", body, "result")
}
func (c *Client) IndustryNameOfStock(ctx context.Context, industryType, stockcode string) (any, error) {
body := map[string]any{"industryType": industryType, "stockcode": stockcode}
return c.postField(ctx, "/api/check/get_industry_name_of_stock", body, "industry_name")
}

View File

@@ -1,141 +0,0 @@
package sdk
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
type Client struct {
baseURL string
token string
accountType string
http *http.Client
}
func New(baseURL, token string, timeout time.Duration) *Client {
if timeout <= 0 {
timeout = 15 * time.Second
}
return &Client{
baseURL: strings.TrimRight(baseURL, "/"),
token: token,
accountType: "stock",
http: &http.Client{Timeout: timeout},
}
}
func (c *Client) SetAccountType(accountType string) *Client {
if strings.TrimSpace(accountType) != "" {
c.accountType = accountType
}
return c
}
func (c *Client) get(ctx context.Context, path string, dest any) error {
return c.do(ctx, http.MethodGet, path, nil, dest)
}
func (c *Client) post(ctx context.Context, path string, body any, dest any) error {
if body == nil {
body = map[string]any{}
}
return c.do(ctx, http.MethodPost, path, body, dest)
}
func (c *Client) do(ctx context.Context, method, path string, body any, dest any) error {
var rdr io.Reader
if body != nil && method != http.MethodGet {
raw, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("marshal request: %w", err)
}
rdr = bytes.NewReader(raw)
}
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, rdr)
if err != nil {
return err
}
req.Header.Set("X-Token", c.token)
req.Header.Set("Accept", "application/json")
if rdr != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := c.http.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
raw, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
if resp.StatusCode >= 400 {
apiErr := &APIError{StatusCode: resp.StatusCode, Message: strings.TrimSpace(string(raw))}
var parsed APIError
if json.Unmarshal(raw, &parsed) == nil {
if parsed.StatusCode == 0 {
parsed.StatusCode = resp.StatusCode
}
if parsed.Message != "" {
apiErr = &parsed
}
}
return apiErr
}
if dest == nil || len(raw) == 0 {
return nil
}
if err := json.Unmarshal(raw, dest); err != nil {
return fmt.Errorf("unmarshal %s: %w; body=%s", path, err, truncate(raw, 512))
}
return nil
}
func (c *Client) getField(ctx context.Context, path, key string) (any, error) {
var out map[string]any
if err := c.get(ctx, path, &out); err != nil {
return nil, err
}
return out[key], nil
}
func (c *Client) postField(ctx context.Context, path string, body any, key string) (any, error) {
var out map[string]any
if err := c.post(ctx, path, body, &out); err != nil {
return nil, err
}
if msg, ok := out["error"].(string); ok && msg != "" {
return nil, &BusinessError{Message: msg}
}
if key == "" {
return out, nil
}
if v, ok := out[key]; ok {
return v, nil
}
return out, nil
}
func csvJoin(items []string) string {
parts := make([]string, 0, len(items))
for _, s := range items {
s = strings.TrimSpace(s)
if s != "" {
parts = append(parts, s)
}
}
return strings.Join(parts, ",")
}
func truncate(b []byte, n int) string {
if len(b) <= n {
return string(b)
}
return string(b[:n]) + "..."
}

View File

@@ -1,63 +0,0 @@
package sdk
import (
"encoding/json"
"strconv"
"strings"
)
func asString(v any) string {
if v == nil {
return ""
}
switch x := v.(type) {
case string:
return x
case json.Number:
return x.String()
default:
return strings.TrimSpace(fmtSprint(v))
}
}
func fmtSprint(v any) string {
b, err := json.Marshal(v)
if err != nil {
return ""
}
return strings.Trim(string(b), `"`)
}
func asFloat(v any) float64 {
if v == nil {
return 0
}
switch x := v.(type) {
case float64:
return x
case float32:
return float64(x)
case int:
return float64(x)
case int64:
return float64(x)
case json.Number:
f, _ := x.Float64()
return f
case string:
f, _ := strconv.ParseFloat(strings.TrimSpace(x), 64)
return f
default:
f, _ := strconv.ParseFloat(asString(v), 64)
return f
}
}
func mapField(m map[string]any, names ...string) any {
for _, name := range names {
if v, ok := m[name]; ok && v != nil {
return v
}
}
return nil
}

View File

@@ -1,78 +0,0 @@
package sdk
import "context"
type ContextInfo struct {
Period any `json:"period"`
Barpos any `json:"barpos"`
TimeTickSize any `json:"time_tick_size"`
Stockcode any `json:"stockcode"`
DividendType any `json:"dividend_type"`
Market any `json:"market"`
DoBackTest any `json:"do_back_test"`
Benchmark any `json:"benchmark"`
Capital any `json:"capital"`
Universe any `json:"universe"`
}
func (c *Client) ContextPeriod(ctx context.Context) (any, error) {
return c.getField(ctx, "/api/context/period", "period")
}
func (c *Client) ContextBarpos(ctx context.Context) (any, error) {
return c.getField(ctx, "/api/context/barpos", "barpos")
}
func (c *Client) ContextTimeTickSize(ctx context.Context) (any, error) {
return c.getField(ctx, "/api/context/time_tick_size", "time_tick_size")
}
func (c *Client) ContextStockcode(ctx context.Context) (any, error) {
return c.getField(ctx, "/api/context/stockcode", "stockcode")
}
func (c *Client) ContextDividendType(ctx context.Context) (any, error) {
return c.getField(ctx, "/api/context/dividend_type", "dividend_type")
}
func (c *Client) ContextMarket(ctx context.Context) (any, error) {
return c.getField(ctx, "/api/context/market", "market")
}
func (c *Client) ContextDoBackTest(ctx context.Context) (any, error) {
return c.getField(ctx, "/api/context/do_back_test", "do_back_test")
}
func (c *Client) ContextBenchmark(ctx context.Context) (any, error) {
return c.getField(ctx, "/api/context/benchmark", "benchmark")
}
func (c *Client) ContextCapital(ctx context.Context) (any, error) {
return c.getField(ctx, "/api/context/capital", "capital")
}
func (c *Client) ContextUniverse(ctx context.Context) ([]string, error) {
v, err := c.getField(ctx, "/api/context/universe", "universe")
if err != nil {
return nil, err
}
switch u := v.(type) {
case nil:
return nil, nil
case []any:
codes := make([]string, 0, len(u))
for _, item := range u {
if s := asString(item); s != "" {
codes = append(codes, s)
}
}
return codes, nil
case []string:
return u, nil
default:
if s := asString(u); s != "" {
return []string{s}, nil
}
return nil, nil
}
}

View File

@@ -1,374 +0,0 @@
package sdk
import (
"context"
"strconv"
"strings"
)
type Tick struct {
LastPrice float64
LastClose float64
Raw map[string]any
}
type HistoryDataRequest struct {
Len int
Period string
Field string
DividendType int
SkipPaused *bool
}
type MarketDataRequest struct {
Fields []string
Stocks []string
StartTime string
EndTime string
Period string
DividendType string
Count int
}
type SubscribeResult struct {
Status string `json:"status"`
SubID any `json:"sub_id"`
}
func (c *Client) StockName(ctx context.Context, stockcode string) (any, error) {
return c.postField(ctx, "/api/data/stock_name", map[string]any{"stockcode": stockcode}, "name")
}
func (c *Client) OpenDate(ctx context.Context, stockcode string) (any, error) {
return c.postField(ctx, "/api/data/open_date", map[string]any{"stockcode": stockcode}, "open_date")
}
func (c *Client) LastVolume(ctx context.Context, stockcode string) (any, error) {
return c.postField(ctx, "/api/data/last_volume", map[string]any{"stockcode": stockcode}, "last_volume")
}
func (c *Client) BarTimetag(ctx context.Context, index int) (any, error) {
return c.postField(ctx, "/api/data/bar_timetag", map[string]any{"index": index}, "timetag")
}
func (c *Client) TickTimetag(ctx context.Context) (any, error) {
return c.getField(ctx, "/api/data/tick_timetag", "timetag")
}
func (c *Client) Sector(ctx context.Context, sector string, realtime int) ([]any, error) {
var out struct {
Stocks []any `json:"stocks"`
}
if err := c.post(ctx, "/api/data/sector", map[string]any{"sector": sector, "realtime": realtime}, &out); err != nil {
return nil, err
}
return out.Stocks, nil
}
func (c *Client) Industry(ctx context.Context, industry string) ([]any, error) {
var out struct {
Stocks []any `json:"stocks"`
}
if err := c.post(ctx, "/api/data/industry", map[string]any{"industry": industry}, &out); err != nil {
return nil, err
}
return out.Stocks, nil
}
func (c *Client) StockListInSector(ctx context.Context, sectorname string) ([]any, error) {
var out struct {
Stocks []any `json:"stocks"`
}
if err := c.post(ctx, "/api/data/stock_list_in_sector", map[string]any{"sectorname": sectorname}, &out); err != nil {
return nil, err
}
return out.Stocks, nil
}
func (c *Client) WeightInIndex(ctx context.Context, indexcode, stockcode string) (any, error) {
return c.postField(ctx, "/api/data/weight_in_index", map[string]any{"indexcode": indexcode, "stockcode": stockcode}, "weight")
}
func (c *Client) ContractMultiplier(ctx context.Context, contractcode string) (any, error) {
return c.postField(ctx, "/api/data/contract_multiplier", map[string]any{"contractcode": contractcode}, "multiplier")
}
func (c *Client) RiskFreeRate(ctx context.Context, index int) (any, error) {
return c.postField(ctx, "/api/data/risk_free_rate", map[string]any{"index": index}, "risk_free_rate")
}
func (c *Client) DateLocation(ctx context.Context, strdate string) (any, error) {
return c.postField(ctx, "/api/data/date_location", map[string]any{"strdate": strdate}, "location")
}
func (c *Client) HistoryData(ctx context.Context, req HistoryDataRequest) (any, error) {
if req.Len == 0 {
req.Len = 10
}
skip := "true"
if req.SkipPaused != nil {
skip = strconv.FormatBool(*req.SkipPaused)
}
return c.postField(ctx, "/api/data/history_data", map[string]any{
"len": req.Len, "period": req.Period, "field": req.Field,
"dividend_type": req.DividendType, "skip_paused": skip,
}, "data")
}
func (c *Client) marketDataBody(req MarketDataRequest) map[string]any {
return map[string]any{
"fields": csvJoin(req.Fields),
"stock_code": csvJoin(req.Stocks),
"start_time": req.StartTime,
"end_time": req.EndTime,
"period": req.Period,
"dividend_type": req.DividendType,
"count": req.Count,
}
}
func (c *Client) MarketData(ctx context.Context, req MarketDataRequest) (any, error) {
return c.postField(ctx, "/api/data/market_data", c.marketDataBody(req), "data")
}
func (c *Client) MarketDataEx(ctx context.Context, req MarketDataRequest) (any, error) {
return c.postField(ctx, "/api/data/market_data_ex", c.marketDataBody(req), "data")
}
func (c *Client) FullTick(ctx context.Context, stocks []string) (map[string]Tick, error) {
raw := map[string]any{}
if err := c.post(ctx, "/api/data/full_tick", map[string]any{"stocks": stocks}, &raw); err != nil {
return nil, err
}
out := make(map[string]Tick, len(raw))
for code, v := range raw {
tick := Tick{Raw: map[string]any{}}
if m, ok := v.(map[string]any); ok {
tick.Raw = m
tick.LastPrice = asFloat(mapField(m, "lastPrice", "last_price", "LastPrice"))
tick.LastClose = asFloat(mapField(m, "lastClose", "last_close", "LastClose"))
}
out[code] = tick
}
return out, nil
}
func (c *Client) DividFactors(ctx context.Context, stockcode string) (any, error) {
return c.postField(ctx, "/api/data/divid_factors", map[string]any{"stockcode": stockcode}, "factors")
}
func (c *Client) MainContract(ctx context.Context, codemarket string) (any, error) {
return c.postField(ctx, "/api/data/main_contract", map[string]any{"codemarket": codemarket}, "main_contract")
}
func (c *Client) TimetagToDatetime(ctx context.Context, timetag int64, format string) (any, error) {
body := map[string]any{"timetag": timetag}
if format != "" {
body["format"] = format
}
return c.postField(ctx, "/api/data/timetag_to_datetime", body, "datetime")
}
func (c *Client) TotalShare(ctx context.Context, stockcode string) (any, error) {
return c.postField(ctx, "/api/data/total_share", map[string]any{"stockcode": stockcode}, "total_share")
}
func (c *Client) TradingDates(ctx context.Context, stockcode, startDate, endDate, period string, count int) ([]any, error) {
body := map[string]any{"stockcode": stockcode, "start_date": startDate, "end_date": endDate, "period": period}
if count != 0 {
body["count"] = count
}
var out struct {
Dates []any `json:"dates"`
}
if err := c.post(ctx, "/api/data/trading_dates", body, &out); err != nil {
return nil, err
}
return out.Dates, nil
}
func (c *Client) Svol(ctx context.Context, stockcode string) (any, error) {
return c.postField(ctx, "/api/data/svol", map[string]any{"stockcode": stockcode}, "svol")
}
func (c *Client) Bvol(ctx context.Context, stockcode string) (any, error) {
return c.postField(ctx, "/api/data/bvol", map[string]any{"stockcode": stockcode}, "bvol")
}
func (c *Client) Longhubang(ctx context.Context, stockList []string, startTime, endTime string) (any, error) {
return c.postField(ctx, "/api/data/longhubang", map[string]any{
"stock_list": csvJoin(stockList), "startTime": startTime, "endTime": endTime,
}, "data")
}
func (c *Client) Top10ShareHolder(ctx context.Context, stockList []string, dataName, startTime, endTime string) (any, error) {
return c.postField(ctx, "/api/data/top10_share_holder", map[string]any{
"stock_list": csvJoin(stockList), "data_name": dataName, "start_time": startTime, "end_time": endTime,
}, "data")
}
func (c *Client) OptionDetail(ctx context.Context, optioncode string) (any, error) {
return c.postField(ctx, "/api/data/option_detail", map[string]any{"optioncode": optioncode}, "detail")
}
func (c *Client) TurnoverRate(ctx context.Context, stockList []string, startTime, endTime string) (any, error) {
return c.postField(ctx, "/api/data/turnover_rate", map[string]any{
"stock_list": csvJoin(stockList), "startTime": startTime, "endTime": endTime,
}, "data")
}
func (c *Client) ETFInfo(ctx context.Context, stockcode string) (any, error) {
return c.postField(ctx, "/api/data/etf_info", map[string]any{"stockcode": stockcode}, "info")
}
func (c *Client) ETFIOPV(ctx context.Context, stockcode string) (any, error) {
return c.postField(ctx, "/api/data/etf_iopv", map[string]any{"stockcode": stockcode}, "iopv")
}
func (c *Client) InstrumentDetail(ctx context.Context, stockcode string) (any, error) {
return c.postField(ctx, "/api/data/instrumentdetail", map[string]any{"stockcode": stockcode}, "detail")
}
func (c *Client) ContractExpireDate(ctx context.Context, codemarket string) (any, error) {
return c.postField(ctx, "/api/data/contract_expire_date", map[string]any{"codemarket": codemarket}, "expire_date")
}
func (c *Client) OptionUndlData(ctx context.Context, undlCodeRef string) (any, error) {
return c.postField(ctx, "/api/data/option_undl_data", map[string]any{"undl_code_ref": undlCodeRef}, "data")
}
type FinancialDataRequest struct {
Tabname string
Colname string
Market string
Code string
ReportType string
Barpos int
FieldList []string
StockList []string
StartDate string
EndDate string
}
func (c *Client) FinancialData(ctx context.Context, req FinancialDataRequest) (any, error) {
body := map[string]any{
"tabname": req.Tabname, "colname": req.Colname, "market": req.Market, "code": req.Code,
"report_type": req.ReportType, "barpos": req.Barpos,
"fieldList": csvJoin(req.FieldList), "stockList": csvJoin(req.StockList),
"startDate": req.StartDate, "endDate": req.EndDate,
}
return c.postField(ctx, "/api/data/financial_data", body, "data")
}
type FactorDataRequest struct {
FieldList []string
StockList []string
StockCode string
StartDate string
EndDate string
}
func (c *Client) FactorData(ctx context.Context, req FactorDataRequest) (any, error) {
body := map[string]any{
"fieldList": csvJoin(req.FieldList), "stockList": csvJoin(req.StockList),
"stockCode": req.StockCode, "startDate": req.StartDate, "endDate": req.EndDate,
}
return c.postField(ctx, "/api/data/factor_data", body, "data")
}
func (c *Client) HisSTData(ctx context.Context, stockCode string) (any, error) {
return c.postField(ctx, "/api/data/his_st_data", map[string]any{"stockCode": stockCode}, "data")
}
func (c *Client) HisIndexData(ctx context.Context, index string) (any, error) {
return c.postField(ctx, "/api/data/his_index_data", map[string]any{"index": index}, "data")
}
func (c *Client) AllSubscription(ctx context.Context) (any, error) {
return c.getField(ctx, "/api/data/all_subscription", "subscriptions")
}
func (c *Client) OptionList(ctx context.Context, undlCode, dedate, opttype string, isavailable bool) (any, error) {
body := map[string]any{
"undl_code": undlCode, "dedate": dedate, "opttype": opttype,
"isavailable": strconv.FormatBool(isavailable),
}
return c.postField(ctx, "/api/data/option_list", body, "option_list")
}
func (c *Client) HisContractList(ctx context.Context, market string) (any, error) {
return c.postField(ctx, "/api/data/his_contract_list", map[string]any{"market": market}, "contracts")
}
func (c *Client) OptionIV(ctx context.Context, optioncode string) (any, error) {
return c.postField(ctx, "/api/data/option_iv", map[string]any{"optioncode": optioncode}, "iv")
}
type BSMPriceRequest struct {
OptionType string
ObjectPrices any // float64 或 []float64
StrikePrice float64
RiskFree float64
Sigma float64
Days int
Dividend float64
}
func (c *Client) BSMPrice(ctx context.Context, req BSMPriceRequest) (any, error) {
prices := req.ObjectPrices
if vals, ok := req.ObjectPrices.([]float64); ok {
parts := make([]string, len(vals))
for i, v := range vals {
parts[i] = strconv.FormatFloat(v, 'f', -1, 64)
}
prices = strings.Join(parts, ",")
}
return c.postField(ctx, "/api/data/bsm_price", map[string]any{
"optionType": req.OptionType, "objectPrices": prices, "strikePrice": req.StrikePrice,
"riskFree": req.RiskFree, "sigma": req.Sigma, "days": req.Days, "dividend": req.Dividend,
}, "price")
}
type BSMIVRequest struct {
OptionType string `json:"optionType"`
ObjectPrices float64 `json:"objectPrices"`
StrikePrice float64 `json:"strikePrice"`
OptionPrice float64 `json:"optionPrice"`
RiskFree float64 `json:"riskFree"`
Days int `json:"days"`
Dividend float64 `json:"dividend"`
}
func (c *Client) BSMIV(ctx context.Context, req BSMIVRequest) (any, error) {
return c.postField(ctx, "/api/data/bsm_iv", req, "iv")
}
type LocalDataRequest struct {
StockCode string `json:"stock_code"`
StartTime string `json:"start_time,omitempty"`
EndTime string `json:"end_time,omitempty"`
Period string `json:"period,omitempty"`
DividType string `json:"divid_type,omitempty"`
Count int `json:"count"`
}
func (c *Client) LocalData(ctx context.Context, req LocalDataRequest) (any, error) {
return c.postField(ctx, "/api/data/local_data", req, "data")
}
func (c *Client) SubscribeQuote(ctx context.Context, stockCode, period, dividendType string) (*SubscribeResult, error) {
var out SubscribeResult
body := map[string]any{"stock_code": stockCode, "period": period, "dividend_type": dividendType}
if err := c.post(ctx, "/api/data/subscribe_quote", body, &out); err != nil {
return nil, err
}
return &out, nil
}
func (c *Client) UnsubscribeQuote(ctx context.Context, subID int) (*SubscribeResult, error) {
var out SubscribeResult
if err := c.post(ctx, "/api/data/unsubscribe_quote", map[string]any{"sub_id": subID}, &out); err != nil {
return nil, err
}
return &out, nil
}

View File

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

View File

@@ -1,38 +0,0 @@
package sdk
import (
"fmt"
"net/http"
)
// APIError 表示服务端返回的 HTTP 错误write_error 格式)。
type APIError struct {
StatusCode int `json:"status_code"`
Message string `json:"error"`
}
func (e *APIError) Error() string {
if e == nil {
return "qmt api error"
}
if e.Message == "" {
return fmt.Sprintf("qmt api: http %d", e.StatusCode)
}
return fmt.Sprintf("qmt api: http %d: %s", e.StatusCode, e.Message)
}
func (e *APIError) Unauthorized() bool {
return e != nil && e.StatusCode == http.StatusUnauthorized
}
// BusinessError 表示 HTTP 200 但业务 JSON 带 error 字段。
type BusinessError struct {
Message string
}
func (e *BusinessError) Error() string {
if e == nil || e.Message == "" {
return "qmt api business error"
}
return e.Message
}

View File

@@ -1,27 +0,0 @@
package sdk
import "context"
func (c *Client) ExtData(ctx context.Context, extdataname, stockcode string, deviation int) (any, error) {
return c.postField(ctx, "/api/ext/ext_data", map[string]any{
"extdataname": extdataname, "stockcode": stockcode, "deviation": deviation,
}, "value")
}
func (c *Client) ExtDataRank(ctx context.Context, extdataname, stockcode string, deviation int) (any, error) {
return c.postField(ctx, "/api/ext/ext_data_rank", map[string]any{
"extdataname": extdataname, "stockcode": stockcode, "deviation": deviation,
}, "rank")
}
func (c *Client) GetFactorValue(ctx context.Context, factorname, stockcode string, deviation int) (any, error) {
return c.postField(ctx, "/api/ext/get_factor_value", map[string]any{
"factorname": factorname, "stockcode": stockcode, "deviation": deviation,
}, "value")
}
func (c *Client) GetFactorRank(ctx context.Context, factorname, stockcode string, deviation int) (any, error) {
return c.postField(ctx, "/api/ext/get_factor_rank", map[string]any{
"factorname": factorname, "stockcode": stockcode, "deviation": deviation,
}, "rank")
}

View File

@@ -1,30 +0,0 @@
package sdk
import "context"
type PythonVersion struct {
PythonVersion string `json:"python_version"`
PythonVersionInfo struct {
Major int `json:"major"`
Minor int `json:"minor"`
Micro int `json:"micro"`
ReleaseLevel string `json:"releaselevel"`
Serial int `json:"serial"`
} `json:"python_version_info"`
}
func (c *Client) PythonVersion(ctx context.Context) (*PythonVersion, error) {
var out PythonVersion
if err := c.get(ctx, "/api/sys/python_version", &out); err != nil {
return nil, err
}
return &out, nil
}
func (c *Client) Shutdown(ctx context.Context) (map[string]any, error) {
var out map[string]any
if err := c.post(ctx, "/api/sys/shutdown", map[string]any{}, &out); err != nil {
return nil, err
}
return out, nil
}

View File

@@ -1,256 +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, buy bool, stock string, volume int) (*OrderRefResult, error) {
return c.PassorderLatestTagged(ctx, buy, stock, volume, "")
}
// PassorderLatestTagged 使用 strategyName 将本地唯一订单号传给 QMT。
func (c *Client) PassorderLatestTagged(ctx context.Context, buy bool, stock string, volume int, orderID string) (*OrderRefResult, error) {
op := OpSell
if buy {
op = OpBuy
}
return c.Passorder(ctx, PassorderRequest{
OpType: op,
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")
}

61
grpc/qmt_grpc_new.py Normal file
View File

@@ -0,0 +1,61 @@
# -*- coding: gbk -*-
import grpc
import qmt_service_pb2
import qmt_service_pb2_grpc
import time
class QmtServiceServicer(qmt_service_pb2_grpc.QmtServiceServicer):
"""实现QMT服务单线程版本"""
def GetAsset(self, request, context):
"""实现GetAsset方法"""
print(f"收到查询请求,账户: {request.account_id}")
# 这里是你调用大QMT API获取数据的逻辑
# 实际使用时请替换为真实的xt_trader查询代码
# 参考: asset = xt_trader.query_stock_asset(acc)
# 模拟数据
total = 1000000.0
cash = 500000.0
market_val = 500000.0
# 模拟一些耗时操作(如查询数据库)
# time.sleep(0.1) # 如果需要可以取消注释
# 返回响应
return qmt_service_pb2.AssetResponse(
total_asset=total,
cash=cash,
market_value=market_val
)
def serve():
"""启动gRPC服务单线程"""
# 使用单线程服务器通过设置maximum_concurrent_rpcs参数限制并发
# 或者使用同步服务器,直接处理请求
server = grpc.server()
# 注册服务
qmt_service_pb2_grpc.add_QmtServiceServicer_to_server(
QmtServiceServicer(),
server
)
# 监听端口
server.add_insecure_port('[::]:58051')
# 启动服务器
server.start()
print("QMT gRPC 服务已启动(单线程模式),监听端口 58051...")
print("所有请求将串行处理,不会并发执行")
# 保持服务运行
try:
server.wait_for_termination()
except KeyboardInterrupt:
print("\n服务已停止")
server.stop(0)
if __name__ == '__main__':
serve()

19
grpc/qmt_service.proto Normal file
View File

@@ -0,0 +1,19 @@
syntax = "proto3";
// 定义服务
service QmtService {
// 查询账户资产
rpc GetAsset (AssetRequest) returns (AssetResponse) {}
}
// 请求消息
message AssetRequest {
string account_id = 1; // 账户ID
}
// 响应消息
message AssetResponse {
double total_asset = 1; // 总资产
double cash = 2; // 可用资金
double market_value = 3; // 持仓市值
}

3
py-client/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
.venv/
__pycache__/
*.py[cod]

View File

@@ -0,0 +1 @@
3.14

65
py-client/README.md Normal file
View File

@@ -0,0 +1,65 @@
# Python 3.14 客户端
运行目标为 Windows x64、标准 CPython 3.14;本次验证版本为 3.14.7。
迁移仅针对本目录QMT 服务端及其内置 Python 不变。
## 安装与运行
`py-client` 目录执行 PowerShell 命令:
```powershell
py -3.14 -m venv .venv
.venv/Scripts/python.exe -m pip install -r requirements.txt
.venv/Scripts/python.exe -m pip check
.venv/Scripts/python.exe main.py
```
`requirements.txt` 锁定本次在 Python 3.14 下实际验证的完整依赖版本。
不要复用 Python 3.11 的虚拟环境。`.python-version` 为支持该文件的工具声明版本。
## 优化范围与行为约束
已审查本目录全部 39 个原有 Python 源文件;只修改有适用优化或迁移需求的文件。
- 移除旧的 `from __future__ import annotations`,使用 Python 3.14 原生延迟求值注解,前向引用不再手工加引号。验证所有业务模块、类及方法注解可正常解析。
- 订单时间解析采用上限 4096 项的 LRU 缓存;每次刷新只读取一次时间和转换一次订单状态。缓存键为日期与时间原值,订单字段变化立即生效,继续使用原 `strptime` 解析规则。
- 信号时间边界解析采用上限 256 项的 LRU 缓存,当前时刻与允许交易的结果不缓存。
- 趋势、做 T 信号筛选直接查持仓字典,避免逐信号扫描持仓列表;候选顺序、重复信号及行情请求顺序不变。
- 交易时段常量复用,订单方向映射复用;避免构建单元素集合、合并校验列表和已存在状态的默认对象。
- 保持原浮点计算、价格阈值、资金规则、调度频率、线程结构、HTTP 重试、SQLite 事务及深复制隔离语义。
未启用 free-threaded Python、JIT 或多解释器线程池。现有交易任务共享客户端、锁和可变状态,切换并发模型不是等价替换。
延迟注解的运行时读取语义由原字符串注解变为按需求值,外部 SDK 调用者若需要字符串形式,应使用 `annotationlib.get_annotations(..., format=Format.STRING)`
官方说明:[Python 3.14 延迟注解](https://docs.python.org/3.14/whatsnew/3.14.html#pep-649-pep-749-deferred-evaluation-of-annotations)。
## 验证与性能
```powershell
.venv/Scripts/python.exe -B -m unittest discover -s tests -v
.venv/Scripts/python.exe -B benchmarks/hotpaths.py
```
25 项离线测试通过,包括原 18 项测试和新增的时间边界、缓存上限、可变订单、信号顺序、原生注解回归测试。
测试使用模拟客户端、临时 SQLite 数据库,不启动真实交易。
同一 CPython 3.14.7、原算法与优化算法对比;每组重复 5 次取中位数:
| 场景 | 原算法 µs/次 | 优化后 µs/次 | 倍率 |
| --- | ---: | ---: | ---: |
| 订单日期解析(缓存命中) | 4.263 | 0.061 | 69.90× |
| 信号时间边界解析(缓存命中) | 0.230 | 0.045 | 5.09× |
| 交易时段判断(下午) | 0.413 | 0.183 | 2.25× |
| 订单方向解析 | 0.163 | 0.092 | 1.78× |
| 1000 持仓、2000 信号筛选 | 13127.276 | 67.827 | 193.54× |
以上是局部微基准,缓存未命中仍执行原解析逻辑;不是 3.11 对 3.14 的整轮交易加速数据。
网络及数据库耗时未纳入,未做实盘端到端性能测量。
## 基线问题
修改前 18 项测试中 12 项失败,原因是模型仅有 `get_local_order_id` 属性,调用处却使用缺失的 `local_order_id`,存储层还将属性当方法调用。
本次增加同一属性的兼容别名,并统一存储层属性访问,保留原属性名和 API 数据字段;这些是使既有撤单、成交对账测试恢复的接口修复。
审查还发现既有 `strategy/zt/boot.py` 向做 T 的 `manage_positions``open_signal` 提交的参数与函数签名不匹配。
本次未改其调度和资金流程,因此 25 项测试通过不代表该既有做 T 启动路径已可用于实盘。

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,66 @@
"""Offline microbenchmarks; run with .venv/Scripts/python benchmarks/hotpaths.py."""
import sys
from datetime import datetime, time
from pathlib import Path
from statistics import median
from timeit import repeat
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from libs.calc import trading_time
from sdk.models import _parse_datetime, _side
from strategy.trend.open import _parse_minutes
def original_date(date, clock):
clock = clock.replace(':', '').zfill(6)
try:
return datetime.strptime(date.replace('-', '') + clock, '%Y%m%d%H%M%S')
except ValueError:
return None
def original_minutes(value):
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
def original_trading_time(now):
if now.weekday() >= 5:
return False
return time(9, 30) <= now.time() <= time(11, 30) or time(13) <= now.time() <= time(15)
def measure(name, before, after, number=10000):
assert before() == after(), name
old = median(repeat(before, number=number, repeat=5)) / number
new = median(repeat(after, number=number, repeat=5)) / number
print(f'{name:26} {old * 1e6:10.3f} -> {new * 1e6:10.3f} us {old / new:7.2f}x')
def main():
print(sys.version)
print('Same interpreter, original versus optimized; cache timings are warm.')
now = datetime(2026, 9, 7, 14)
measure('order date', lambda: original_date('20260907', '100000'),
lambda: _parse_datetime('20260907', '100000'))
measure('signal time bound', lambda: original_minutes('9:30'), lambda: _parse_minutes('9:30'))
measure('trading session', lambda: original_trading_time(now), lambda: trading_time(now))
measure('order side', lambda: {'23': 'BUY', '24': 'SELL', '48': 'BUY', '49': 'SELL'}.get(str(23), ''),
lambda: _side(23))
positions = {f'{i:06}.SH': None for i in range(1000)}
codes = list(positions)
signals = [f'{i:06}.SH' for i in range(500, 2500)]
measure('1000 positions/2000 signals', lambda: [c for c in signals if c not in codes],
lambda: [c for c in signals if c not in positions], number=100)
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,154 @@
import socket
from dataclasses import dataclass, field
from pathlib import Path
import yaml
@dataclass(slots=True)
class SignalConfig:
"""单个交易信号的数据源及开仓限制配置。"""
# 信号接口相对于 api_host 的路径。
url: str = ""
# 允许使用该信号的时间段;"*" 表示不限制时间。
timezone: str = "*"
# 当前价格高于信号昨收价时是否仍允许开仓。
gt_last_price_is_open: bool = False
@dataclass(slots=True)
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(slots=True)
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
enable_auto_ipo: bool = True
signal_allow: list[str] = field(default_factory=list)
excluded_codes: list[str] = field(default_factory=list)
zt_sell_ratio: float = 0.5
zt_buy_fall_pct: float = 1.0
zt_max_price: float = 200.0
# 当前账户启用的策略名称,例如 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 0 < account_config.zt_sell_ratio <= 1:
raise ValueError("zt_sell_ratio 必须在 (0, 1] 区间")
if account_config.zt_buy_fall_pct <= 0 or account_config.zt_max_price <= 0:
raise ValueError("zt_buy_fall_pct、zt_max_price 必须大于 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()
if account_config.strategy == "zt" and account_config.signal_allow != ["dcm"]:
raise ValueError("zt 策略的 signal_allow 必须且只能为 [\"dcm\"]")
return global_config, account_config
def _yaml(path: Path) -> dict:
"""读取 YAML 文件,并将空文件转换为空字典。"""
try:
with path.open(encoding="utf-8") as handle:
return yaml.safe_load(handle) or {}
except (OSError, yaml.YAMLError) as exc:
raise ValueError(f"读取或解析配置 {path} 失败: {exc}") from exc

Binary file not shown.

View File

@@ -0,0 +1,22 @@
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
t8zznqs49f1ju7q: liao.yaml
3zaewgoemkkhvst: zhang.yaml
ba0wpr7xkrbr5l7: hu.yaml
f7tib45aqk4n10h: test.yaml
dpxrcond71s657r: long.yaml
hwoy9gjt1612wq6: wen_ting.yaml
n31pqopr0xlunui: tong_zhao.yaml
3xrfluszqg98sgm: cai_cai.yaml
rfo1dc7c5nn2f1c: fu_xing.yaml
rfo1dc7ucpwgmvk: xiao_dong.yaml
DESKTOP-8NH54LS: yin_fei.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}

View File

@@ -0,0 +1,13 @@
account_id: 8886966846
host_key: cai_cai
buy_value: 5000
min_cash_ratio: 0.10
loss_trigger_pct: -10
grid_step_pct: 1
min_profit_pct: 2
strategy: trend
signal_allow: ["morning","tail","arbitrage"]
enable_loss_add_position: True
enable_auto_ipo: True
excluded_codes:
- "00000.SZ"

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

@@ -0,0 +1,13 @@
account_id: 86037237
host_key: dev
buy_value: 5000
min_cash_ratio: 0.10
loss_trigger_pct: -10
grid_step_pct: 1
min_profit_pct: 2
strategy: trend
signal_allow: ["morning","tail","arbitrage"]
enable_loss_add_position: True
enable_auto_ipo: True
excluded_codes:
- "00000.SZ"

View File

@@ -0,0 +1,13 @@
account_id: 8886120710
host_key: fu_xing
buy_value: 5000
min_cash_ratio: 0.10
loss_trigger_pct: -10
grid_step_pct: 1
min_profit_pct: 2
strategy: trend
signal_allow: ["morning","tail","arbitrage"]
enable_loss_add_position: True
enable_auto_ipo: True
excluded_codes:
- "00000.SZ"

13
py-client/etc/hu.yaml Normal file
View File

@@ -0,0 +1,13 @@
account_id: 8889975553
host_key: hu
buy_value: 5000
min_cash_ratio: 0.10
loss_trigger_pct: -10
grid_step_pct: 1
min_profit_pct: 2
strategy: trend
signal_allow: ["morning","tail","arbitrage"]
enable_loss_add_position: True
enable_auto_ipo: True
excluded_codes:
- "00000.SZ"

13
py-client/etc/liao.yaml Normal file
View File

@@ -0,0 +1,13 @@
account_id: 8889399698
host_key: liao
buy_value: 5000
min_cash_ratio: 0.10
loss_trigger_pct: -10
grid_step_pct: 1
min_profit_pct: 2
strategy: trend
signal_allow: ["morning","tail","arbitrage"]
enable_loss_add_position: True
enable_auto_ipo: True
excluded_codes:
- "00000.SZ"

13
py-client/etc/long.yaml Normal file
View File

@@ -0,0 +1,13 @@
account_id: 8886508526
host_key: long
buy_value: 5000
min_cash_ratio: 0.10
loss_trigger_pct: -10
grid_step_pct: 1
min_profit_pct: 2
strategy: trend
signal_allow: ["morning","tail","arbitrage"]
enable_loss_add_position: True
enable_auto_ipo: True
excluded_codes:
- "00000.SZ"

13
py-client/etc/test.yaml Normal file
View File

@@ -0,0 +1,13 @@
account_id: 88017860
host_key: test
buy_value: 5000
min_cash_ratio: 0.10
loss_trigger_pct: -10
grid_step_pct: 1
min_profit_pct: 2
strategy: trend
signal_allow: ["morning","tail","arbitrage"]
enable_loss_add_position: True
enable_auto_ipo: True
excluded_codes:
- "00000.SZ"

View File

@@ -0,0 +1,13 @@
account_id: 8886225815
host_key: tong_zhao
buy_value: 5000
min_cash_ratio: 0.10
loss_trigger_pct: -10
grid_step_pct: 1
min_profit_pct: 2
strategy: trend
signal_allow: ["morning","tail","arbitrage"]
enable_loss_add_position: True
enable_auto_ipo: True
excluded_codes:
- "00000.SZ"

View File

@@ -0,0 +1,13 @@
account_id: 8886441125
host_key: wen_ting
buy_value: 5000
min_cash_ratio: 0.10
loss_trigger_pct: -10
grid_step_pct: 1
min_profit_pct: 2
strategy: trend
signal_allow: ["morning","tail","arbitrage"]
enable_loss_add_position: True
enable_auto_ipo: True
excluded_codes:
- "00000.SZ"

View File

@@ -0,0 +1,13 @@
account_id: 8887377770
host_key: xiao_dong
buy_value: 5000
min_cash_ratio: 0.10
loss_trigger_pct: -10
grid_step_pct: 1
min_profit_pct: 2
strategy: trend
signal_allow: ["morning","tail","arbitrage"]
enable_loss_add_position: True
enable_auto_ipo: True
excluded_codes:
- "00000.SZ"

View File

@@ -0,0 +1,13 @@
account_id: 8889292292
host_key: yanweidong
buy_value: 5000
min_cash_ratio: 0.10
loss_trigger_pct: -10
grid_step_pct: 1
min_profit_pct: 2
strategy: trend
signal_allow: ["morning","tail","arbitrage"]
enable_loss_add_position: True
enable_auto_ipo: True
excluded_codes:
- "00000.SZ"

View File

@@ -0,0 +1,13 @@
account_id: 8891110937
host_key: yin_fei
buy_value: 5000
min_cash_ratio: 0.10
loss_trigger_pct: -10
grid_step_pct: 1
min_profit_pct: 9
strategy: trend
signal_allow: ["morning","tail","arbitrage"]
enable_loss_add_position: True
enable_auto_ipo: True
excluded_codes:
- "00000.SZ"

13
py-client/etc/zhang.yaml Normal file
View File

@@ -0,0 +1,13 @@
account_id: 8889616198
host_key: zhang
buy_value: 5000
min_cash_ratio: 0.10
loss_trigger_pct: -10
grid_step_pct: 1
min_profit_pct: 2
strategy: trend
signal_allow: ["morning","tail","arbitrage"]
enable_loss_add_position: True
enable_auto_ipo: True
excluded_codes:
- "00000.SZ"

View File

@@ -0,0 +1,18 @@
from .calc import calc_buy_volume, trading_time
from .lockfile import is_lock, write_lockfile
from .market import market_allow_open, refresh_market, status
from .signal import SignalItem, SignalResult, fetch_signal, init_signals
__all__ = [
"calc_buy_volume",
"trading_time",
"is_lock",
"write_lockfile",
"market_allow_open",
"refresh_market",
"status",
"SignalItem",
"SignalResult",
"fetch_signal",
"init_signals",
]

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

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

@@ -0,0 +1,37 @@
from datetime import datetime, time
from math import floor
_MORNING_START, _MORNING_END = time(9, 30), time(11, 30)
_AFTERNOON_START, _AFTERNOON_END = time(13), time(15)
def trading_time(now: datetime) -> bool:
if now.weekday() >= 5: return False
clock = now.time()
return _MORNING_START <= clock <= _MORNING_END or _AFTERNOON_START <= clock <= _AFTERNOON_END
def calc_buy_volume(price: float, buy_value: float) -> int:
if price <= 0 or buy_value <= 0: return 0
return max(1, floor(buy_value / (price * 100))) * 100
def calculate_min_profit_rate(price: float, profit_mult: int) -> float:
"""
根据价格返回最小利润率
Args:
price: 股票价格
profit_mult: 利润倍数配置
Returns:
float: 最小利润率(百分比)
"""
if price >= 300:
return 3 * profit_mult # 3%
if price >= 200:
return 5 * profit_mult # 5%
elif price >= 100:
return 7 * profit_mult # 7%
else:
return 9 * profit_mult # 9%

View File

@@ -0,0 +1,52 @@
from dataclasses import asdict, is_dataclass
from datetime import date, datetime
from enum import Enum
import json
from typing import Any
from strategy.trend.boot import get_collector_snapshot
import httpx
COLLECTOR_URL = "http://139.224.247.176:13499/collector"
def submit_trend_data() -> None:
"""每五分钟提交趋势策略的最新缓存,尚无快照时跳过。"""
snapshot = get_collector_snapshot()
if snapshot is not None:
collector_push(*snapshot)
def _json_value(value: Any) -> Any:
"""Convert the QMT model values into values accepted by a JSON encoder."""
if is_dataclass(value) and not isinstance(value, type):
return _json_value(asdict(value))
if isinstance(value, dict):
return {str(key): _json_value(item) for key, item in value.items()}
if isinstance(value, (list, tuple, set)):
return [_json_value(item) for item in value]
if isinstance(value, Enum):
return _json_value(value.value)
if isinstance(value, (datetime, date)):
return value.isoformat()
if value is None or isinstance(value, (str, int, float, bool)):
return value
return str(value)
def collector_push(account_id: str, assets: Any, positions: Any) -> None:
"""[暂停] 数据收集提交太耗时超过200毫秒."""
try:
payload = _json_value(
{
"account_id": account_id,
"assets": assets,
"positions": positions,
}
)
pretty_json = json.dumps(payload, indent=4, ensure_ascii=False)
print(pretty_json)
httpx.post(COLLECTOR_URL, json=payload, timeout=3.0)
except BaseException:
# Collection must never interrupt or affect the trading workflow.
pass

View File

@@ -0,0 +1,75 @@
# -*- 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(slots=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)

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

@@ -0,0 +1,8 @@
import json
from urllib.request import Request, urlopen
def get_json(url: str, timeout: float = 5.0):
request = Request(url, headers={"Accept": "application/json", "User-Agent": "big-qmt-python/1"})
with urlopen(request, timeout=timeout) as response:
return json.load(response)

View File

@@ -0,0 +1,16 @@
"""简单的文件锁标记工具。"""
from os import PathLike
from pathlib import Path
def is_lock(file_path: str | PathLike[str]) -> bool:
"""判断指定的锁文件是否存在。"""
return Path(file_path).is_file()
def write_lockfile(file_path: str | PathLike[str]) -> None:
"""创建锁文件;父目录不存在时自动创建。"""
path = Path(file_path)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("LOCK", encoding="utf-8")

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

@@ -0,0 +1,38 @@
import logging
import secrets
from threading import Lock
from .http import get_json
API_HOST = "http://139.224.247.176:13499"
MARKET_URL, PERIOD, HTTP_TIMEOUT = "/a/market", "60m", 5.0
_market_lock = Lock()
_market_status = "UNKNOWN"
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 refresh_market(api_host: str = API_HOST) -> str:
"""由后台调度线程刷新大盘状态;请求失败时缓存为 UNKNOWN。"""
global _market_status
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:
result = "UNKNOWN"
with _market_lock:
_market_status = result
return result
def market_allow_open() -> bool:
"""读取最近一次后台刷新得到的大盘缓存;未知状态时禁止开仓。"""
with _market_lock:
# return _market_status == "UP"
return True

165
py-client/libs/order.py Normal file
View File

@@ -0,0 +1,165 @@
"""策略共用委托簿。"""
import secrets
from dataclasses import dataclass
from datetime import datetime, timedelta
from threading import Lock
from cachelib import SimpleCache
import logging
import httpx
from sdk import Client, ORDER_SIDE_BY_OFFSET, APIError, OrderItem
# 表示委托仍在处理、可能继续成交的 QMT 状态。
BUSY_STATUSES = {"48", "49", "50", "51", "52", "55"}
COMPLETED_STATUSES = {"56"}
TRACKED_STATUSES = BUSY_STATUSES | COMPLETED_STATUSES
CANCELABLE_STATUSES = {"49", "50", "51", "52"}
@dataclass(slots=True)
class PlaceOrderRequest:
"""``OrderBook.place`` 提交委托所需的全部参数。"""
op: int
code: str
volume: int
order_id: str
strategy_name: str
kind: str = ""
class OrderBook:
"""线程安全的活动委托缓存。"""
def __init__(
self, order_prefix: str, lock_timeout_sec: int = 180, cancel_timeout_sec: float = 30
) -> None:
self.order_prefix = order_prefix
self.lock_timeout_sec = max(1, lock_timeout_sec)
self.cancel_timeout_sec = timedelta(seconds=cancel_timeout_sec)
self.data: list[OrderItem] = []
self.busy_keys: set[str] = set()
self.busy_cache = SimpleCache(
threshold=10_000, default_timeout=self.lock_timeout_sec
)
self.mutex = Lock()
def new_order_id(self, side: str) -> str:
"""生成带策略前缀的本地订单号。"""
return f"{self.order_prefix}-{side}-{secrets.token_hex(10)}"
def busy(self, code: str, side: str) -> bool:
"""判断证券是否存在仍在处理中的同方向委托。"""
with self.mutex:
key = self._busy_key(side, code)
return key in self.busy_keys or self.busy_cache.has(key)
@staticmethod
def _busy_key(side: str, code: str) -> str:
return f"{side}-{code}"
def refresh(self, client: Client, orders: list[OrderItem]) -> None:
"""用账户快照刷新委托,并撤销超时的活动委托。"""
current = datetime.now()
data: list[OrderItem] = []
busy_keys: set[str] = set()
canceled = 0
for item in orders:
status = str(item.order_status)
# 不处理状态不对的
if status not in TRACKED_STATUSES:
continue
if status in BUSY_STATUSES:
busy_keys.add(self._busy_key(item.side, item.stock_code))
# 清理过期的
created_at = item.created_at
if (
created_at is not None
and item.local_order_id.startswith(f"{self.order_prefix}-")
and status in CANCELABLE_STATUSES
and current - created_at > self.cancel_timeout_sec
):
try:
client.cancel_by_id(item.order_sys_id)
canceled += 1
logging.info(
"[Order] 超时撤单,代码=%s,方向=%s,柜台订单=%s",
item.stock_code,
item.side,
item.order_sys_id,
)
except Exception:
logging.exception(
"[Order] 撤单失败,保留在途状态,订单=%s", item.order_sys_id
)
# 缓存本次有效订单
data.append(item)
with self.mutex:
self.data = data
self.busy_keys = busy_keys
logging.info(
"[Order] 刷新完成,跟踪=%d,处理中=%d,撤销=%d",
len(data),
len(busy_keys),
canceled,
)
def place(self, client: Client, request: PlaceOrderRequest) -> bool:
"""按最新价提交委托,并立即写入本地方向锁。"""
side = ORDER_SIDE_BY_OFFSET.get(str(request.op), "")
if not side:
logging.warning(
"[Order] 下单失败,代码=%s,原因=未知买卖方向(%s)",
request.code,
request.op,
)
return False
key = self._busy_key(side, request.code)
with self.mutex:
if key in self.busy_keys or self.busy_cache.has(key):
logging.info(
"[Order] 跳过重复下单,代码=%s,方向=%s", request.code, side
)
return False
self.busy_cache.set(key, True, timeout=self.lock_timeout_sec)
try:
result = client.passorder(
op_type=request.op,
stock_code=request.code,
volume=request.volume,
strategy_name=request.strategy_name,
order_id=request.order_id,
)
except APIError as exc:
logging.exception(
"[Order] 下单失败,代码=%s,本地订单=%sHTTP状态=%d,错误=%s",
request.code,
request.order_id,
exc.status_code,
exc.message or str(exc),
)
return False
except (httpx.RequestError, ValueError):
# 响应异常不能证明柜台未受理,保留缓存防重,不自动重试。
logging.exception(
"[Order] 下单请求或响应异常,代码=%s,本地订单=%s",
request.code,
request.order_id,
)
return False
logging.info(
"[Order] 下单已受理,代码=%s,方向=%s,数量=%d,本地订单=%s,返回=%s",
request.code,
side,
request.volume,
request.order_id,
result,
)
return True

View File

@@ -0,0 +1,38 @@
"""账户启动概览日志。"""
import logging as log
import config
def Overview(assets, positions, account_cfg=None) -> None:
"""记录策略启动时的账户、资金和持仓概览。"""
account_cfg = account_cfg or config.account_config
if account_cfg is not None:
log.info(
"[启动] 账户=%s,主机=%s,单笔金额=%.2f",
account_cfg.account_id,
account_cfg.host_key,
account_cfg.buy_value,
)
if assets is not None:
log.info("[启动] 总资产=%.2f,可用资金=%.2f", assets.total, assets.available)
else:
log.warning("[启动] 获取资金概览失败")
for position in positions:
if position.volume <= 0:
continue
log.info(
"[启动] %s %s,持仓=%d,可用=%d,成本=%.2f(%.2f),现价=%.2f,盈亏=%.2f%%",
position.stock_code,
position.stock_name,
position.volume,
position.can_use_volume,
position.open_price,
position.open_cost,
position.last_price,
position.profit_rate * 100,
)

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

@@ -0,0 +1,24 @@
"""策略单次运行所需的公共上下文对象。"""
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass
from config import AccountConfig, GlobalConfig
from sdk import Client
from libs.grid_take_profit import GridTrailingTracker
from libs.order import OrderBook
from libs.watch import DipWatch
@dataclass(slots=True)
class Runtime:
"""集中保存策略运行期间共享的客户端、配置和内存组件。"""
client: Client
global_cfg: GlobalConfig
account_cfg: AccountConfig
orders: OrderBook
open_watch: DipWatch
add_watch: DipWatch
profit_tracker: GridTrailingTracker
executor: ThreadPoolExecutor | None = None

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

@@ -0,0 +1,36 @@
from dataclasses import dataclass, field
import secrets
from .http import get_json
@dataclass(slots=True)
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(slots=True)
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)}"
try:
raw = get_json(url, timeout)
except Exception:
return SignalResult()
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 not in allow:
continue
signal_result = fetch_signal(global_config.api_host, cfg.url)
for item in signal_result.data.values():
item.signal_key = key
result.append(item)
return result

259
py-client/libs/state.py Normal file
View File

@@ -0,0 +1,259 @@
"""SQLite 策略状态与成交存储;每个数据库仅使用一个写入者,不做数据迁移。"""
import math
import json
import logging as log
import sqlite3
from contextlib import closing
from dataclasses import asdict, dataclass
from datetime import datetime
from pathlib import Path
from sdk import DealItem, PositionItem
SCHEMA = """
-- 策略状态base_ 表示底仓added_ 表示补仓。
CREATE TABLE IF NOT EXISTS state (
id INTEGER PRIMARY KEY AUTOINCREMENT, -- 状态记录主键
stock_code TEXT NOT NULL, -- 证券代码
status TEXT NOT NULL DEFAULT '', -- 策略状态,由策略定义取值
base_order_local_id TEXT NOT NULL DEFAULT '', -- 底仓本地委托编号
base_qty INTEGER NOT NULL DEFAULT 0 CHECK (base_qty >= 0), -- 底仓数量
base_price REAL NOT NULL DEFAULT 0, -- 底仓价格
base_created_at TEXT NOT NULL DEFAULT '', -- 底仓创建时间
added_order_local_id TEXT NOT NULL DEFAULT '', -- 补仓本地委托编号
added_qty INTEGER NOT NULL DEFAULT 0 CHECK (added_qty >= 0), -- 补仓数量
added_price REAL NOT NULL DEFAULT 0, -- 补仓价格
added_created_at TEXT NOT NULL DEFAULT '' -- 补仓创建时间
);
-- 每个证券仅保留一条策略状态。
CREATE UNIQUE INDEX IF NOT EXISTS idx_state_stock_code ON state (stock_code);
-- 首次归档前的持仓基准,清仓后仍保留,供迟到成交按时间重算。
CREATE TABLE IF NOT EXISTS state_origin (
stock_code TEXT PRIMARY KEY, -- 证券代码
snapshot TEXT NOT NULL -- 初始持仓字段的 JSON 快照
);
-- 成交记录独立保存,不随状态删除。
CREATE TABLE IF NOT EXISTS deals (
id INTEGER PRIMARY KEY AUTOINCREMENT,
stock_code TEXT NOT NULL,
order_sys_id TEXT NOT NULL CHECK (order_sys_id <> ''),
order_local_id TEXT NOT NULL CHECK (order_local_id <> ''),
ref INTEGER NOT NULL DEFAULT 0,
order_ref TEXT NOT NULL DEFAULT '',
direction INTEGER NOT NULL DEFAULT 0,
offset_flag INTEGER NOT NULL CHECK (offset_flag IN (23, 24, 48, 49)),
price REAL NOT NULL CHECK (price >= 0),
volume INTEGER NOT NULL CHECK (volume > 0),
trade_amount REAL NOT NULL CHECK (trade_amount > 0),
trade_date TEXT NOT NULL,
trade_time TEXT NOT NULL,
remark TEXT NOT NULL DEFAULT '',
close_profit REAL NOT NULL DEFAULT 0,
is_arch INTEGER DEFAULT 0
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_deals_order_sys_id ON deals (order_sys_id);
CREATE INDEX IF NOT EXISTS idx_deals_order_ref ON deals (order_local_id);
CREATE INDEX IF NOT EXISTS idx_deals_stock_code_date ON deals (stock_code);
CREATE INDEX IF NOT EXISTS idx_deals_date_time ON deals (trade_date);
"""
@dataclass(slots=True)
class StateItem:
"""策略状态字段;同步账户底仓时无法获知的委托编号留空。"""
stock_code: str = '' # 证券代码
status: str = '' # 策略状态
base_order_local_id: str = '' # 底仓本地委托编号
base_qty: int = 0 # 底仓数量
base_price: float = 0.0 # 底仓价格
base_created_at: str = '' # 底仓创建时间
added_order_local_id: str = '' # 补仓本地委托编号
added_qty: int = 0 # 补仓数量
added_price: float = 0.0 # 补仓价格
added_created_at: str = '' # 补仓创建时间
class State:
"""保存策略状态和只追加的成交记录,仅创建新表,不迁移旧数据。"""
def __init__(self, path: str | Path) -> None:
self.path = Path(path)
self.state: dict[str, dict] = {}
self.deals: dict[str, dict] = {}
self.deals_sys_ids: set[str] = set()
self.path.parent.mkdir(parents=True, exist_ok=True)
with closing(self._connect()) as db:
db.executescript(SCHEMA)
self.load()
def _connect(self) -> sqlite3.Connection:
db = sqlite3.connect(self.path, timeout=30)
db.row_factory = sqlite3.Row
return db
def load(self) -> None:
"""从数据库刷新状态、成交及去重缓存。"""
with closing(self._connect()) as db, db:
db.execute('BEGIN')
state = {row['stock_code']: dict(row) for row in db.execute('SELECT * FROM state')}
deals = {row['order_sys_id']: dict(row) for row in db.execute('SELECT * FROM deals ORDER BY id')}
self.state = state
self.deals = deals
self.deals_sys_ids = set(deals)
def sync_deals(self, deals: list[DealItem]) -> None:
"""按系统成交编号去重,整批写入成功后刷新缓存。"""
new_deals = {}
for deal in deals:
if deal.order_sys_id not in self.deals_sys_ids and deal.order_sys_id not in new_deals:
new_deals[deal.order_sys_id] = deal
if not new_deals:
return
with closing(self._connect()) as db, db:
for deal in new_deals.values():
order_id = deal.get_local_order_id
if not order_id:
raise ValueError('Local order ID is required')
amount = deal.trade_amount if deal.trade_amount > 0 else deal.price * deal.volume
if not math.isfinite(amount) or amount <= 0:
raise ValueError('Trade amount must be positive and finite')
date = deal.trade_date or datetime.now().date().isoformat()
if len(date) == 8 and date.isdigit():
date = f'{date[:4]}-{date[4:6]}-{date[6:]}'
# 直接读取模型字段,金额和日期的补全不修改传入模型。
db.execute(
'INSERT INTO deals (stock_code, order_sys_id, order_local_id, ref, '
'order_ref, direction, offset_flag, price, volume, trade_amount, '
'trade_date, trade_time, remark, close_profit) '
'VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
(deal.stock_code, deal.order_sys_id, order_id, deal.ref,
deal.order_ref, deal.direction, deal.offset_flag, deal.price, deal.volume,
amount, date, deal.trade_time, deal.remark, deal.close_profit),
)
self.load()
def archiving(self) -> dict[str, str]:
"""按证券从持仓基准重放成交;失败证券保留未归档记录并返回原因。"""
errors = {}
with closing(self._connect()) as db, db:
db.execute('BEGIN IMMEDIATE')
codes = db.execute(
'SELECT DISTINCT stock_code FROM deals WHERE is_arch = 0 AND offset_flag IN (48, 49)'
).fetchall()
for entry in codes:
code = entry['stock_code']
db.execute('SAVEPOINT archive_stock')
try:
current = db.execute('SELECT * FROM state WHERE stock_code = ?', (code,)).fetchone()
origin = db.execute('SELECT snapshot FROM state_origin WHERE stock_code = ?', (code,)).fetchone()
if origin is None:
# 没有旧基准时不能用已归档后的持仓反推历史,不做数据迁移。
if db.execute(
'SELECT 1 FROM deals WHERE stock_code = ? AND is_arch = 1 LIMIT 1', (code,)
).fetchone():
raise ValueError('Missing holding baseline for archived history')
state = dict(current) if current else asdict(StateItem(stock_code=code))
db.execute('INSERT INTO state_origin VALUES (?, ?)', (code, json.dumps(state)))
else:
state = json.loads(origin['snapshot'])
# 数量相等的初始买入视为已包含在快照中,只匹配一次。
snapshot_qty = state['base_qty'] + state['added_qty']
covered = False
deals = db.execute(
'SELECT * FROM deals WHERE stock_code = ? AND offset_flag IN (48, 49) '
"ORDER BY trade_date, REPLACE(trade_time, ':', ''), id", (code,)
).fetchall()
for deal in deals:
qty = deal['volume']
if deal['offset_flag'] == 48:
if not covered and snapshot_qty == qty:
covered = True
continue
covered = True
total = state['added_qty'] + qty
state['added_price'] = (
state['added_qty'] * state['added_price'] + deal['trade_amount']
) / total
state['added_qty'] = total
state['added_order_local_id'] = deal['order_local_id']
state['added_created_at'] = f"{deal['trade_date']} {deal['trade_time']}".strip()
else:
# 卖出之后的买入属于新交易,不再与初始快照匹配。
covered = True
total = state['base_qty'] + state['added_qty']
if qty > total:
raise ValueError(f'Sell volume {qty} exceeds recorded holdings {total}')
if qty < state['added_qty']:
state['added_qty'] -= qty
else:
state['base_qty'] = total - qty
state['added_qty'] = 0
state['added_price'] = 0.0
state['added_order_local_id'] = state['added_created_at'] = ''
if state['base_qty'] + state['added_qty'] == 0:
state = asdict(StateItem(stock_code=code))
if state['base_qty'] + state['added_qty'] == 0:
db.execute('DELETE FROM state WHERE stock_code = ?', (code,))
else:
# 重算数量和成本,保留调用方当前设置的 status 及已有记录主键。
state['status'] = current['status'] if current else state['status']
state.pop('id', None)
columns = tuple(state)
db.execute(
f"INSERT INTO state ({', '.join(columns)}) "
f"VALUES ({', '.join(':' + key for key in columns)}) "
'ON CONFLICT(stock_code) DO UPDATE SET '
+ ', '.join(f'{key} = excluded.{key}' for key in columns if key != 'stock_code'),
state,
)
db.execute(
'UPDATE deals SET is_arch = 1 WHERE stock_code = ? '
'AND is_arch = 0 AND offset_flag IN (48, 49)', (code,)
)
except (ValueError, sqlite3.IntegrityError) as exc:
db.execute('ROLLBACK TO archive_stock')
errors[code] = str(exc)
log.warning('[归档] %s 失败,保留未归档成交:%s', code, exc)
finally:
db.execute('RELEASE archive_stock')
self.load()
return errors
def sync_state(self, positions: list[PositionItem]) -> None:
"""同步完整持仓:无状态则插入底仓,已有则保留,清仓则删除。
数量为零或未出现在完整持仓列表中的证券视为已清仓;空列表清空状态。
"""
# 传入完整账户持仓;同步时间作为新增底仓的创建时间。
created_at = datetime.now().isoformat(timespec='seconds')
holdings = {item.stock_code: item for item in positions if item.volume > 0}
with closing(self._connect()) as db, db:
db.execute('BEGIN')
existing = {row['stock_code']: dict(row) for row in db.execute('SELECT * FROM state')}
# 先保留基准,再删除清仓状态,卖出成交仍可据此归档。
for code, row in existing.items():
db.execute(
'INSERT OR IGNORE INTO state_origin SELECT ?, ? WHERE NOT EXISTS '
'(SELECT 1 FROM deals WHERE stock_code = ? AND is_arch = 1)',
(code, json.dumps(row), code),
)
db.executemany(
'DELETE FROM state WHERE stock_code = ?',
[(code,) for code in existing if code not in holdings],
)
for code, item in holdings.items():
if code in existing:
continue
if not math.isfinite(item.open_price):
raise ValueError('Base price must be finite')
# 只插入底仓字段,补仓字段使用数据库默认值。
db.execute(
'INSERT INTO state '
'(stock_code, status, base_order_local_id, base_qty, base_price, base_created_at) '
"VALUES (?, '', '', ?, ?, ?)",
(code, item.volume, item.open_price, created_at),
)
self.load()

Some files were not shown because too many files have changed in this diff Show More