Compare commits
66 Commits
566a07fea8
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 72a49bc741 | |||
| 32460e37bc | |||
| 91ad2dabdf | |||
| 7adee94192 | |||
| 71b1c8b2d5 | |||
| e9cd816d4d | |||
| 983902d065 | |||
| 8d3c32cd43 | |||
| bd7b6a37d8 | |||
| 8a3a29268e | |||
| 2a1458e91e | |||
| ba61ed5de7 | |||
| 9778d54f3d | |||
| a4b637f90e | |||
| f9c4997658 | |||
| 8eb44440d3 | |||
| e320de3241 | |||
| d37f9edefc | |||
| fdcbdc7869 | |||
| 2eafbb8303 | |||
| bcc6f02398 | |||
| ac9e9193ad | |||
| b38ff12f2a | |||
| 2f93fe2ecb | |||
| af55324e2a | |||
| 1fc3e119d3 | |||
| ba1bd93afa | |||
| 2059f87fe5 | |||
| 58e1e3e291 | |||
| 63ffc1329b | |||
| 3689a7ad86 | |||
| a53fc738cf | |||
| 6c2eca8fdc | |||
| 9f01eb8dc5 | |||
| 8f97666d9e | |||
| 6a94f67e82 | |||
| eb0bbcdddc | |||
| d793914b83 | |||
| e6c5c1023c | |||
| cf5f33ab9e | |||
| 9ef063f4ce | |||
| 409676745b | |||
| e6a5096353 | |||
| d5303cc22b | |||
| e2800fc193 | |||
| 07e74d054e | |||
| 9d8b913465 | |||
| 66ccd42d4e | |||
| 556e21d624 | |||
| dedbf63a92 | |||
| 027d7e06eb | |||
| 1b6f5a9f03 | |||
| a946c5b53d | |||
| 6334f10904 | |||
| cdccc48d8c | |||
| 9a43aaba23 | |||
| 4e28182b3f | |||
| 846d6c03e0 | |||
| 28e91366d6 | |||
| b72f99b4f8 | |||
| 29fee85b3d | |||
| d09f271569 | |||
| e0ecdfba52 | |||
| a4791b477a | |||
| cdbd03fe53 | |||
| c30c1541d2 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -17,6 +17,7 @@
|
||||
|
||||
# Dependency directories (remove the comment below to include it)
|
||||
# vendor/
|
||||
logs/
|
||||
|
||||
# Go workspace file
|
||||
go.work
|
||||
|
||||
BIN
__pycache__/main.cpython-311.pyc
Normal file
BIN
__pycache__/main.cpython-311.pyc
Normal file
Binary file not shown.
Binary file not shown.
445
api/qmt_rest_new.py
Normal file
445
api/qmt_rest_new.py
Normal 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
438
api/qmt_rest_rele.py
Normal 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
9
buf.gen.yaml
Normal 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 # 必须与上面保持同一输出目录
|
||||
266
docs/README.md
266
docs/README.md
@@ -1,257 +1,43 @@
|
||||
# QMT HTTP API
|
||||
# QMT REST API 文档
|
||||
|
||||
将迅投 QMT(MiniQMT / 投研版)策略进程内的 `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)。
|
||||
- POST:Body 必须是 **合法 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)。
|
||||
|
||||
1389
docs/api.md
1389
docs/api.md
File diff suppressed because it is too large
Load Diff
190
docs/arch/state.py
Normal file
190
docs/arch/state.py
Normal 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
0
docs/bug.md
Normal file
68
docs/ipo-audit-2026-09-05.md
Normal file
68
docs/ipo-audit-2026-09-05.md
Normal file
@@ -0,0 +1,68 @@
|
||||
# IPO 策略审计
|
||||
|
||||
日期:2026-09-05。范围:当前 `py-client/strategy/ipo`,以及直接相关的 SDK、文件锁、调度入口和 `api/qmt_rest_new.py`。仅审计,不修改策略、不调用真实交易接口。
|
||||
|
||||
## 结论与分级
|
||||
|
||||
本次发现 **P0:0 项,P1:2 项,P2:3 项**。只列当前问题,不沿用历史报告的已修复项。
|
||||
|
||||
- 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
164
docs/ipo-trend-audit.md
Normal 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 400;SDK 转为 APIError;place 仅记录并返回 False,未调用 state.reject。当前 reject 分支只识别响应字典中的 failed/rejected,而本服务端下单处理器正常只返回 success,失败主要通过 HTTP 错误表达。
|
||||
|
||||
复现 R3:mock 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,不再处理该订单。复现 R4:100 股终态成交金额 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) 不是空代码;非有限价格可绕过 <=0;int 浮点数量会截断;证券代码直接进入路径。建议有限正价格、严格整数数量、代码格式与路径字符限制。
|
||||
|
||||
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
99
docs/state.md
Normal 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
363
docs/todo.md
Normal 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. 对同一账户的查询建立 100–500ms 短周期缓存。
|
||||
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. 明确刷新周期,例如每 1–5 分钟重新拉取。
|
||||
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. 验证服务端重启、超时和队列满时行为。
|
||||
89
docs/trend-audit-2026-09-05.md
Normal file
89
docs/trend-audit-2026-09-05.md
Normal file
@@ -0,0 +1,89 @@
|
||||
# Trend 当前工作树重新审计
|
||||
|
||||
审计日期:2026-09-05。审计基线:`2f93fe2`,本轮读取时工作树干净。以当前磁盘文件为准,仅审计 Trend 及直接依赖;不修改策略代码,不连接交易接口。
|
||||
|
||||
## 结论
|
||||
|
||||
当前发现 **P0:0 项,P1:4 项,P2:3 项**。本报告只保留当前存在的问题与待确认行为,不保留已修复问题清单。不建议仅凭语法检查通过直接运行实盘。
|
||||
|
||||
- **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
151
docs/trend-audit.md
Normal 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
37
docs/trend-state.md
Normal 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
40
docs/zt.md
Normal 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` 提取。持仓与新增成交在同一事务提交,失败时回滚并恢复内存。
|
||||
@@ -1,125 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"big-qmt/go-client/sdk"
|
||||
)
|
||||
|
||||
var (
|
||||
BaseURL = "http://127.0.0.1:10086"
|
||||
Token = "QMTbyYanweidong"
|
||||
AccountType = "stock"
|
||||
Timeout = 15 * time.Second
|
||||
)
|
||||
|
||||
func main() {
|
||||
client := sdk.New(BaseURL, Token, Timeout).SetAccountType(AccountType)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), Timeout)
|
||||
defer cancel()
|
||||
|
||||
assets, err := client.Assets(ctx)
|
||||
if err != nil {
|
||||
fatal("获取资产失败: %v", err)
|
||||
}
|
||||
positions, err := client.Positions(ctx)
|
||||
if err != nil {
|
||||
fatal("获取持仓失败: %v", err)
|
||||
}
|
||||
|
||||
fmt.Println(strings.Repeat("=", 80))
|
||||
fmt.Printf("【时间】%s\n", time.Now().Format("2006-01-02 15:04:05"))
|
||||
fmt.Printf("【服务】%s accountType=%s\n", BaseURL, AccountType)
|
||||
fmt.Printf("【资金】总资产:%.2f元,可用资金:%.2f元\n", assets.Total, assets.Available)
|
||||
fmt.Printf("【持仓】%d只\n", len(positions))
|
||||
fmt.Println(strings.Repeat("=", 80))
|
||||
|
||||
sort.Slice(positions, func(i, j int) bool {
|
||||
return positions[i].StockCode < positions[j].StockCode
|
||||
})
|
||||
for _, p := range positions {
|
||||
if p.Volume <= 0 {
|
||||
continue
|
||||
}
|
||||
fmt.Printf(
|
||||
"【持仓】%s %s 持仓=%d 可用=%d 冻结=%d 在途=%d 昨仓=%d 成本=%.3f 现价=%.3f 市值=%.2f 浮盈=%.2f 盈亏比例=%.2f%%\n",
|
||||
p.StockCode, p.StockName, p.Volume, p.CanUseVolume, p.FrozenVolume, p.OnRoadVolume, p.YesterdayVolume,
|
||||
p.OpenPrice, p.LastPrice, p.MarketValue, p.FloatProfit, p.ProfitRate*100,
|
||||
)
|
||||
}
|
||||
|
||||
codes := loadPassCodes()
|
||||
printTicks(client, codes)
|
||||
// if _, err := client.Shutdown(ctx); err != nil {
|
||||
// fatal("关闭服务失败: %v", err)
|
||||
// }
|
||||
// fmt.Println("【服务】已关闭")
|
||||
}
|
||||
|
||||
func loadPassCodes() []string {
|
||||
dir := strings.TrimSpace(os.Getenv("QMT_DATA_DIR"))
|
||||
if dir == "" {
|
||||
fatal("环境变量 QMT_DATA_DIR 为空")
|
||||
}
|
||||
path := filepath.Join(dir, "pass_codes.json")
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
fatal("读取 %s 失败: %v", path, err)
|
||||
}
|
||||
var codes []string
|
||||
if err := json.Unmarshal(raw, &codes); err != nil {
|
||||
fatal("解析 %s 失败: %v", path, err)
|
||||
}
|
||||
return codes
|
||||
}
|
||||
|
||||
func printTicks(client *sdk.Client, codes []string) {
|
||||
fmt.Println(strings.Repeat("-", 80))
|
||||
if len(codes) == 0 {
|
||||
fmt.Println("【行情】pass_codes.json 为空,跳过")
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), Timeout)
|
||||
defer cancel()
|
||||
ticks, err := client.FullTick(ctx, codes)
|
||||
if err != nil {
|
||||
fatal("获取行情失败: %v", err)
|
||||
}
|
||||
fmt.Printf("【行情】请求 %d 只,返回 %d 只\n", len(codes), len(ticks))
|
||||
keys := make([]string, 0, len(ticks))
|
||||
for code := range ticks {
|
||||
keys = append(keys, code)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, code := range keys {
|
||||
t := ticks[code]
|
||||
fmt.Printf("【Tick】%s last=%.3f close=%.3f open=%s high=%s low=%s volume=%s\n",
|
||||
code, t.LastPrice, t.LastClose,
|
||||
rawStr(t.Raw, "open", "lastOpen", "Open"),
|
||||
rawStr(t.Raw, "high", "High"),
|
||||
rawStr(t.Raw, "low", "Low"),
|
||||
rawStr(t.Raw, "volume", "Volume"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func rawStr(m map[string]any, names ...string) string {
|
||||
for _, name := range names {
|
||||
if v, ok := m[name]; ok && v != nil {
|
||||
return fmt.Sprint(v)
|
||||
}
|
||||
}
|
||||
return "-"
|
||||
}
|
||||
|
||||
func fatal(format string, args ...any) {
|
||||
fmt.Fprintf(os.Stderr, format+"\n", args...)
|
||||
os.Exit(1)
|
||||
}
|
||||
@@ -1,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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
package logic
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path"
|
||||
"slices"
|
||||
"sync"
|
||||
|
||||
"big-qmt/go-client/config"
|
||||
"big-qmt/go-client/sdk"
|
||||
)
|
||||
|
||||
var (
|
||||
StatusNone = ""
|
||||
StatusIng = "ING" // 处理中
|
||||
StatusOk = "OK" // 成功
|
||||
QuantState *State
|
||||
)
|
||||
|
||||
type State struct {
|
||||
AbsPath string
|
||||
mu sync.Mutex
|
||||
Items map[string]*StateItem
|
||||
Codes []string
|
||||
}
|
||||
|
||||
type StateItem struct {
|
||||
Code string `json:"code"`
|
||||
BaseOrderId string `json:"base_order_id"`
|
||||
BaseQty int `json:"base_qty"`
|
||||
BaseCost float64 `json:"base_cost"`
|
||||
BaseStatus string `json:"base_status,omitempty"`
|
||||
AddedOrderId string `json:"added_order_id"`
|
||||
AddedNum int `json:"add_num"`
|
||||
AddedQty int `json:"add_qty"`
|
||||
AddedCost float64 `json:"add_cost"`
|
||||
AddedStatus string `json:"added_status,omitempty"`
|
||||
}
|
||||
|
||||
func InitState(sn string) error {
|
||||
absPath := path.Join(config.Global.QMTDataDir, fmt.Sprintf("%s_%s_state.json", sn, config.Account.AccountID))
|
||||
items, err := loadStateFile(absPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var codes []string
|
||||
for code, _ := range items {
|
||||
codes = append(codes, code)
|
||||
}
|
||||
|
||||
QuantState = &State{
|
||||
AbsPath: absPath,
|
||||
Items: items,
|
||||
Codes: codes,
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadStateFile(fp string) (map[string]*StateItem, error) {
|
||||
raw, err := os.ReadFile(fp)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("[状态] 读取失败: %v", err)
|
||||
}
|
||||
var items map[string]*StateItem
|
||||
if err := json.Unmarshal(raw, &items); err != nil {
|
||||
return nil, fmt.Errorf("[状态] 解析失败:%s", err)
|
||||
}
|
||||
return items, nil
|
||||
|
||||
}
|
||||
|
||||
func SyncPositions(positions []sdk.Position) error {
|
||||
for _, pos := range positions {
|
||||
code := pos.StockCode
|
||||
if code == "" || pos.Volume <= 0 || pos.OpenPrice <= 0 {
|
||||
continue
|
||||
}
|
||||
if !slices.Contains(QuantState.Codes, code) {
|
||||
item := &StateItem{
|
||||
Code: code,
|
||||
BaseQty: pos.Volume,
|
||||
BaseCost: pos.OpenPrice,
|
||||
BaseStatus: StatusOk,
|
||||
}
|
||||
QuantState.Append(item)
|
||||
logf("WARNING", "[状态] %s 首次接管为底仓 数量=%d 成本=%.2f", code, pos.Volume, pos.OpenPrice)
|
||||
}
|
||||
}
|
||||
|
||||
return QuantState.Save()
|
||||
}
|
||||
|
||||
func (s *State) Append(i *StateItem) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
s.Items[i.Code] = i
|
||||
s.Codes = append(s.Codes, i.Code)
|
||||
}
|
||||
|
||||
func (s *State) Get(code string) (*StateItem, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if i, ok := s.Items[code]; ok {
|
||||
return i, nil
|
||||
} else {
|
||||
return nil, fmt.Errorf("%s not found.", code)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *State) Set(i *StateItem) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if _, ok := s.Items[i.Code]; !ok {
|
||||
s.Codes = append(s.Codes, i.Code)
|
||||
}
|
||||
s.Items[i.Code] = i
|
||||
}
|
||||
|
||||
func (s *State) Delete(code string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
delete(s.Items, code)
|
||||
if index := slices.Index(s.Codes, code); index >= 0 {
|
||||
s.Codes = slices.Delete(s.Codes, index, index+1)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *State) Save() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
// 写入AbsPath文件
|
||||
f, err := os.OpenFile(s.AbsPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644)
|
||||
if err != nil {
|
||||
return fmt.Errorf("[状态] 打开文件失败: %v", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
encoder := json.NewEncoder(f)
|
||||
encoder.SetIndent("", " ")
|
||||
if err := encoder.Encode(s.Items); err != nil {
|
||||
return fmt.Errorf("[状态] 写入失败: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,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)
|
||||
}
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
package logic
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
WatchExpireTime = 5 * time.Minute
|
||||
WatchReThreshold = 0.61
|
||||
|
||||
OpenWatch *WatchMu
|
||||
PosbuyWatch *WatchMu
|
||||
)
|
||||
|
||||
type dipWatch struct {
|
||||
LastClose float64
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
type WatchMu struct {
|
||||
mu sync.Mutex
|
||||
Data map[string]dipWatch
|
||||
}
|
||||
|
||||
func InitWatch() {
|
||||
OpenWatch = &WatchMu{
|
||||
Data: make(map[string]dipWatch),
|
||||
}
|
||||
PosbuyWatch = &WatchMu{
|
||||
Data: make(map[string]dipWatch),
|
||||
}
|
||||
}
|
||||
|
||||
func (w *WatchMu) Triggered(tag, code string, price float64) bool {
|
||||
if price <= 0 {
|
||||
return false
|
||||
}
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
now := time.Now()
|
||||
watch, ok := w.Data[code]
|
||||
if !ok || now.After(watch.ExpiresAt) || now.Equal(watch.ExpiresAt) {
|
||||
w.Data[code] = dipWatch{LastClose: price, ExpiresAt: now.Add(WatchExpireTime)}
|
||||
logf("INFO", "[%s-观察] %s 现价=%.2f", tag, code, price)
|
||||
return false
|
||||
}
|
||||
if price < watch.LastClose {
|
||||
watch.LastClose = price
|
||||
watch.ExpiresAt = now.Add(WatchExpireTime)
|
||||
w.Data[code] = watch
|
||||
logf("INFO", "[%s-下跌] %s 刷新低点=%.2f", tag, code, price)
|
||||
return false
|
||||
}
|
||||
rebound := (price - watch.LastClose) / watch.LastClose * 100
|
||||
if rebound <= 0 {
|
||||
return false
|
||||
}
|
||||
if rebound < WatchReThreshold {
|
||||
logf("INFO", "[%s-等待] %s 反弹=%.2f%% 阈值=%.2f%%", tag, code, rebound, WatchReThreshold)
|
||||
return false
|
||||
}
|
||||
delete(w.Data, code)
|
||||
logf("INFO", "[%s-触发] %s 反弹=%.2f%% 低点=%.2f", tag, code, rebound, watch.LastClose)
|
||||
return true
|
||||
}
|
||||
@@ -1,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):
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
# 做 T 策略说明
|
||||
|
||||
## 启动准备
|
||||
|
||||
策略启动后,根据当前计算机选择对应的交易账户。账户连接成功后,展示总资产、可用资金和当前持仓,并读取一次当日开仓信号。运行期间一直使用这份内存信号,不再重复请求。
|
||||
|
||||
首次运行且没有历史策略状态时,账户中已有的全部持仓都作为底仓接管。后续运行以已保存的策略状态为准。
|
||||
|
||||
## 运行时间
|
||||
|
||||
策略仅在周一至周五运行,周末不执行交易计算。每天运行时段为:
|
||||
|
||||
- 09:30 至 11:30;
|
||||
- 13:00 至 15:00。
|
||||
|
||||
交易时段内每 30 秒计算一次。午间休市和收盘后不执行交易计算。
|
||||
|
||||
## 每轮计算流程
|
||||
|
||||
每轮读取账户资产、当前持仓、最新行情和委托情况,并处理已经超过等待时间的委托。
|
||||
|
||||
随后判断开仓信号中的股票是否已经持仓。未持仓信号和已有持仓可以在同一轮中分别处理,不会因为存在未开仓信号而停止管理已有持仓。
|
||||
|
||||
大盘信号只控制买入行为。大盘不允许开仓时,不新建底仓,也不补仓;止盈卖出、委托清理和持仓状态同步仍然正常进行。
|
||||
|
||||
## 底仓开仓
|
||||
|
||||
开仓信号对应的股票尚未持仓,且大盘允许开仓时,进入价格观察阶段。
|
||||
|
||||
观察期间持续记录最低价格。当价格从观察低点反弹达到设定幅度后,触发底仓买入。
|
||||
|
||||
买入数量根据配置的 `buy_value` 和当前股价计算,向下取整为整手。不足一手时按一手买入。
|
||||
|
||||
提交底仓买入后,策略记录正在开仓的状态,等待委托和持仓结果确认。
|
||||
|
||||
## 补仓
|
||||
|
||||
底仓亏损达到配置的补仓触发比例后,进入补仓价格观察阶段。
|
||||
|
||||
观察期间持续记录新的最低价格。当价格从低点反弹达到设定幅度,且大盘允许买入时,触发补仓。
|
||||
|
||||
补仓数量同样根据 `buy_value` 和补仓时的股价独立计算,因此补仓数量不要求与底仓数量相同。部分成交的数量按实际补仓数量接管。
|
||||
|
||||
## 网格止盈
|
||||
|
||||
底仓和补仓分别计算盈利比例,并分别记录本次运行期间达到的最高盈利网格。
|
||||
|
||||
盈利达到最低止盈比例后,策略开始跟踪最高网格。当盈利从最高网格回落时,触发对应仓位的卖出:
|
||||
|
||||
- 补仓达到回撤条件时,只卖出补仓部分;
|
||||
- 底仓达到回撤条件时,可以卖出全部底仓。
|
||||
|
||||
最高盈利网格只在本次程序运行期间保留,程序重新启动后重新开始记录。
|
||||
|
||||
## 委托确认
|
||||
|
||||
每笔委托生成一个不超过 24 个字符的唯一订单号。策略根据账户回写的委托结果确认订单状态,并将结果同步到策略状态。
|
||||
|
||||
回写状态包括已提交、部分成交、全部成交、已撤销和已拒绝。没有回写结果的订单不会直接重复下单。
|
||||
|
||||
委托超过等待时间后,策略先检查当前委托和持仓。如果委托已经不存在,则释放该股票的等待状态,允许后续交易轮次重新判断,但不会在释放状态的同一轮自动重复下单。
|
||||
|
||||
## 状态保护
|
||||
|
||||
策略持续保存底仓数量与成本、补仓数量与成本、当前待确认动作和最近委托状态。
|
||||
|
||||
状态文件不存在时,启动前持仓全部作为底仓接管。状态文件内容异常时,策略停止交易处理,不会自动删除或重建异常文件。
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -1,15 +0,0 @@
|
||||
module big-qmt/go-client
|
||||
|
||||
go 1.26.5
|
||||
|
||||
require (
|
||||
git.apinb.com/bsm-sdk/core v0.2.1
|
||||
github.com/robfig/cron/v3 v3.0.1
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/oklog/ulid/v2 v2.1.2 // indirect
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect
|
||||
)
|
||||
@@ -1,15 +0,0 @@
|
||||
git.apinb.com/bsm-sdk/core v0.2.1 h1:1kpbdij3qOlf1DmKTq3coIXSgLth5iJHJ3LvVZnjaXM=
|
||||
git.apinb.com/bsm-sdk/core v0.2.1/go.mod h1:BL/aGHujCWdxrKZrWaiebmLx69J0OrTVv5XfugbbyhE=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/oklog/ulid/v2 v2.1.2 h1:IEclFb9JNvzYA6MW2SCxbLzcHTVsfqm3PrqGQJH5zec=
|
||||
github.com/oklog/ulid/v2 v2.1.2/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ=
|
||||
github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o=
|
||||
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
@@ -1,38 +0,0 @@
|
||||
package libs
|
||||
|
||||
import (
|
||||
"math"
|
||||
"math/rand"
|
||||
"time"
|
||||
)
|
||||
|
||||
var letters = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
|
||||
|
||||
func randStr(n int) string {
|
||||
b := make([]rune, n)
|
||||
for i := range b {
|
||||
b[i] = letters[rand.Intn(len(letters))]
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func TradingTime(t time.Time) bool {
|
||||
if t.Weekday() == time.Saturday || t.Weekday() == time.Sunday {
|
||||
return false
|
||||
}
|
||||
second := t.Hour()*3600 + t.Minute()*60 + t.Second()
|
||||
return (second >= 9*3600+30*60 && second <= 11*3600+30*60) ||
|
||||
(second >= 13*3600 && second <= 15*3600)
|
||||
}
|
||||
|
||||
func CalcBuyVolume(price, buyValue float64) int {
|
||||
if price <= 0 || buyValue <= 0 {
|
||||
return 0
|
||||
}
|
||||
// 不足一手时仍按最低一手委托。
|
||||
hands := int(math.Floor(buyValue / (price * 100)))
|
||||
if hands == 0 {
|
||||
hands = 1
|
||||
}
|
||||
return hands * 100
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
package libs
|
||||
|
||||
import "time"
|
||||
|
||||
var (
|
||||
API_HOST = "http://139.224.247.176:13499"
|
||||
HTTPTimeout = 5 * time.Second
|
||||
)
|
||||
@@ -1,33 +0,0 @@
|
||||
package libs
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// GetJSON 请求 JSON 接口并返回对象。
|
||||
func GetJSON(rawURL string, timeout time.Duration) ([]byte, error) {
|
||||
req, err := http.NewRequest(http.MethodGet, rawURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("User-Agent", "big-qmt-go/1")
|
||||
client := &http.Client{Timeout: timeout}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode >= 400 {
|
||||
return nil, fmt.Errorf("http %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
package libs
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
MarketUrl = "/a/market"
|
||||
Period = "60m"
|
||||
)
|
||||
|
||||
// AllowOpen 每次开仓或补仓前取 60 分钟大盘信号,只有 UP 才放行。
|
||||
func MarketAllowOpen() bool {
|
||||
// gen url.
|
||||
fullUrl := fmt.Sprintf("%s%s?period=%s&t=%s", API_HOST, MarketUrl, Period, randStr(16))
|
||||
payload, err := GetJSON(fullUrl, HTTPTimeout)
|
||||
if err != nil {
|
||||
log.Printf("[ERROR] 获取大盘指数失败: %s %v", fullUrl, err)
|
||||
return false
|
||||
}
|
||||
var result map[string]any
|
||||
err = json.Unmarshal(payload, &result)
|
||||
if err != nil {
|
||||
log.Printf("[ERROR] 获取大盘指数解析: %v", err)
|
||||
return false
|
||||
}
|
||||
|
||||
status := Status(result)
|
||||
log.Printf("[INFO] 大盘信号: url=%s status=%s", fullUrl, status)
|
||||
return status == "UP"
|
||||
}
|
||||
|
||||
// Status 兼容常见响应结构;无法识别的值统一按 UNKNOWN 处理。
|
||||
func Status(payload map[string]any) string {
|
||||
var value any = payload
|
||||
if m, ok := value.(map[string]any); ok {
|
||||
if d, exists := m["data"]; exists {
|
||||
value = d
|
||||
}
|
||||
}
|
||||
if arr, ok := value.([]any); ok {
|
||||
if len(arr) == 0 {
|
||||
value = nil
|
||||
} else {
|
||||
value = arr[len(arr)-1]
|
||||
}
|
||||
}
|
||||
if m, ok := value.(map[string]any); ok {
|
||||
if v, exists := m["action"]; exists {
|
||||
value = v
|
||||
} else if v, exists := m["status"]; exists {
|
||||
value = v
|
||||
} else if v, exists := m["signal"]; exists {
|
||||
value = v
|
||||
}
|
||||
}
|
||||
s := strings.ToUpper(strings.TrimSpace(fmt.Sprint(value)))
|
||||
switch s {
|
||||
case "UP", "DOWN", "NEUTRAL":
|
||||
return s
|
||||
default:
|
||||
return "UNKNOWN"
|
||||
}
|
||||
}
|
||||
@@ -1,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
|
||||
}
|
||||
@@ -1,179 +0,0 @@
|
||||
package sdk
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Position 对应 HoldingHandler 封装后的持仓。
|
||||
type Position struct {
|
||||
StockCode string `json:"StockCode"`
|
||||
StockName string `json:"StockName"`
|
||||
Direction any `json:"Direction"`
|
||||
Volume int `json:"Volume"`
|
||||
OpenPrice float64 `json:"OpenPrice"`
|
||||
FloatProfit float64 `json:"FloatProfit"`
|
||||
MarketValue float64 `json:"MarketValue"`
|
||||
StockHolder string `json:"StockHolder"`
|
||||
FrozenVolume int `json:"FrozenVolume"`
|
||||
CanUseVolume int `json:"CanUseVolume"`
|
||||
OnRoadVolume int `json:"OnRoadVolume"`
|
||||
YesterdayVolume int `json:"YesterdayVolume"`
|
||||
LastPrice float64 `json:"LastPrice"`
|
||||
ProfitRate float64 `json:"ProfitRate"`
|
||||
FutureTradeType any `json:"FutureTradeType"`
|
||||
ExpireDate string `json:"ExpireDate"`
|
||||
}
|
||||
|
||||
type Assets struct {
|
||||
Total float64 `json:"total"`
|
||||
Available float64 `json:"available"`
|
||||
}
|
||||
|
||||
func (c *Client) Positions(ctx context.Context) ([]string, []Position, error) {
|
||||
return c.decodePositions(ctx, "/api/v2/positions")
|
||||
}
|
||||
|
||||
func (c *Client) Holding(ctx context.Context) ([]string, []Position, error) {
|
||||
return c.decodePositions(ctx, "/api/holding")
|
||||
}
|
||||
|
||||
func (c *Client) decodePositions(ctx context.Context, path string) ([]string, []Position, error) {
|
||||
raw := map[string]json.RawMessage{}
|
||||
if err := c.post(ctx, path, map[string]any{"account": c.accountType}, &raw); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
codes := make([]string, 0, len(raw))
|
||||
out := make([]Position, 0, len(raw))
|
||||
for code, blob := range raw {
|
||||
var p Position
|
||||
if err := json.Unmarshal(blob, &p); err != nil {
|
||||
return nil, nil, fmt.Errorf("position %s: %w", code, err)
|
||||
}
|
||||
if p.StockCode == "" {
|
||||
p.StockCode = code
|
||||
}
|
||||
codes = append(codes, code)
|
||||
out = append(out, p)
|
||||
}
|
||||
return codes, out, nil
|
||||
}
|
||||
|
||||
func (c *Client) Assets(ctx context.Context) (*Assets, error) {
|
||||
var out Assets
|
||||
if err := c.post(ctx, "/api/v2/assets", map[string]any{"account": c.accountType}, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
func (c *Client) TotalMoney(ctx context.Context) (float64, error) {
|
||||
var out struct {
|
||||
TotalMoney float64 `json:"total_money"`
|
||||
}
|
||||
if err := c.post(ctx, "/api/money/total", map[string]any{"account": c.accountType}, &out); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return out.TotalMoney, nil
|
||||
}
|
||||
|
||||
func (c *Client) AvailableMoney(ctx context.Context) (float64, error) {
|
||||
var out struct {
|
||||
AvailableMoney float64 `json:"available_money"`
|
||||
}
|
||||
if err := c.post(ctx, "/api/money/available", map[string]any{"account": c.accountType}, &out); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return out.AvailableMoney, nil
|
||||
}
|
||||
|
||||
type OrderRefResult struct {
|
||||
Status string `json:"status"`
|
||||
Action string `json:"action"`
|
||||
Stock string `json:"stock"`
|
||||
OpType int `json:"opType"`
|
||||
OrderRef string `json:"order_ref"`
|
||||
}
|
||||
|
||||
func (c *Client) Buy(ctx context.Context, stock string, price float64, volume int, prType int) (*OrderRefResult, error) {
|
||||
body := map[string]any{"stock": stock, "price": price, "volume": volume}
|
||||
if prType != 0 {
|
||||
body["prType"] = prType
|
||||
}
|
||||
var out OrderRefResult
|
||||
if err := c.post(ctx, "/api/order/buy", body, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
func (c *Client) Sell(ctx context.Context, stock string, price float64, volume int, prType int) (*OrderRefResult, error) {
|
||||
body := map[string]any{"stock": stock, "price": price, "volume": volume}
|
||||
if prType != 0 {
|
||||
body["prType"] = prType
|
||||
}
|
||||
var out OrderRefResult
|
||||
if err := c.post(ctx, "/api/order/sell", body, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
type OrderStatus struct {
|
||||
OrderSysID string `json:"order_sys_id"`
|
||||
Status int `json:"status"`
|
||||
VolumeLeft int `json:"volume_left"`
|
||||
VolumeTraded int `json:"volume_traded"`
|
||||
}
|
||||
|
||||
func (c *Client) OrderStatusList(ctx context.Context) ([]OrderStatus, error) {
|
||||
var out struct {
|
||||
Orders []OrderStatus `json:"orders"`
|
||||
}
|
||||
if err := c.post(ctx, "/api/order/status", map[string]any{"account": c.accountType}, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out.Orders, nil
|
||||
}
|
||||
|
||||
type CanceledOrder struct {
|
||||
OrderSysID string `json:"order_sys_id"`
|
||||
Stock string `json:"stock"`
|
||||
VolumeLeft int `json:"volume_left"`
|
||||
}
|
||||
|
||||
type CancelAllResult struct {
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message"`
|
||||
CanceledOrders []CanceledOrder `json:"canceled_orders"`
|
||||
CanceledSysIDs []string `json:"canceled_sys_ids"`
|
||||
}
|
||||
|
||||
func (c *Client) CancelAll(ctx context.Context) (*CancelAllResult, error) {
|
||||
var out CancelAllResult
|
||||
if err := c.post(ctx, "/api/order/cancel_all", map[string]any{"account": c.accountType}, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// CancelByRule 按「代码.市场 + (剩余+已成)」匹配撤单。HTTP 没有按委托号撤单。
|
||||
func (c *Client) CancelByRule(ctx context.Context, stock string, volume int) (*CancelAllResult, error) {
|
||||
var out CancelAllResult
|
||||
body := map[string]any{"stock": stock, "volume": volume, "account": c.accountType}
|
||||
if err := c.post(ctx, "/api/order/cancel_order", body, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
func (c *Client) Deals(ctx context.Context) ([]map[string]string, error) {
|
||||
var out struct {
|
||||
Deals []map[string]string `json:"deals"`
|
||||
}
|
||||
if err := c.post(ctx, "/api/order/deal", map[string]any{"account": c.accountType}, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out.Deals, nil
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
package sdk
|
||||
|
||||
import "context"
|
||||
|
||||
func (c *Client) IsLastBar(ctx context.Context) (any, error) {
|
||||
return c.getField(ctx, "/api/check/is_last_bar", "is_last_bar")
|
||||
}
|
||||
|
||||
func (c *Client) IsNewBar(ctx context.Context) (any, error) {
|
||||
return c.getField(ctx, "/api/check/is_new_bar", "is_new_bar")
|
||||
}
|
||||
|
||||
func (c *Client) IsSuspendedStock(ctx context.Context, stockcode string) (any, error) {
|
||||
return c.postField(ctx, "/api/check/is_suspended_stock", map[string]any{"stockcode": stockcode}, "is_suspended")
|
||||
}
|
||||
|
||||
func (c *Client) IsSectorStock(ctx context.Context, sectorname, market, stockcode string) (any, error) {
|
||||
body := map[string]any{"sectorname": sectorname, "market": market, "stockcode": stockcode}
|
||||
return c.postField(ctx, "/api/check/is_sector_stock", body, "is_in_sector")
|
||||
}
|
||||
|
||||
func (c *Client) IsTypedStock(ctx context.Context, stocktypenum int, market, stockcode string) (any, error) {
|
||||
body := map[string]any{"stocktypenum": stocktypenum, "market": market, "stockcode": stockcode}
|
||||
return c.postField(ctx, "/api/check/is_typed_stock", body, "result")
|
||||
}
|
||||
|
||||
func (c *Client) IndustryNameOfStock(ctx context.Context, industryType, stockcode string) (any, error) {
|
||||
body := map[string]any{"industryType": industryType, "stockcode": stockcode}
|
||||
return c.postField(ctx, "/api/check/get_industry_name_of_stock", body, "industry_name")
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
package sdk
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
baseURL string
|
||||
token string
|
||||
accountType string
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
func New(baseURL, token string, timeout time.Duration) *Client {
|
||||
if timeout <= 0 {
|
||||
timeout = 15 * time.Second
|
||||
}
|
||||
return &Client{
|
||||
baseURL: strings.TrimRight(baseURL, "/"),
|
||||
token: token,
|
||||
accountType: "stock",
|
||||
http: &http.Client{Timeout: timeout},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) SetAccountType(accountType string) *Client {
|
||||
if strings.TrimSpace(accountType) != "" {
|
||||
c.accountType = accountType
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *Client) get(ctx context.Context, path string, dest any) error {
|
||||
return c.do(ctx, http.MethodGet, path, nil, dest)
|
||||
}
|
||||
|
||||
func (c *Client) post(ctx context.Context, path string, body any, dest any) error {
|
||||
if body == nil {
|
||||
body = map[string]any{}
|
||||
}
|
||||
return c.do(ctx, http.MethodPost, path, body, dest)
|
||||
}
|
||||
|
||||
func (c *Client) do(ctx context.Context, method, path string, body any, dest any) error {
|
||||
var rdr io.Reader
|
||||
if body != nil && method != http.MethodGet {
|
||||
raw, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal request: %w", err)
|
||||
}
|
||||
rdr = bytes.NewReader(raw)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, rdr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("X-Token", c.token)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
if rdr != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resp.StatusCode >= 400 {
|
||||
apiErr := &APIError{StatusCode: resp.StatusCode, Message: strings.TrimSpace(string(raw))}
|
||||
var parsed APIError
|
||||
if json.Unmarshal(raw, &parsed) == nil {
|
||||
if parsed.StatusCode == 0 {
|
||||
parsed.StatusCode = resp.StatusCode
|
||||
}
|
||||
if parsed.Message != "" {
|
||||
apiErr = &parsed
|
||||
}
|
||||
}
|
||||
return apiErr
|
||||
}
|
||||
if dest == nil || len(raw) == 0 {
|
||||
return nil
|
||||
}
|
||||
if err := json.Unmarshal(raw, dest); err != nil {
|
||||
return fmt.Errorf("unmarshal %s: %w; body=%s", path, err, truncate(raw, 512))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) getField(ctx context.Context, path, key string) (any, error) {
|
||||
var out map[string]any
|
||||
if err := c.get(ctx, path, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out[key], nil
|
||||
}
|
||||
|
||||
func (c *Client) postField(ctx context.Context, path string, body any, key string) (any, error) {
|
||||
var out map[string]any
|
||||
if err := c.post(ctx, path, body, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if msg, ok := out["error"].(string); ok && msg != "" {
|
||||
return nil, &BusinessError{Message: msg}
|
||||
}
|
||||
if key == "" {
|
||||
return out, nil
|
||||
}
|
||||
if v, ok := out[key]; ok {
|
||||
return v, nil
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func csvJoin(items []string) string {
|
||||
parts := make([]string, 0, len(items))
|
||||
for _, s := range items {
|
||||
s = strings.TrimSpace(s)
|
||||
if s != "" {
|
||||
parts = append(parts, s)
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, ",")
|
||||
}
|
||||
|
||||
func truncate(b []byte, n int) string {
|
||||
if len(b) <= n {
|
||||
return string(b)
|
||||
}
|
||||
return string(b[:n]) + "..."
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
package sdk
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func asString(v any) string {
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
switch x := v.(type) {
|
||||
case string:
|
||||
return x
|
||||
case json.Number:
|
||||
return x.String()
|
||||
default:
|
||||
return strings.TrimSpace(fmtSprint(v))
|
||||
}
|
||||
}
|
||||
|
||||
func fmtSprint(v any) string {
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.Trim(string(b), `"`)
|
||||
}
|
||||
|
||||
func asFloat(v any) float64 {
|
||||
if v == nil {
|
||||
return 0
|
||||
}
|
||||
switch x := v.(type) {
|
||||
case float64:
|
||||
return x
|
||||
case float32:
|
||||
return float64(x)
|
||||
case int:
|
||||
return float64(x)
|
||||
case int64:
|
||||
return float64(x)
|
||||
case json.Number:
|
||||
f, _ := x.Float64()
|
||||
return f
|
||||
case string:
|
||||
f, _ := strconv.ParseFloat(strings.TrimSpace(x), 64)
|
||||
return f
|
||||
default:
|
||||
f, _ := strconv.ParseFloat(asString(v), 64)
|
||||
return f
|
||||
}
|
||||
}
|
||||
|
||||
func mapField(m map[string]any, names ...string) any {
|
||||
for _, name := range names {
|
||||
if v, ok := m[name]; ok && v != nil {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
package sdk
|
||||
|
||||
import "context"
|
||||
|
||||
type ContextInfo struct {
|
||||
Period any `json:"period"`
|
||||
Barpos any `json:"barpos"`
|
||||
TimeTickSize any `json:"time_tick_size"`
|
||||
Stockcode any `json:"stockcode"`
|
||||
DividendType any `json:"dividend_type"`
|
||||
Market any `json:"market"`
|
||||
DoBackTest any `json:"do_back_test"`
|
||||
Benchmark any `json:"benchmark"`
|
||||
Capital any `json:"capital"`
|
||||
Universe any `json:"universe"`
|
||||
}
|
||||
|
||||
func (c *Client) ContextPeriod(ctx context.Context) (any, error) {
|
||||
return c.getField(ctx, "/api/context/period", "period")
|
||||
}
|
||||
|
||||
func (c *Client) ContextBarpos(ctx context.Context) (any, error) {
|
||||
return c.getField(ctx, "/api/context/barpos", "barpos")
|
||||
}
|
||||
|
||||
func (c *Client) ContextTimeTickSize(ctx context.Context) (any, error) {
|
||||
return c.getField(ctx, "/api/context/time_tick_size", "time_tick_size")
|
||||
}
|
||||
|
||||
func (c *Client) ContextStockcode(ctx context.Context) (any, error) {
|
||||
return c.getField(ctx, "/api/context/stockcode", "stockcode")
|
||||
}
|
||||
|
||||
func (c *Client) ContextDividendType(ctx context.Context) (any, error) {
|
||||
return c.getField(ctx, "/api/context/dividend_type", "dividend_type")
|
||||
}
|
||||
|
||||
func (c *Client) ContextMarket(ctx context.Context) (any, error) {
|
||||
return c.getField(ctx, "/api/context/market", "market")
|
||||
}
|
||||
|
||||
func (c *Client) ContextDoBackTest(ctx context.Context) (any, error) {
|
||||
return c.getField(ctx, "/api/context/do_back_test", "do_back_test")
|
||||
}
|
||||
|
||||
func (c *Client) ContextBenchmark(ctx context.Context) (any, error) {
|
||||
return c.getField(ctx, "/api/context/benchmark", "benchmark")
|
||||
}
|
||||
|
||||
func (c *Client) ContextCapital(ctx context.Context) (any, error) {
|
||||
return c.getField(ctx, "/api/context/capital", "capital")
|
||||
}
|
||||
|
||||
func (c *Client) ContextUniverse(ctx context.Context) ([]string, error) {
|
||||
v, err := c.getField(ctx, "/api/context/universe", "universe")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch u := v.(type) {
|
||||
case nil:
|
||||
return nil, nil
|
||||
case []any:
|
||||
codes := make([]string, 0, len(u))
|
||||
for _, item := range u {
|
||||
if s := asString(item); s != "" {
|
||||
codes = append(codes, s)
|
||||
}
|
||||
}
|
||||
return codes, nil
|
||||
case []string:
|
||||
return u, nil
|
||||
default:
|
||||
if s := asString(u); s != "" {
|
||||
return []string{s}, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
@@ -1,374 +0,0 @@
|
||||
package sdk
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Tick struct {
|
||||
LastPrice float64
|
||||
LastClose float64
|
||||
Raw map[string]any
|
||||
}
|
||||
|
||||
type HistoryDataRequest struct {
|
||||
Len int
|
||||
Period string
|
||||
Field string
|
||||
DividendType int
|
||||
SkipPaused *bool
|
||||
}
|
||||
|
||||
type MarketDataRequest struct {
|
||||
Fields []string
|
||||
Stocks []string
|
||||
StartTime string
|
||||
EndTime string
|
||||
Period string
|
||||
DividendType string
|
||||
Count int
|
||||
}
|
||||
|
||||
type SubscribeResult struct {
|
||||
Status string `json:"status"`
|
||||
SubID any `json:"sub_id"`
|
||||
}
|
||||
|
||||
func (c *Client) StockName(ctx context.Context, stockcode string) (any, error) {
|
||||
return c.postField(ctx, "/api/data/stock_name", map[string]any{"stockcode": stockcode}, "name")
|
||||
}
|
||||
|
||||
func (c *Client) OpenDate(ctx context.Context, stockcode string) (any, error) {
|
||||
return c.postField(ctx, "/api/data/open_date", map[string]any{"stockcode": stockcode}, "open_date")
|
||||
}
|
||||
|
||||
func (c *Client) LastVolume(ctx context.Context, stockcode string) (any, error) {
|
||||
return c.postField(ctx, "/api/data/last_volume", map[string]any{"stockcode": stockcode}, "last_volume")
|
||||
}
|
||||
|
||||
func (c *Client) BarTimetag(ctx context.Context, index int) (any, error) {
|
||||
return c.postField(ctx, "/api/data/bar_timetag", map[string]any{"index": index}, "timetag")
|
||||
}
|
||||
|
||||
func (c *Client) TickTimetag(ctx context.Context) (any, error) {
|
||||
return c.getField(ctx, "/api/data/tick_timetag", "timetag")
|
||||
}
|
||||
|
||||
func (c *Client) Sector(ctx context.Context, sector string, realtime int) ([]any, error) {
|
||||
var out struct {
|
||||
Stocks []any `json:"stocks"`
|
||||
}
|
||||
if err := c.post(ctx, "/api/data/sector", map[string]any{"sector": sector, "realtime": realtime}, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out.Stocks, nil
|
||||
}
|
||||
|
||||
func (c *Client) Industry(ctx context.Context, industry string) ([]any, error) {
|
||||
var out struct {
|
||||
Stocks []any `json:"stocks"`
|
||||
}
|
||||
if err := c.post(ctx, "/api/data/industry", map[string]any{"industry": industry}, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out.Stocks, nil
|
||||
}
|
||||
|
||||
func (c *Client) StockListInSector(ctx context.Context, sectorname string) ([]any, error) {
|
||||
var out struct {
|
||||
Stocks []any `json:"stocks"`
|
||||
}
|
||||
if err := c.post(ctx, "/api/data/stock_list_in_sector", map[string]any{"sectorname": sectorname}, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out.Stocks, nil
|
||||
}
|
||||
|
||||
func (c *Client) WeightInIndex(ctx context.Context, indexcode, stockcode string) (any, error) {
|
||||
return c.postField(ctx, "/api/data/weight_in_index", map[string]any{"indexcode": indexcode, "stockcode": stockcode}, "weight")
|
||||
}
|
||||
|
||||
func (c *Client) ContractMultiplier(ctx context.Context, contractcode string) (any, error) {
|
||||
return c.postField(ctx, "/api/data/contract_multiplier", map[string]any{"contractcode": contractcode}, "multiplier")
|
||||
}
|
||||
|
||||
func (c *Client) RiskFreeRate(ctx context.Context, index int) (any, error) {
|
||||
return c.postField(ctx, "/api/data/risk_free_rate", map[string]any{"index": index}, "risk_free_rate")
|
||||
}
|
||||
|
||||
func (c *Client) DateLocation(ctx context.Context, strdate string) (any, error) {
|
||||
return c.postField(ctx, "/api/data/date_location", map[string]any{"strdate": strdate}, "location")
|
||||
}
|
||||
|
||||
func (c *Client) HistoryData(ctx context.Context, req HistoryDataRequest) (any, error) {
|
||||
if req.Len == 0 {
|
||||
req.Len = 10
|
||||
}
|
||||
skip := "true"
|
||||
if req.SkipPaused != nil {
|
||||
skip = strconv.FormatBool(*req.SkipPaused)
|
||||
}
|
||||
return c.postField(ctx, "/api/data/history_data", map[string]any{
|
||||
"len": req.Len, "period": req.Period, "field": req.Field,
|
||||
"dividend_type": req.DividendType, "skip_paused": skip,
|
||||
}, "data")
|
||||
}
|
||||
|
||||
func (c *Client) marketDataBody(req MarketDataRequest) map[string]any {
|
||||
return map[string]any{
|
||||
"fields": csvJoin(req.Fields),
|
||||
"stock_code": csvJoin(req.Stocks),
|
||||
"start_time": req.StartTime,
|
||||
"end_time": req.EndTime,
|
||||
"period": req.Period,
|
||||
"dividend_type": req.DividendType,
|
||||
"count": req.Count,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) MarketData(ctx context.Context, req MarketDataRequest) (any, error) {
|
||||
return c.postField(ctx, "/api/data/market_data", c.marketDataBody(req), "data")
|
||||
}
|
||||
|
||||
func (c *Client) MarketDataEx(ctx context.Context, req MarketDataRequest) (any, error) {
|
||||
return c.postField(ctx, "/api/data/market_data_ex", c.marketDataBody(req), "data")
|
||||
}
|
||||
|
||||
func (c *Client) FullTick(ctx context.Context, stocks []string) (map[string]Tick, error) {
|
||||
raw := map[string]any{}
|
||||
if err := c.post(ctx, "/api/data/full_tick", map[string]any{"stocks": stocks}, &raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make(map[string]Tick, len(raw))
|
||||
for code, v := range raw {
|
||||
tick := Tick{Raw: map[string]any{}}
|
||||
if m, ok := v.(map[string]any); ok {
|
||||
tick.Raw = m
|
||||
tick.LastPrice = asFloat(mapField(m, "lastPrice", "last_price", "LastPrice"))
|
||||
tick.LastClose = asFloat(mapField(m, "lastClose", "last_close", "LastClose"))
|
||||
}
|
||||
out[code] = tick
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *Client) DividFactors(ctx context.Context, stockcode string) (any, error) {
|
||||
return c.postField(ctx, "/api/data/divid_factors", map[string]any{"stockcode": stockcode}, "factors")
|
||||
}
|
||||
|
||||
func (c *Client) MainContract(ctx context.Context, codemarket string) (any, error) {
|
||||
return c.postField(ctx, "/api/data/main_contract", map[string]any{"codemarket": codemarket}, "main_contract")
|
||||
}
|
||||
|
||||
func (c *Client) TimetagToDatetime(ctx context.Context, timetag int64, format string) (any, error) {
|
||||
body := map[string]any{"timetag": timetag}
|
||||
if format != "" {
|
||||
body["format"] = format
|
||||
}
|
||||
return c.postField(ctx, "/api/data/timetag_to_datetime", body, "datetime")
|
||||
}
|
||||
|
||||
func (c *Client) TotalShare(ctx context.Context, stockcode string) (any, error) {
|
||||
return c.postField(ctx, "/api/data/total_share", map[string]any{"stockcode": stockcode}, "total_share")
|
||||
}
|
||||
|
||||
func (c *Client) TradingDates(ctx context.Context, stockcode, startDate, endDate, period string, count int) ([]any, error) {
|
||||
body := map[string]any{"stockcode": stockcode, "start_date": startDate, "end_date": endDate, "period": period}
|
||||
if count != 0 {
|
||||
body["count"] = count
|
||||
}
|
||||
var out struct {
|
||||
Dates []any `json:"dates"`
|
||||
}
|
||||
if err := c.post(ctx, "/api/data/trading_dates", body, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out.Dates, nil
|
||||
}
|
||||
|
||||
func (c *Client) Svol(ctx context.Context, stockcode string) (any, error) {
|
||||
return c.postField(ctx, "/api/data/svol", map[string]any{"stockcode": stockcode}, "svol")
|
||||
}
|
||||
|
||||
func (c *Client) Bvol(ctx context.Context, stockcode string) (any, error) {
|
||||
return c.postField(ctx, "/api/data/bvol", map[string]any{"stockcode": stockcode}, "bvol")
|
||||
}
|
||||
|
||||
func (c *Client) Longhubang(ctx context.Context, stockList []string, startTime, endTime string) (any, error) {
|
||||
return c.postField(ctx, "/api/data/longhubang", map[string]any{
|
||||
"stock_list": csvJoin(stockList), "startTime": startTime, "endTime": endTime,
|
||||
}, "data")
|
||||
}
|
||||
|
||||
func (c *Client) Top10ShareHolder(ctx context.Context, stockList []string, dataName, startTime, endTime string) (any, error) {
|
||||
return c.postField(ctx, "/api/data/top10_share_holder", map[string]any{
|
||||
"stock_list": csvJoin(stockList), "data_name": dataName, "start_time": startTime, "end_time": endTime,
|
||||
}, "data")
|
||||
}
|
||||
|
||||
func (c *Client) OptionDetail(ctx context.Context, optioncode string) (any, error) {
|
||||
return c.postField(ctx, "/api/data/option_detail", map[string]any{"optioncode": optioncode}, "detail")
|
||||
}
|
||||
|
||||
func (c *Client) TurnoverRate(ctx context.Context, stockList []string, startTime, endTime string) (any, error) {
|
||||
return c.postField(ctx, "/api/data/turnover_rate", map[string]any{
|
||||
"stock_list": csvJoin(stockList), "startTime": startTime, "endTime": endTime,
|
||||
}, "data")
|
||||
}
|
||||
|
||||
func (c *Client) ETFInfo(ctx context.Context, stockcode string) (any, error) {
|
||||
return c.postField(ctx, "/api/data/etf_info", map[string]any{"stockcode": stockcode}, "info")
|
||||
}
|
||||
|
||||
func (c *Client) ETFIOPV(ctx context.Context, stockcode string) (any, error) {
|
||||
return c.postField(ctx, "/api/data/etf_iopv", map[string]any{"stockcode": stockcode}, "iopv")
|
||||
}
|
||||
|
||||
func (c *Client) InstrumentDetail(ctx context.Context, stockcode string) (any, error) {
|
||||
return c.postField(ctx, "/api/data/instrumentdetail", map[string]any{"stockcode": stockcode}, "detail")
|
||||
}
|
||||
|
||||
func (c *Client) ContractExpireDate(ctx context.Context, codemarket string) (any, error) {
|
||||
return c.postField(ctx, "/api/data/contract_expire_date", map[string]any{"codemarket": codemarket}, "expire_date")
|
||||
}
|
||||
|
||||
func (c *Client) OptionUndlData(ctx context.Context, undlCodeRef string) (any, error) {
|
||||
return c.postField(ctx, "/api/data/option_undl_data", map[string]any{"undl_code_ref": undlCodeRef}, "data")
|
||||
}
|
||||
|
||||
type FinancialDataRequest struct {
|
||||
Tabname string
|
||||
Colname string
|
||||
Market string
|
||||
Code string
|
||||
ReportType string
|
||||
Barpos int
|
||||
FieldList []string
|
||||
StockList []string
|
||||
StartDate string
|
||||
EndDate string
|
||||
}
|
||||
|
||||
func (c *Client) FinancialData(ctx context.Context, req FinancialDataRequest) (any, error) {
|
||||
body := map[string]any{
|
||||
"tabname": req.Tabname, "colname": req.Colname, "market": req.Market, "code": req.Code,
|
||||
"report_type": req.ReportType, "barpos": req.Barpos,
|
||||
"fieldList": csvJoin(req.FieldList), "stockList": csvJoin(req.StockList),
|
||||
"startDate": req.StartDate, "endDate": req.EndDate,
|
||||
}
|
||||
return c.postField(ctx, "/api/data/financial_data", body, "data")
|
||||
}
|
||||
|
||||
type FactorDataRequest struct {
|
||||
FieldList []string
|
||||
StockList []string
|
||||
StockCode string
|
||||
StartDate string
|
||||
EndDate string
|
||||
}
|
||||
|
||||
func (c *Client) FactorData(ctx context.Context, req FactorDataRequest) (any, error) {
|
||||
body := map[string]any{
|
||||
"fieldList": csvJoin(req.FieldList), "stockList": csvJoin(req.StockList),
|
||||
"stockCode": req.StockCode, "startDate": req.StartDate, "endDate": req.EndDate,
|
||||
}
|
||||
return c.postField(ctx, "/api/data/factor_data", body, "data")
|
||||
}
|
||||
|
||||
func (c *Client) HisSTData(ctx context.Context, stockCode string) (any, error) {
|
||||
return c.postField(ctx, "/api/data/his_st_data", map[string]any{"stockCode": stockCode}, "data")
|
||||
}
|
||||
|
||||
func (c *Client) HisIndexData(ctx context.Context, index string) (any, error) {
|
||||
return c.postField(ctx, "/api/data/his_index_data", map[string]any{"index": index}, "data")
|
||||
}
|
||||
|
||||
func (c *Client) AllSubscription(ctx context.Context) (any, error) {
|
||||
return c.getField(ctx, "/api/data/all_subscription", "subscriptions")
|
||||
}
|
||||
|
||||
func (c *Client) OptionList(ctx context.Context, undlCode, dedate, opttype string, isavailable bool) (any, error) {
|
||||
body := map[string]any{
|
||||
"undl_code": undlCode, "dedate": dedate, "opttype": opttype,
|
||||
"isavailable": strconv.FormatBool(isavailable),
|
||||
}
|
||||
return c.postField(ctx, "/api/data/option_list", body, "option_list")
|
||||
}
|
||||
|
||||
func (c *Client) HisContractList(ctx context.Context, market string) (any, error) {
|
||||
return c.postField(ctx, "/api/data/his_contract_list", map[string]any{"market": market}, "contracts")
|
||||
}
|
||||
|
||||
func (c *Client) OptionIV(ctx context.Context, optioncode string) (any, error) {
|
||||
return c.postField(ctx, "/api/data/option_iv", map[string]any{"optioncode": optioncode}, "iv")
|
||||
}
|
||||
|
||||
type BSMPriceRequest struct {
|
||||
OptionType string
|
||||
ObjectPrices any // float64 或 []float64
|
||||
StrikePrice float64
|
||||
RiskFree float64
|
||||
Sigma float64
|
||||
Days int
|
||||
Dividend float64
|
||||
}
|
||||
|
||||
func (c *Client) BSMPrice(ctx context.Context, req BSMPriceRequest) (any, error) {
|
||||
prices := req.ObjectPrices
|
||||
if vals, ok := req.ObjectPrices.([]float64); ok {
|
||||
parts := make([]string, len(vals))
|
||||
for i, v := range vals {
|
||||
parts[i] = strconv.FormatFloat(v, 'f', -1, 64)
|
||||
}
|
||||
prices = strings.Join(parts, ",")
|
||||
}
|
||||
return c.postField(ctx, "/api/data/bsm_price", map[string]any{
|
||||
"optionType": req.OptionType, "objectPrices": prices, "strikePrice": req.StrikePrice,
|
||||
"riskFree": req.RiskFree, "sigma": req.Sigma, "days": req.Days, "dividend": req.Dividend,
|
||||
}, "price")
|
||||
}
|
||||
|
||||
type BSMIVRequest struct {
|
||||
OptionType string `json:"optionType"`
|
||||
ObjectPrices float64 `json:"objectPrices"`
|
||||
StrikePrice float64 `json:"strikePrice"`
|
||||
OptionPrice float64 `json:"optionPrice"`
|
||||
RiskFree float64 `json:"riskFree"`
|
||||
Days int `json:"days"`
|
||||
Dividend float64 `json:"dividend"`
|
||||
}
|
||||
|
||||
func (c *Client) BSMIV(ctx context.Context, req BSMIVRequest) (any, error) {
|
||||
return c.postField(ctx, "/api/data/bsm_iv", req, "iv")
|
||||
}
|
||||
|
||||
type LocalDataRequest struct {
|
||||
StockCode string `json:"stock_code"`
|
||||
StartTime string `json:"start_time,omitempty"`
|
||||
EndTime string `json:"end_time,omitempty"`
|
||||
Period string `json:"period,omitempty"`
|
||||
DividType string `json:"divid_type,omitempty"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
func (c *Client) LocalData(ctx context.Context, req LocalDataRequest) (any, error) {
|
||||
return c.postField(ctx, "/api/data/local_data", req, "data")
|
||||
}
|
||||
|
||||
func (c *Client) SubscribeQuote(ctx context.Context, stockCode, period, dividendType string) (*SubscribeResult, error) {
|
||||
var out SubscribeResult
|
||||
body := map[string]any{"stock_code": stockCode, "period": period, "dividend_type": dividendType}
|
||||
if err := c.post(ctx, "/api/data/subscribe_quote", body, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
func (c *Client) UnsubscribeQuote(ctx context.Context, subID int) (*SubscribeResult, error) {
|
||||
var out SubscribeResult
|
||||
if err := c.post(ctx, "/api/data/unsubscribe_quote", map[string]any{"sub_id": subID}, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
// Package sdk 是 QMT_API.py HTTP 服务的 Go 客户端。
|
||||
//
|
||||
// 用法:sdk.New(baseURL, token, timeout),账户类型默认 stock,资金账号由服务端环境变量决定。
|
||||
package sdk
|
||||
@@ -1,38 +0,0 @@
|
||||
package sdk
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// APIError 表示服务端返回的 HTTP 错误(write_error 格式)。
|
||||
type APIError struct {
|
||||
StatusCode int `json:"status_code"`
|
||||
Message string `json:"error"`
|
||||
}
|
||||
|
||||
func (e *APIError) Error() string {
|
||||
if e == nil {
|
||||
return "qmt api error"
|
||||
}
|
||||
if e.Message == "" {
|
||||
return fmt.Sprintf("qmt api: http %d", e.StatusCode)
|
||||
}
|
||||
return fmt.Sprintf("qmt api: http %d: %s", e.StatusCode, e.Message)
|
||||
}
|
||||
|
||||
func (e *APIError) Unauthorized() bool {
|
||||
return e != nil && e.StatusCode == http.StatusUnauthorized
|
||||
}
|
||||
|
||||
// BusinessError 表示 HTTP 200 但业务 JSON 带 error 字段。
|
||||
type BusinessError struct {
|
||||
Message string
|
||||
}
|
||||
|
||||
func (e *BusinessError) Error() string {
|
||||
if e == nil || e.Message == "" {
|
||||
return "qmt api business error"
|
||||
}
|
||||
return e.Message
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
package sdk
|
||||
|
||||
import "context"
|
||||
|
||||
func (c *Client) ExtData(ctx context.Context, extdataname, stockcode string, deviation int) (any, error) {
|
||||
return c.postField(ctx, "/api/ext/ext_data", map[string]any{
|
||||
"extdataname": extdataname, "stockcode": stockcode, "deviation": deviation,
|
||||
}, "value")
|
||||
}
|
||||
|
||||
func (c *Client) ExtDataRank(ctx context.Context, extdataname, stockcode string, deviation int) (any, error) {
|
||||
return c.postField(ctx, "/api/ext/ext_data_rank", map[string]any{
|
||||
"extdataname": extdataname, "stockcode": stockcode, "deviation": deviation,
|
||||
}, "rank")
|
||||
}
|
||||
|
||||
func (c *Client) GetFactorValue(ctx context.Context, factorname, stockcode string, deviation int) (any, error) {
|
||||
return c.postField(ctx, "/api/ext/get_factor_value", map[string]any{
|
||||
"factorname": factorname, "stockcode": stockcode, "deviation": deviation,
|
||||
}, "value")
|
||||
}
|
||||
|
||||
func (c *Client) GetFactorRank(ctx context.Context, factorname, stockcode string, deviation int) (any, error) {
|
||||
return c.postField(ctx, "/api/ext/get_factor_rank", map[string]any{
|
||||
"factorname": factorname, "stockcode": stockcode, "deviation": deviation,
|
||||
}, "rank")
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
package sdk
|
||||
|
||||
import "context"
|
||||
|
||||
type PythonVersion struct {
|
||||
PythonVersion string `json:"python_version"`
|
||||
PythonVersionInfo struct {
|
||||
Major int `json:"major"`
|
||||
Minor int `json:"minor"`
|
||||
Micro int `json:"micro"`
|
||||
ReleaseLevel string `json:"releaselevel"`
|
||||
Serial int `json:"serial"`
|
||||
} `json:"python_version_info"`
|
||||
}
|
||||
|
||||
func (c *Client) PythonVersion(ctx context.Context) (*PythonVersion, error) {
|
||||
var out PythonVersion
|
||||
if err := c.get(ctx, "/api/sys/python_version", &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
func (c *Client) Shutdown(ctx context.Context) (map[string]any, error) {
|
||||
var out map[string]any
|
||||
if err := c.post(ctx, "/api/sys/shutdown", map[string]any{}, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -1,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
61
grpc/qmt_grpc_new.py
Normal 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
19
grpc/qmt_service.proto
Normal 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
3
py-client/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
1
py-client/.python-version
Normal file
1
py-client/.python-version
Normal file
@@ -0,0 +1 @@
|
||||
3.14
|
||||
65
py-client/README.md
Normal file
65
py-client/README.md
Normal 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 启动路径已可用于实盘。
|
||||
BIN
py-client/__pycache__/main.cpython-311.pyc
Normal file
BIN
py-client/__pycache__/main.cpython-311.pyc
Normal file
Binary file not shown.
BIN
py-client/__pycache__/test.cpython-311.pyc
Normal file
BIN
py-client/__pycache__/test.cpython-311.pyc
Normal file
Binary file not shown.
66
py-client/benchmarks/hotpaths.py
Normal file
66
py-client/benchmarks/hotpaths.py
Normal 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()
|
||||
154
py-client/config/__init__.py
Normal file
154
py-client/config/__init__.py
Normal 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
|
||||
BIN
py-client/config/__pycache__/__init__.cpython-311.pyc
Normal file
BIN
py-client/config/__pycache__/__init__.cpython-311.pyc
Normal file
Binary file not shown.
22
py-client/etc/_global.yaml
Normal file
22
py-client/etc/_global.yaml
Normal 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}
|
||||
13
py-client/etc/cai_cai.yaml
Normal file
13
py-client/etc/cai_cai.yaml
Normal 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
13
py-client/etc/dev.yaml
Normal 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"
|
||||
13
py-client/etc/fu_xing.yaml
Normal file
13
py-client/etc/fu_xing.yaml
Normal 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
13
py-client/etc/hu.yaml
Normal 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
13
py-client/etc/liao.yaml
Normal 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
13
py-client/etc/long.yaml
Normal 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
13
py-client/etc/test.yaml
Normal 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"
|
||||
13
py-client/etc/tong_zhao.yaml
Normal file
13
py-client/etc/tong_zhao.yaml
Normal 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"
|
||||
13
py-client/etc/wen_ting.yaml
Normal file
13
py-client/etc/wen_ting.yaml
Normal 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"
|
||||
13
py-client/etc/xiao_dong.yaml
Normal file
13
py-client/etc/xiao_dong.yaml
Normal 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"
|
||||
13
py-client/etc/yanweidong.yaml
Normal file
13
py-client/etc/yanweidong.yaml
Normal 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"
|
||||
13
py-client/etc/yin_fei.yaml
Normal file
13
py-client/etc/yin_fei.yaml
Normal 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
13
py-client/etc/zhang.yaml
Normal 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"
|
||||
18
py-client/libs/__init__.py
Normal file
18
py-client/libs/__init__.py
Normal 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",
|
||||
]
|
||||
BIN
py-client/libs/__pycache__/__init__.cpython-311.pyc
Normal file
BIN
py-client/libs/__pycache__/__init__.cpython-311.pyc
Normal file
Binary file not shown.
BIN
py-client/libs/__pycache__/calc.cpython-311.pyc
Normal file
BIN
py-client/libs/__pycache__/calc.cpython-311.pyc
Normal file
Binary file not shown.
BIN
py-client/libs/__pycache__/collector.cpython-311.pyc
Normal file
BIN
py-client/libs/__pycache__/collector.cpython-311.pyc
Normal file
Binary file not shown.
BIN
py-client/libs/__pycache__/dataset.cpython-311.pyc
Normal file
BIN
py-client/libs/__pycache__/dataset.cpython-311.pyc
Normal file
Binary file not shown.
BIN
py-client/libs/__pycache__/grid_take_profit.cpython-311.pyc
Normal file
BIN
py-client/libs/__pycache__/grid_take_profit.cpython-311.pyc
Normal file
Binary file not shown.
BIN
py-client/libs/__pycache__/http.cpython-311.pyc
Normal file
BIN
py-client/libs/__pycache__/http.cpython-311.pyc
Normal file
Binary file not shown.
BIN
py-client/libs/__pycache__/lockfile.cpython-311.pyc
Normal file
BIN
py-client/libs/__pycache__/lockfile.cpython-311.pyc
Normal file
Binary file not shown.
BIN
py-client/libs/__pycache__/market.cpython-311.pyc
Normal file
BIN
py-client/libs/__pycache__/market.cpython-311.pyc
Normal file
Binary file not shown.
BIN
py-client/libs/__pycache__/order.cpython-311.pyc
Normal file
BIN
py-client/libs/__pycache__/order.cpython-311.pyc
Normal file
Binary file not shown.
BIN
py-client/libs/__pycache__/orderbook.cpython-311.pyc
Normal file
BIN
py-client/libs/__pycache__/orderbook.cpython-311.pyc
Normal file
Binary file not shown.
BIN
py-client/libs/__pycache__/overview.cpython-311.pyc
Normal file
BIN
py-client/libs/__pycache__/overview.cpython-311.pyc
Normal file
Binary file not shown.
BIN
py-client/libs/__pycache__/runtime.cpython-311.pyc
Normal file
BIN
py-client/libs/__pycache__/runtime.cpython-311.pyc
Normal file
Binary file not shown.
BIN
py-client/libs/__pycache__/signal.cpython-311.pyc
Normal file
BIN
py-client/libs/__pycache__/signal.cpython-311.pyc
Normal file
Binary file not shown.
BIN
py-client/libs/__pycache__/watch.cpython-311.pyc
Normal file
BIN
py-client/libs/__pycache__/watch.cpython-311.pyc
Normal file
Binary file not shown.
37
py-client/libs/calc.py
Normal file
37
py-client/libs/calc.py
Normal 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%
|
||||
52
py-client/libs/collector.py
Normal file
52
py-client/libs/collector.py
Normal 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
|
||||
75
py-client/libs/grid_take_profit.py
Normal file
75
py-client/libs/grid_take_profit.py
Normal 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
8
py-client/libs/http.py
Normal file
@@ -0,0 +1,8 @@
|
||||
import json
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
|
||||
def get_json(url: str, timeout: float = 5.0):
|
||||
request = Request(url, headers={"Accept": "application/json", "User-Agent": "big-qmt-python/1"})
|
||||
with urlopen(request, timeout=timeout) as response:
|
||||
return json.load(response)
|
||||
16
py-client/libs/lockfile.py
Normal file
16
py-client/libs/lockfile.py
Normal 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
38
py-client/libs/market.py
Normal 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
165
py-client/libs/order.py
Normal 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,本地订单=%s,HTTP状态=%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
|
||||
38
py-client/libs/overview.py
Normal file
38
py-client/libs/overview.py
Normal 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
24
py-client/libs/runtime.py
Normal 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
36
py-client/libs/signal.py
Normal 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
259
py-client/libs/state.py
Normal 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
Reference in New Issue
Block a user