diff --git a/api/QMT_API.py b/api/QMT_API.py new file mode 100644 index 0000000..829617e --- /dev/null +++ b/api/QMT_API.py @@ -0,0 +1,1537 @@ +# -*- coding: gbk -*- +import json +import locale +import os +import datetime +from pathlib import Path +from tornado.web import Application, RequestHandler, HTTPError +from tornado.ioloop import IOLoop +import logging + +# 定义 +ACCOUNT_ID = os.environ.get('QMT_ACCOUNT_ID', '') +DATA_DIR = os.environ.get('QMT_DATA_DIR', 'D:\qmt_strategy_data') +TOKEN="QMTbyYanweidong" +PORT = 10086 + +# =================================== +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 Exception as e: + logger.error(f"{func.__name__} 调用失败: {e}") + return None + + +# ============= 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(401, "认证失败: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(json.dumps({ + "error": self._reason, + "status_code": status_code + }, ensure_ascii=False)) + + def ctx(self): + return self.application.ContextInfo + + def acc(self): + return self.application.accountID + + +# ============= 1. ContextInfo 属性 ============= +# ContextInfo.period - 获取当前周期 +class ContextPeriodHandler(BaseHandler): + def get(self): + self.write(json.dumps({"period": self.ctx().period}, ensure_ascii=False)) + +# ContextInfo.barpos - 获取当前K线索引号 +class ContextBarposHandler(BaseHandler): + def get(self): + self.write(json.dumps({"barpos": self.ctx().barpos}, ensure_ascii=False)) + +# ContextInfo.time_tick_size - 获取当前K线数目 +class ContextTimeTickSizeHandler(BaseHandler): + def get(self): + self.write(json.dumps({"time_tick_size": self.ctx().time_tick_size}, ensure_ascii=False)) + +# ContextInfo.stockcode - 获取当前主图品种代码 +class ContextStockCodeHandler(BaseHandler): + def get(self): + self.write(json.dumps({"stockcode": self.ctx().stockcode}, ensure_ascii=False)) + +# ContextInfo.dividend_type - 获取当前复权方式 +class ContextDividendTypeHandler(BaseHandler): + def get(self): + self.write(json.dumps({"dividend_type": self.ctx().dividend_type}, ensure_ascii=False)) + +# ContextInfo.market - 获取当前主图市场 +class ContextMarketHandler(BaseHandler): + def get(self): + self.write(json.dumps({"market": self.ctx().market}, ensure_ascii=False)) + +# ContextInfo.do_back_test - 是否开启回测模式 +class ContextDoBackTestHandler(BaseHandler): + def get(self): + self.write(json.dumps({"do_back_test": self.ctx().do_back_test}, ensure_ascii=False)) + +# ContextInfo.benchmark - 获取回测基准 +class ContextBenchmarkHandler(BaseHandler): + def get(self): + self.write(json.dumps({"benchmark": self.ctx().benchmark}, ensure_ascii=False)) + +# ContextInfo.capital - 获取回测初始资金 +class ContextCapitalHandler(BaseHandler): + def get(self): + self.write(json.dumps({"capital": self.ctx().capital}, ensure_ascii=False)) + +# ContextInfo.get_universe() - 获取股票池中的股票 +class ContextUniverseHandler(BaseHandler): + def get(self): + self.write(json.dumps({"universe": self.ctx().get_universe()}, ensure_ascii=False)) + + +# ============= 2. 数据查询 (ContextInfo get_*) ============= +# ContextInfo.get_stock_name() - 根据代码获取股票名称 +class StockNameHandler(BaseHandler): + def post(self): + data = json.loads(self.request.body) + stockcode = data.get('stockcode', '') + ret = safe_call(self.ctx().get_stock_name, stockcode) + self.write(json.dumps({"stockcode": stockcode, "name": ret}, ensure_ascii=False)) + +# get_open_date() - 根据代码获取上市时间 +class OpenDateHandler(BaseHandler): + def post(self): + data = json.loads(self.request.body) + stockcode = data.get('stockcode', '') + ret = safe_call(get_open_date, stockcode) + self.write(json.dumps({"stockcode": stockcode, "open_date": ret}, ensure_ascii=False)) + +# ContextInfo.get_last_volume() - 获取最新流通股本 +class LastVolumeHandler(BaseHandler): + def post(self): + data = json.loads(self.request.body) + stockcode = data.get('stockcode', '') + ret = safe_call(self.ctx().get_last_volume, stockcode) + if ret is None: + raise HTTPError(500, "获取流通股本失败") + self.write(json.dumps({"stockcode": stockcode, "last_volume": ret}, ensure_ascii=False)) + +# ContextInfo.get_bar_timetag() - 获取K线时间戳 +class BarTimetagHandler(BaseHandler): + def post(self): + data = json.loads(self.request.body) + index = int(data.get('index', -1)) + ret = safe_call(self.ctx().get_bar_timetag, index) + self.write(json.dumps({"index": index, "timetag": ret}, ensure_ascii=False)) + +# ContextInfo.get_tick_timetag() - 获取最新分笔时间戳 +class TickTimetagHandler(BaseHandler): + def get(self): + ret = safe_call(self.ctx().get_tick_timetag) + self.write(json.dumps({"timetag": ret}, ensure_ascii=False)) + +# ContextInfo.get_sector() - 获取指数成份股 +class SectorHandler(BaseHandler): + def post(self): + data = json.loads(self.request.body) + sector = data.get('sector', '') + realtime = data.get('realtime', '0') + if not sector: + raise HTTPError(400, "need args sector") + ret = safe_call(self.ctx().get_sector, sector, int(realtime) if realtime != '0' else 0) + self.write(json.dumps({"sector": sector, "stocks": ret or []}, ensure_ascii=False)) + +# ContextInfo.get_industry() - 获取行业成份股 +class IndustryHandler(BaseHandler): + def post(self): + data = json.loads(self.request.body) + industry = data.get('industry', '') + if not industry: + raise HTTPError(400, "need args industry") + print(industry) + ret = safe_call(self.ctx().get_industry, industry) + self.write(json.dumps({"industry": industry, "stocks": ret or []}, ensure_ascii=False)) + +# ContextInfo.get_stock_list_in_sector() - 获取板块成份股 +class StockListInSectorHandler(BaseHandler): + def post(self): + data = json.loads(self.request.body) + sectorname = data.get('sectorname', '') + if not sectorname: + raise HTTPError(400, "need args sectorname") + ret = safe_call(self.ctx().get_stock_list_in_sector, sectorname) + self.write(json.dumps({"sectorname": sectorname, "stocks": ret or []}, ensure_ascii=False)) + +# ContextInfo.get_weight_in_index() - 获取指数中权重 +class WeightInIndexHandler(BaseHandler): + def post(self): + data = json.loads(self.request.body) + indexcode = data.get('indexcode', '') + stockcode = data.get('stockcode', '') + ret = safe_call(self.ctx().get_weight_in_index, indexcode, stockcode) + self.write(json.dumps({"indexcode": indexcode, "stockcode": stockcode, "weight": ret}, ensure_ascii=False)) + +# ContextInfo.get_contract_multiplier() - 获取合约乘数 +class ContractMultiplierHandler(BaseHandler): + def post(self): + data = json.loads(self.request.body) + contractcode = data.get('contractcode', '') + ret = safe_call(self.ctx().get_contract_multiplier, contractcode) + self.write(json.dumps({"contractcode": contractcode, "multiplier": ret}, ensure_ascii=False)) + +# ContextInfo.get_risk_free_rate() - 获取无风险利率 +class RiskFreeRateHandler(BaseHandler): + def post(self): + data = json.loads(self.request.body) + index = int(data.get('index', '-1')) + ret = safe_call(self.ctx().get_risk_free_rate, index) + self.write(json.dumps({"index": index, "risk_free_rate": ret}, ensure_ascii=False)) + +# ContextInfo.get_date_location() - 获取日期对应的K线索引 +class DateLocationHandler(BaseHandler): + def post(self): + data = json.loads(self.request.body) + strdate = data.get('strdate', '') + ret = safe_call(self.ctx().get_date_location, strdate) + self.write(json.dumps({"strdate": strdate, "location": ret}, ensure_ascii=False)) + +# ContextInfo.get_history_data() - 获取历史行情数据(多品种字典) +class HistoryDataHandler(BaseHandler): + def post(self): + data = json.loads(self.request.body) + length = int(data.get('len', '10')) + period = data.get('period', '1d') + field = data.get('field', 'close') + dividend_type = int(data.get('dividend_type', '0')) + skip_paused = data.get('skip_paused', 'true').lower() == 'true' + ret = safe_call(self.ctx().get_history_data, length, period, field, dividend_type, skip_paused) + self.write(json.dumps({"data": ret} if ret else {"error": "获取历史数据失败"}, ensure_ascii=False)) + +# ContextInfo.get_market_data() - 获取行情数据(DataFrame) +class MarketDataHandler(BaseHandler): + def post(self): + data = json.loads(self.request.body) + fields = data.get('fields', '') + stock_code = data.get('stock_code', '') + start_time = data.get('start_time', '') + end_time = data.get('end_time', '') + period = data.get('period', '1d') + dividend_type = data.get('dividend_type', 'none') + count = int(data.get('count', '-1')) + fields_list = [f.strip() for f in fields.split(',')] if fields else [] + stock_list = [s.strip() for s in stock_code.split(',')] if stock_code else [] + ret = safe_call(self.ctx().get_market_data, fields_list, stock_list, start_time, end_time, True, period, dividend_type, count) + if ret is None: + raise HTTPError(500, "获取行情数据失败") + if hasattr(ret, 'to_dict'): + ret = ret.to_dict() + self.write(json.dumps({"data": ret}, ensure_ascii=False, default=str)) + +# ContextInfo.get_market_data_ex() - 获取扩展行情(Level2) +class MarketDataExHandler(BaseHandler): + def post(self): + data = json.loads(self.request.body) + fields = data.get('fields', '') + stock_code = data.get('stock_code', '') + period = data.get('period', 'follow') + start_time = data.get('start_time', '') + end_time = data.get('end_time', '') + count = int(data.get('count', '-1')) + dividend_type = data.get('dividend_type', 'follow') + fields_list = [f.strip() for f in fields.split(',')] if fields else [] + stock_list = [s.strip() for s in stock_code.split(',')] if stock_code else [] + ret = safe_call(self.ctx().get_market_data_ex, fields_list, stock_list, period, start_time, end_time, count, dividend_type) + if ret is None: + raise HTTPError(500, "获取扩展行情失败") + result = {} + for k, v in ret.items(): + if hasattr(v, 'to_dict'): + result[k] = v.to_dict() + else: + result[k] = str(v) + self.write(json.dumps({"data": result}, ensure_ascii=False, default=str)) + +# ContextInfo.get_full_tick() - 获取分笔数据 +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") + code_list = [s.strip() for s in stocks.split(',')] + ret = safe_call(self.ctx().get_full_tick, code_list) + if not ret: + raise HTTPError(500, "获取分笔行情失败") + self.write(json.dumps(ret, ensure_ascii=False, default=str)) + +# ContextInfo.get_divid_factors() - 获取除权除息和复权因子 +class DividFactorsHandler(BaseHandler): + def post(self): + data = json.loads(self.request.body) + stockcode = data.get('stockcode', '') + ret = safe_call(self.ctx().get_divid_factors, stockcode) + self.write(json.dumps({"stockcode": stockcode, "factors": ret or {}}, ensure_ascii=False)) + +# ContextInfo.get_main_contract() - 获取期货主力合约 +class MainContractHandler(BaseHandler): + def post(self): + data = json.loads(self.request.body) + codemarket = data.get('codemarket', '') + ret = safe_call(self.ctx().get_main_contract, codemarket) + self.write(json.dumps({"codemarket": codemarket, "main_contract": ret}, ensure_ascii=False)) + +# timetag_to_datetime() - 毫秒时间戳转日期时间 +class TimetagToDatetimeHandler(BaseHandler): + def post(self): + data = json.loads(self.request.body) + timetag = int(data.get('timetag', '0')) + fmt = data.get('format', '%Y-%m-%d %H:%M:%S') + ret = safe_call(timetag_to_datetime, timetag, fmt) + self.write(json.dumps({"timetag": timetag, "datetime": ret}, ensure_ascii=False)) + +# ContextInfo.get_total_share() - 获取总股本 +class TotalShareHandler(BaseHandler): + def post(self): + data = json.loads(self.request.body) + stockcode = data.get('stockcode', '') + ret = safe_call(self.ctx().get_total_share, stockcode) + self.write(json.dumps({"stockcode": stockcode, "total_share": ret}, ensure_ascii=False)) + +# ContextInfo.get_trading_dates() - 获取交易日列表 +class TradingDatesHandler(BaseHandler): + def post(self): + data = json.loads(self.request.body) + stockcode = data.get('stockcode', '') + start_date = data.get('start_date', '') + end_date = data.get('end_date', '') + count = data.get('count', '') + period = data.get('period', '1d') + count_int = int(count) if count else -1 + ret = safe_call(self.ctx().get_trading_dates, stockcode, start_date, end_date, count_int, period) + self.write(json.dumps({"dates": ret or []}, ensure_ascii=False)) + +# ContextInfo.get_svol() - 获取内盘成交量 +class SvolHandler(BaseHandler): + def post(self): + data = json.loads(self.request.body) + stockcode = data.get('stockcode', '') + ret = safe_call(self.ctx().get_svol, stockcode) + self.write(json.dumps({"stockcode": stockcode, "svol": ret}, ensure_ascii=False)) + +# ContextInfo.get_bvol() - 获取外盘成交量 +class BvolHandler(BaseHandler): + def post(self): + data = json.loads(self.request.body) + stockcode = data.get('stockcode', '') + ret = safe_call(self.ctx().get_bvol, stockcode) + self.write(json.dumps({"stockcode": stockcode, "bvol": ret}, ensure_ascii=False)) + +# ContextInfo.get_longhubang() - 获取龙虎榜数据 +class LonghubangHandler(BaseHandler): + def post(self): + data = json.loads(self.request.body) + stock_list = data.get('stock_list', '') + startTime = data.get('startTime', '') + endTime = data.get('endTime', '') + slist = [s.strip() for s in stock_list.split(',')] if stock_list else [] + ret = safe_call(self.ctx().get_longhubang, slist, startTime, endTime) + if hasattr(ret, 'to_dict'): + ret = ret.to_dict() + self.write(json.dumps({"data": ret} if ret else {"error": "获取龙虎榜数据失败"}, ensure_ascii=False, default=str)) + +# get_top10_share_holder() - 获取十大股东数据 +class Top10ShareHolderHandler(BaseHandler): + def post(self): + data = json.loads(self.request.body) + stock_list = data.get('stock_list', '') + data_name = data.get('data_name', 'holder') + start_time = data.get('start_time', '') + end_time = data.get('end_time', '') + slist = [s.strip() for s in stock_list.split(',')] if stock_list else [] + ret = safe_call(get_top10_share_holder, slist, data_name, start_time, end_time) + if hasattr(ret, 'to_dict'): + ret = ret.to_dict() + self.write(json.dumps({"data": ret} if ret else {"error": "获取十大股东数据失败"}, ensure_ascii=False, default=str)) + +# ContextInfo.get_option_detail_data() - 获取期权详细信息 +class OptionDetailHandler(BaseHandler): + def post(self): + data = json.loads(self.request.body) + optioncode = data.get('optioncode', '') + ret = safe_call(self.ctx().get_option_detail_data, optioncode) + self.write(json.dumps({"optioncode": optioncode, "detail": ret or {}}, ensure_ascii=False)) + +# ContextInfo.get_turnover_rate() - 获取换手率 +class TurnoverRateHandler(BaseHandler): + def post(self): + data = json.loads(self.request.body) + stock_list = data.get('stock_list', '') + startTime = data.get('startTime', '') + endTime = data.get('endTime', '') + slist = [s.strip() for s in stock_list.split(',')] if stock_list else [] + ret = safe_call(self.ctx().get_turnover_rate, slist, startTime, endTime) + if hasattr(ret, 'to_dict'): + ret = ret.to_dict() + self.write(json.dumps({"data": ret} if ret else {"error": "获取换手率失败"}, ensure_ascii=False, default=str)) + +# get_etf_info() - 获取ETF申赎清单及成分股 +class EtfInfoHandler(BaseHandler): + def post(self): + data = json.loads(self.request.body) + stockcode = data.get('stockcode', '') + ret = safe_call(get_etf_info, stockcode) + self.write(json.dumps({"stockcode": stockcode, "info": ret or {}}, ensure_ascii=False, default=str)) + +# get_etf_iopv() - 获取ETF基金份额参考净值 +class EtfIopvHandler(BaseHandler): + def post(self): + data = json.loads(self.request.body) + stockcode = data.get('stockcode', '') + ret = safe_call(get_etf_iopv, stockcode) + self.write(json.dumps({"stockcode": stockcode, "iopv": ret}, ensure_ascii=False)) + +# ContextInfo.get_instrumentdetail() - 获取合约详细信息 +class InstrumentDetailHandler(BaseHandler): + def post(self): + data = json.loads(self.request.body) + stockcode = data.get('stockcode', '') + ret = safe_call(self.ctx().get_instrumentdetail, stockcode) + self.write(json.dumps({"stockcode": stockcode, "detail": ret or {}}, ensure_ascii=False, default=str)) + +# ContextInfo.get_contract_expire_date() - 获取期货合约到期日 +class ContractExpireDateHandler(BaseHandler): + def post(self): + data = json.loads(self.request.body) + codemarket = data.get('codemarket', '') + ret = safe_call(self.ctx().get_contract_expire_date, codemarket) + self.write(json.dumps({"codemarket": codemarket, "expire_date": ret}, ensure_ascii=False)) + +# ContextInfo.get_option_undl_data() - 获取期权标的对应的期权品种列表 +class OptionUndlDataHandler(BaseHandler): + def post(self): + data = json.loads(self.request.body) + undl_code_ref = data.get('undl_code_ref', '') + ret = safe_call(self.ctx().get_option_undl_data, undl_code_ref) + self.write(json.dumps({"data": ret or []}, ensure_ascii=False, default=str)) + +# ContextInfo.get_financial_data() - 获取财务数据 +class FinancialDataHandler(BaseHandler): + def post(self): + data = json.loads(self.request.body) + tabname = data.get('tabname', '') + colname = data.get('colname', '') + market = data.get('market', '') + code = data.get('code', '') + report_type = data.get('report_type', 'report_time') + barpos = int(data.get('barpos', '-1')) + if tabname and colname and market and code: + ret = safe_call(self.ctx().get_financial_data, tabname, colname, market, code, report_type, barpos) + else: + field_list = data.get('fieldList', '') + stock_list = data.get('stockList', '') + start_date = data.get('startDate', '') + end_date = data.get('endDate', '') + fields = [f.strip() for f in field_list.split(',')] if field_list else [] + stocks = [s.strip() for s in stock_list.split(',')] if stock_list else [] + rtype = data.get('report_type', 'announce_time') + ret = safe_call(self.ctx().get_financial_data, fields, stocks, start_date, end_date, rtype) + if hasattr(ret, 'to_dict'): + ret = ret.to_dict() + self.write(json.dumps({"data": ret} if ret is not None else {"error": "获取财务数据失败"}, ensure_ascii=False, default=str)) + +# ContextInfo.get_factor_data() - 获取多因子数据 +class FactorDataHandler(BaseHandler): + def post(self): + data = json.loads(self.request.body) + field_list = data.get('fieldList', '') + stock_list = data.get('stockList', '') + stock_code = data.get('stockCode', '') + start_date = data.get('startDate', '') + end_date = data.get('endDate', '') + fields = [f.strip() for f in field_list.split(',')] if field_list else [] + if stock_code: + ret = safe_call(self.ctx().get_factor_data, fields, stock_code, start_date, end_date) + else: + stocks = [s.strip() for s in stock_list.split(',')] if stock_list else [] + ret = safe_call(self.ctx().get_factor_data, fields, stocks, start_date, end_date) + if hasattr(ret, 'to_dict'): + ret = ret.to_dict() + self.write(json.dumps({"data": ret} if ret is not None else {"error": "获取因子数据失败"}, ensure_ascii=False, default=str)) + +# ContextInfo.get_his_st_data() - 获取历史ST数据 +class HisStDataHandler(BaseHandler): + def post(self): + data = json.loads(self.request.body) + stockCode = data.get('stockCode', '') + ret = safe_call(self.ctx().get_his_st_data, stockCode) + self.write(json.dumps({"stockCode": stockCode, "data": ret or {}}, ensure_ascii=False)) + +# ContextInfo.get_his_index_data() - 获取历史指数数据 +class HisIndexDataHandler(BaseHandler): + def post(self): + data = json.loads(self.request.body) + index = data.get('index', '') + ret = safe_call(self.ctx().get_his_index_data, index) + self.write(json.dumps({"index": index, "data": ret or {}}, ensure_ascii=False, default=str)) + +# ContextInfo.get_all_subscription() - 获取当前所有行情订阅信息 +class AllSubscriptionHandler(BaseHandler): + def get(self): + ret = safe_call(self.ctx().get_all_subscription) + self.write(json.dumps({"subscriptions": ret or {}}, ensure_ascii=False, default=str)) + +# ContextInfo.get_option_list() - 获取指定期权列表 +class OptionListHandler(BaseHandler): + def post(self): + data = json.loads(self.request.body) + undl_code = data.get('undl_code', '') + dedate = data.get('dedate', '') + opttype = data.get('opttype', '') + isavailable = data.get('isavailable', 'true').lower() == 'true' + ret = safe_call(self.ctx().get_option_list, undl_code, dedate, opttype, isavailable) + self.write(json.dumps({"option_list": ret or []}, ensure_ascii=False)) + +# ContextInfo.get_his_contract_list() - 获取过期合约列表 +class HisContractListHandler(BaseHandler): + def post(self): + data = json.loads(self.request.body) + market = data.get('market', '') + ret = safe_call(self.ctx().get_his_contract_list, market) + self.write(json.dumps({"market": market, "contracts": ret or []}, ensure_ascii=False)) + +# ContextInfo.get_option_iv() - 获取期权实时隐含波动率 +class OptionIvHandler(BaseHandler): + def post(self): + data = json.loads(self.request.body) + optioncode = data.get('optioncode', '') + ret = safe_call(self.ctx().get_option_iv, optioncode) + self.write(json.dumps({"optioncode": optioncode, "iv": ret}, ensure_ascii=False)) + +# ContextInfo.bsm_price() - BS模型计算欧式期权理论价格 +class BsmPriceHandler(BaseHandler): + def post(self): + data = json.loads(self.request.body) + optionType = data.get('optionType', 'C') + objectPrices = data.get('objectPrices', '') + strikePrice = float(data.get('strikePrice', '0')) + riskFree = float(data.get('riskFree', '0')) + sigma = float(data.get('sigma', '0')) + days = int(data.get('days', '0')) + dividend = float(data.get('dividend', '0')) + try: + op = float(objectPrices) + except ValueError: + op = [float(x) for x in objectPrices.split(',')] + ret = safe_call(self.ctx().bsm_price, optionType, op, strikePrice, riskFree, sigma, days, dividend) + self.write(json.dumps({"price": ret}, ensure_ascii=False, default=str)) + +# ContextInfo.bsm_iv() - BS模型计算欧式期权隐含波动率 +class BsmIvHandler(BaseHandler): + def post(self): + data = json.loads(self.request.body) + optionType = data.get('optionType', 'C') + objectPrices = float(data.get('objectPrices', '0')) + strikePrice = float(data.get('strikePrice', '0')) + optionPrice = float(data.get('optionPrice', '0')) + riskFree = float(data.get('riskFree', '0')) + days = int(data.get('days', '0')) + dividend = float(data.get('dividend', '0')) + ret = safe_call(self.ctx().bsm_iv, optionType, objectPrices, strikePrice, optionPrice, riskFree, days, dividend) + self.write(json.dumps({"iv": ret}, ensure_ascii=False)) + +# ContextInfo.get_local_data() - 从本地获取行情数据 +class LocalDataHandler(BaseHandler): + def post(self): + data = json.loads(self.request.body) + stock_code = data.get('stock_code', '') + start_time = data.get('start_time', '') + end_time = data.get('end_time', '') + period = data.get('period', '1d') + divid_type = data.get('divid_type', 'none') + count = int(data.get('count', '-1')) + ret = safe_call(self.ctx().get_local_data, stock_code, start_time, end_time, period, divid_type, count) + if ret is None: + raise HTTPError(500, "获取本地行情失败") + self.write(json.dumps({"data": ret}, ensure_ascii=False, default=str)) + +# ContextInfo.subscribe_quote() - 订阅行情数据 +class SubscribeQuoteHandler(BaseHandler): + def post(self): + data = json.loads(self.request.body) + stock_code = data.get('stock_code', '') + period = data.get('period', 'follow') + dividend_type = data.get('dividend_type', 'follow') + ret = safe_call(self.ctx().subscribe_quote, stock_code, period, dividend_type) + self.write(json.dumps({"status": "success" if ret is not None else "failed", "sub_id": ret}, ensure_ascii=False)) + +# ContextInfo.unsubscribe_quote() - 反订阅行情数据 +class UnsubscribeQuoteHandler(BaseHandler): + def post(self): + data = json.loads(self.request.body) + sub_id = int(data.get('sub_id', '0')) + safe_call(self.ctx().unsubscribe_quote, sub_id) + self.write(json.dumps({"status": "success", "sub_id": sub_id}, ensure_ascii=False)) + + +# ============= 3. 判定函数 (is_*) ============= +# ContextInfo.is_last_bar() - 判定是否为最后一根K线 +class IsLastBarHandler(BaseHandler): + def get(self): + ret = safe_call(self.ctx().is_last_bar) + self.write(json.dumps({"is_last_bar": ret}, ensure_ascii=False)) + +# ContextInfo.is_new_bar() - 判定是否为新的K线 +class IsNewBarHandler(BaseHandler): + def get(self): + ret = safe_call(self.ctx().is_new_bar) + self.write(json.dumps({"is_new_bar": ret}, ensure_ascii=False)) + +# ContextInfo.is_suspended_stock() - 判定股票是否停牌 +class IsSuspendedStockHandler(BaseHandler): + def post(self): + data = json.loads(self.request.body) + stockcode = data.get('stockcode', '') + ret = safe_call(self.ctx().is_suspended_stock, stockcode) + self.write(json.dumps({"stockcode": stockcode, "is_suspended": ret}, ensure_ascii=False)) + +# is_sector_stock() - 判定股票是否在指定板块中 +class IsSectorStockHandler(BaseHandler): + def post(self): + data = json.loads(self.request.body) + sectorname = data.get('sectorname', '') + market = data.get('market', '') + stockcode = data.get('stockcode', '') + ret = safe_call(is_sector_stock, sectorname, market, stockcode) + self.write(json.dumps({"sectorname": sectorname, "stockcode": stockcode, "is_in_sector": ret}, ensure_ascii=False)) + +# is_typed_stock() - 判定股票是否属于某个类别 +class IsTypedStockHandler(BaseHandler): + def post(self): + data = json.loads(self.request.body) + stocktypenum = int(data.get('stocktypenum', '0')) + market = data.get('market', '') + stockcode = data.get('stockcode', '') + ret = safe_call(is_typed_stock, stocktypenum, market, stockcode) + self.write(json.dumps({"stocktypenum": stocktypenum, "stockcode": stockcode, "result": ret}, ensure_ascii=False)) + +# get_industry_name_of_stock() - 获取股票行业分类名称 +class GetIndustryNameOfStockHandler(BaseHandler): + def post(self): + data = json.loads(self.request.body) + industryType = data.get('industryType', '') + stockcode = data.get('stockcode', '') + ret = safe_call(get_industry_name_of_stock, industryType, stockcode) + self.write(json.dumps({"industryType": industryType, "stockcode": stockcode, "industry_name": ret}, ensure_ascii=False)) + + +# ============= 4. 交易函数 ============= +# passorder() - 综合交易下单(支持股票买卖等) +class PassorderHandler(BaseHandler): + def post(self): + try: + data = json.loads(self.request.body) + opType = int(data['opType']) + orderType = int(data.get('orderType', 1101)) + stock = data['stock'] + pr_type = int(data.get('prType', 11)) + price = float(data['price']) + volume = int(data['volume']) + quickTrade = int(data.get('quickTrade', 2)) + order_ref = passorder(opType, orderType, self.acc(), stock, pr_type, price, volume, 'qmt', quickTrade, self.ctx()) + self.write(json.dumps({ + "status": "success", + "opType": opType, + "stock": stock, + "order_ref": str(order_ref) if order_ref else "unknown" + }, ensure_ascii=False)) + except Exception as e: + logger.exception("passorder下单异常") + raise HTTPError(400, f"下单失败: {str(e)}") + +# algo_passorder() - 算法交易下单 +class AlgoPassorderHandler(BaseHandler): + def post(self): + try: + data = json.loads(self.request.body) + order_ref = algo_passorder( + int(data['opType']), int(data.get('orderType', 1101)), + self.acc(), data['stock'], int(data.get('prType', -1)), + float(data['price']), int(data['volume']), + data.get('strategyName', ''), int(data.get('quickTrade', 2)), + data.get('userOrderId', ''), data.get('userOrderParam', {}), + self.ctx() + ) + self.write(json.dumps({"status": "success", "order_ref": str(order_ref) if order_ref else "unknown"}, ensure_ascii=False)) + except Exception as e: + logger.exception("algo_passorder异常") + raise HTTPError(400, f"算法下单失败: {str(e)}") + +# smart_algo_passorder() - 智能算法交易下单 +class SmartAlgoPassorderHandler(BaseHandler): + def post(self): + try: + data = json.loads(self.request.body) + order_ref = smart_algo_passorder( + int(data['opType']), int(data.get('orderType', 1101)), + self.acc(), data['stock'], int(data.get('prType', -1)), + float(data['price']), int(data['volume']), + data['smartAlgoType'], int(data.get('limitOverRate', 0)), + int(data.get('minAmountPerOrder', 0)), + data.get('startTime', ''), data.get('endTime', ''), + self.ctx() + ) + self.write(json.dumps({"status": "success", "order_ref": str(order_ref) if order_ref else "unknown"}, ensure_ascii=False)) + except Exception as e: + logger.exception("smart_algo_passorder异常") + raise HTTPError(400, f"智能算法下单失败: {str(e)}") + +# order_lots() - 指定手数交易 +class OrderLotsHandler(BaseHandler): + def post(self): + try: + data = json.loads(self.request.body) + order_lots(data['stock'], int(data['lots']), data.get('style', 'LATEST'), + float(data.get('price', 0)), self.ctx(), data.get('accId', self.acc())) + self.write(json.dumps({"status": "success", "action": "order_lots", "stock": data['stock']}, ensure_ascii=False)) + except Exception as e: + logger.exception("order_lots异常") + raise HTTPError(400, f"下单失败: {str(e)}") + +# order_value() - 指定价值交易 +class OrderValueHandler(BaseHandler): + def post(self): + try: + data = json.loads(self.request.body) + order_value(data['stock'], float(data['value']), data.get('style', 'LATEST'), + float(data.get('price', 0)), self.ctx(), data.get('accId', self.acc())) + self.write(json.dumps({"status": "success", "action": "order_value", "stock": data['stock']}, ensure_ascii=False)) + except Exception as e: + logger.exception("order_value异常") + raise HTTPError(400, f"下单失败: {str(e)}") + +# order_percent() - 指定比例交易 +class OrderPercentHandler(BaseHandler): + def post(self): + try: + data = json.loads(self.request.body) + order_percent(data['stock'], float(data['percent']), data.get('style', 'LATEST'), + float(data.get('price', 0)), self.ctx(), data.get('accId', self.acc())) + self.write(json.dumps({"status": "success", "action": "order_percent", "stock": data['stock']}, ensure_ascii=False)) + except Exception as e: + logger.exception("order_percent异常") + raise HTTPError(400, f"下单失败: {str(e)}") + +# order_target_value() - 指定目标价值交易 +class OrderTargetValueHandler(BaseHandler): + def post(self): + try: + data = json.loads(self.request.body) + order_target_value(data['stock'], float(data['tar_value']), data.get('style', 'LATEST'), + float(data.get('price', 0)), self.ctx(), data.get('accId', self.acc())) + self.write(json.dumps({"status": "success", "action": "order_target_value", "stock": data['stock']}, ensure_ascii=False)) + except Exception as e: + logger.exception("order_target_value异常") + raise HTTPError(400, f"下单失败: {str(e)}") + +# order_target_percent() - 指定目标比例交易 +class OrderTargetPercentHandler(BaseHandler): + def post(self): + try: + data = json.loads(self.request.body) + order_target_percent(data['stock'], float(data['tar_percent']), data.get('style', 'LATEST'), + float(data.get('price', 0)), self.ctx(), data.get('accId', self.acc())) + self.write(json.dumps({"status": "success", "action": "order_target_percent", "stock": data['stock']}, ensure_ascii=False)) + except Exception as e: + logger.exception("order_target_percent异常") + raise HTTPError(400, f"下单失败: {str(e)}") + +# order_shares() - 指定股数交易 +class OrderSharesHandler(BaseHandler): + def post(self): + try: + data = json.loads(self.request.body) + order_shares(data['stock'], int(data['shares']), data.get('style', 'LATEST'), + float(data.get('price', 0)), self.ctx(), data.get('accId', self.acc())) + self.write(json.dumps({"status": "success", "action": "order_shares", "stock": data['stock']}, ensure_ascii=False)) + except Exception as e: + logger.exception("order_shares异常") + raise HTTPError(400, f"下单失败: {str(e)}") + + +# ============= 5. 期货交易 ============= +# buy_open() - 期货买入开仓 +class FuturesBuyOpenHandler(BaseHandler): + def post(self): + try: + data = json.loads(self.request.body) + buy_open(data['stock'], int(data['amount']), data.get('style', 'LATEST'), + float(data.get('price', 0)), self.ctx(), data.get('accId', self.acc())) + self.write(json.dumps({"status": "success", "action": "buy_open", "stock": data['stock']}, ensure_ascii=False)) + except Exception as e: + logger.exception("buy_open异常") + raise HTTPError(400, f"期货买入开仓失败: {str(e)}") + +# buy_close_tdayfirst() - 期货买入平仓(平今优先) +class FuturesBuyCloseTdayFirstHandler(BaseHandler): + def post(self): + try: + data = json.loads(self.request.body) + buy_close_tdayfirst(data['stock'], int(data['amount']), data.get('style', 'LATEST'), + float(data.get('price', 0)), self.ctx(), data.get('accId', self.acc())) + self.write(json.dumps({"status": "success", "action": "buy_close_tdayfirst", "stock": data['stock']}, ensure_ascii=False)) + except Exception as e: + logger.exception("buy_close_tdayfirst异常") + raise HTTPError(400, f"期货买入平仓(平今)失败: {str(e)}") + +# buy_close_ydayfirst() - 期货买入平仓(平昨优先) +class FuturesBuyCloseYdayFirstHandler(BaseHandler): + def post(self): + try: + data = json.loads(self.request.body) + buy_close_ydayfirst(data['stock'], int(data['amount']), data.get('style', 'LATEST'), + float(data.get('price', 0)), self.ctx(), data.get('accId', self.acc())) + self.write(json.dumps({"status": "success", "action": "buy_close_ydayfirst", "stock": data['stock']}, ensure_ascii=False)) + except Exception as e: + logger.exception("buy_close_ydayfirst异常") + raise HTTPError(400, f"期货买入平仓(平昨)失败: {str(e)}") + +# sell_open() - 期货卖出开仓 +class FuturesSellOpenHandler(BaseHandler): + def post(self): + try: + data = json.loads(self.request.body) + sell_open(data['stock'], int(data['amount']), data.get('style', 'LATEST'), + float(data.get('price', 0)), self.ctx(), data.get('accId', self.acc())) + self.write(json.dumps({"status": "success", "action": "sell_open", "stock": data['stock']}, ensure_ascii=False)) + except Exception as e: + logger.exception("sell_open异常") + raise HTTPError(400, f"期货卖出开仓失败: {str(e)}") + +# sell_close_tdayfirst() - 期货卖出平仓(平今优先) +class FuturesSellCloseTdayFirstHandler(BaseHandler): + def post(self): + try: + data = json.loads(self.request.body) + sell_close_tdayfirst(data['stock'], int(data['amount']), data.get('style', 'LATEST'), + float(data.get('price', 0)), self.ctx(), data.get('accId', self.acc())) + self.write(json.dumps({"status": "success", "action": "sell_close_tdayfirst", "stock": data['stock']}, ensure_ascii=False)) + except Exception as e: + logger.exception("sell_close_tdayfirst异常") + raise HTTPError(400, f"期货卖出平仓(平今)失败: {str(e)}") + +# sell_close_ydayfirst() - 期货卖出平仓(平昨优先) +class FuturesSellCloseYdayFirstHandler(BaseHandler): + def post(self): + try: + data = json.loads(self.request.body) + sell_close_ydayfirst(data['stock'], int(data['amount']), data.get('style', 'LATEST'), + float(data.get('price', 0)), self.ctx(), data.get('accId', self.acc())) + self.write(json.dumps({"status": "success", "action": "sell_close_ydayfirst", "stock": data['stock']}, ensure_ascii=False)) + except Exception as e: + logger.exception("sell_close_ydayfirst异常") + raise HTTPError(400, f"期货卖出平仓(平昨)失败: {str(e)}") + + +# ============= 6. 任务管理 ============= +# cancel_task() - 撤销任务 +class CancelTaskHandler(BaseHandler): + def post(self): + try: + data = json.loads(self.request.body) + taskId = data['taskId'] + accountType = data.get('accountType', 'stock') + ret = cancel_task(taskId, self.acc(), accountType, self.ctx()) + self.write(json.dumps({"status": "success" if ret else "failed", "taskId": taskId}, ensure_ascii=False)) + except Exception as e: + logger.exception("cancel_task异常") + raise HTTPError(400, f"撤销任务失败: {str(e)}") + +# pause_task() - 暂停任务 +class PauseTaskHandler(BaseHandler): + def post(self): + try: + data = json.loads(self.request.body) + taskId = data['taskId'] + accountType = data.get('accountType', 'stock') + ret = pause_task(taskId, self.acc(), accountType, self.ctx()) + self.write(json.dumps({"status": "success" if ret else "failed", "taskId": taskId}, ensure_ascii=False)) + except Exception as e: + logger.exception("pause_task异常") + raise HTTPError(400, f"暂停任务失败: {str(e)}") + +# resume_task() - 继续任务 +class ResumeTaskHandler(BaseHandler): + def post(self): + try: + data = json.loads(self.request.body) + taskId = data['taskId'] + accountType = data.get('accountType', 'stock') + ret = resume_task(taskId, self.acc(), accountType, self.ctx()) + self.write(json.dumps({"status": "success" if ret else "failed", "taskId": taskId}, ensure_ascii=False)) + except Exception as e: + logger.exception("resume_task异常") + raise HTTPError(400, f"继续任务失败: {str(e)}") + +# do_order() - 实时触发前一根bar信号函数 +class DoOrderHandler(BaseHandler): + def post(self): + try: + do_order(self.ctx()) + self.write(json.dumps({"status": "success", "message": "信号已触发"}, ensure_ascii=False)) + except Exception as e: + logger.exception("do_order异常") + raise HTTPError(400, f"触发信号失败: {str(e)}") + + +# ============= 7. 账户/订单查询 ============= +# get_trade_detail_data() - 获取交易明细(持仓/委托/成交/资金) +class TradeDetailDataHandler(BaseHandler): + def post(self): + data = json.loads(self.request.body) + account = data.get('account', 'stock') + datatype = data.get('datatype', 'position') + ret = safe_call(get_trade_detail_data, self.acc(), account, datatype, 'qmt') + if ret is None: + ret = [] + result = [] + for obj in ret: + attrs = {} + for attr in dir(obj): + if not attr.startswith('_'): + try: + val = getattr(obj, attr) + if not callable(val): + attrs[attr] = str(val) + except Exception: + pass + result.append(attrs) + self.write(json.dumps({"data": result}, ensure_ascii=False)) + +# get_value_by_order_id() - 根据委托号获取委托/成交信息 +class ValueByOrderIdHandler(BaseHandler): + def post(self): + data = json.loads(self.request.body) + orderId = data.get('orderId', '') + accountType = data.get('accountType', 'stock') + datatype = data.get('datatype', 'ORDER') + ret = safe_call(get_value_by_order_id, orderId, self.acc(), accountType, datatype) + attrs = {} + if ret: + for attr in dir(ret): + if not attr.startswith('_'): + try: + val = getattr(ret, attr) + if not callable(val): + attrs[attr] = str(val) + except Exception: + pass + self.write(json.dumps({"orderId": orderId, "data": attrs}, ensure_ascii=False)) + +# get_last_order_id() - 获取最新委托/成交的委托号 +class LastOrderIdHandler(BaseHandler): + def post(self): + data = json.loads(self.request.body) + account = data.get('account', 'stock') + datatype = data.get('datatype', 'ORDER') + ret = safe_call(get_last_order_id, self.acc(), account, datatype, 'qmt') + self.write(json.dumps({"last_order_id": ret}, ensure_ascii=False)) + +# can_cancel_order() - 查询委托是否可撤销 +class CanCancelOrderHandler(BaseHandler): + def post(self): + data = json.loads(self.request.body) + orderId = data.get('orderId', '') + accountType = data.get('accountType', 'stock') + ret = safe_call(can_cancel_order, orderId, self.acc(), accountType) + self.write(json.dumps({"orderId": orderId, "can_cancel": ret}, ensure_ascii=False)) + +# get_debt_contract() - 获取两融负债合约明细 +class DebtContractHandler(BaseHandler): + def post(self): + data = json.loads(self.request.body) + accId = data.get('accId', self.acc()) + ret = safe_call(get_debt_contract, accId) + result = [] + if ret: + for obj in ret: + attrs = {} + for attr in dir(obj): + if not attr.startswith('_'): + try: + val = getattr(obj, attr) + if not callable(val): + attrs[attr] = str(val) + except Exception: + pass + result.append(attrs) + self.write(json.dumps({"data": result}, ensure_ascii=False)) + +# get_assure_contract() - 获取两融担保标的明细 +class AssureContractHandler(BaseHandler): + def post(self): + data = json.loads(self.request.body) + accId = data.get('accId', self.acc()) + ret = safe_call(get_assure_contract, accId) + result = [] + if ret: + for obj in ret: + attrs = {} + for attr in dir(obj): + if not attr.startswith('_'): + try: + val = getattr(obj, attr) + if not callable(val): + attrs[attr] = str(val) + except Exception: + pass + result.append(attrs) + self.write(json.dumps({"data": result}, ensure_ascii=False)) + +# get_enable_short_contract() - 获取可融券明细 +class EnableShortContractHandler(BaseHandler): + def post(self): + data = json.loads(self.request.body) + accId = data.get('accId', self.acc()) + ret = safe_call(get_enable_short_contract, accId) + result = [] + if ret: + for obj in ret: + attrs = {} + for attr in dir(obj): + if not attr.startswith('_'): + try: + val = getattr(obj, attr) + if not callable(val): + attrs[attr] = str(val) + except Exception: + pass + result.append(attrs) + self.write(json.dumps({"data": result}, ensure_ascii=False)) + +# get_ipo_data() - 获取当日新股新债信息 +class IpoDataHandler(BaseHandler): + def post(self): + data = json.loads(self.request.body) + typ = data.get('type', '') + ret = safe_call(get_ipo_data, typ) + self.write(json.dumps({"data": ret or {}}, ensure_ascii=False, default=str)) + +# get_new_purchase_limit() - 获取新股申购额度 +class NewPurchaseLimitHandler(BaseHandler): + def post(self): + data = json.loads(self.request.body) + accid = data.get('accid', self.acc()) + ret = safe_call(get_new_purchase_limit, accid) + self.write(json.dumps({"data": ret or {}}, ensure_ascii=False, default=str)) + + +# ============= 8. 引用函数 (ext_data) ============= +# ext_data() - 获取扩展数据数值 +class ExtDataHandler(BaseHandler): + def post(self): + data = json.loads(self.request.body) + extdataname = data.get('extdataname', '') + stockcode = data.get('stockcode', '') + deviation = int(data.get('deviation', '0')) + ret = safe_call(ext_data, extdataname, stockcode, deviation, self.ctx()) + self.write(json.dumps({"extdataname": extdataname, "stockcode": stockcode, "value": ret}, ensure_ascii=False)) + +# ext_data_rank() - 获取扩展数据排名 +class ExtDataRankHandler(BaseHandler): + def post(self): + data = json.loads(self.request.body) + extdataname = data.get('extdataname', '') + stockcode = data.get('stockcode', '') + deviation = int(data.get('deviation', '0')) + ret = safe_call(ext_data_rank, extdataname, stockcode, deviation, self.ctx()) + self.write(json.dumps({"extdataname": extdataname, "stockcode": stockcode, "rank": ret}, ensure_ascii=False)) + +# get_factor_value() - 获取因子数据 +class GetFactorValueHandler(BaseHandler): + def post(self): + data = json.loads(self.request.body) + factorname = data.get('factorname', '') + stockcode = data.get('stockcode', '') + deviation = int(data.get('deviation', '0')) + ret = safe_call(get_factor_value, factorname, stockcode, deviation, self.ctx()) + self.write(json.dumps({"factorname": factorname, "stockcode": stockcode, "value": ret}, ensure_ascii=False)) + +# get_factor_rank() - 获取因子数据排名 +class GetFactorRankHandler(BaseHandler): + def post(self): + data = json.loads(self.request.body) + factorname = data.get('factorname', '') + stockcode = data.get('stockcode', '') + deviation = int(data.get('deviation', '0')) + ret = safe_call(get_factor_rank, factorname, stockcode, deviation, self.ctx()) + self.write(json.dumps({"factorname": factorname, "stockcode": stockcode, "rank": ret}, ensure_ascii=False)) + + +# ============= 9. 原有 Handler(保持兼容) ============= +# get_trade_detail_data('position') - 查询持仓列表(封装格式) +class HoldingHandler(BaseHandler): + def post(self): + data = json.loads(self.request.body) + account = data.get('account', 'stock') + positions = safe_call(get_trade_detail_data, self.acc(), account, 'position') or [] + holding = {} + for position in positions: + stock = position.m_strInstrumentID + '.' + position.m_strExchangeID + holding[stock] = { + 'StockCode': stock, + 'StockName': position.m_strInstrumentName, + 'Direction': position.m_nDirection, + 'Volume': position.m_nVolume, + 'OpenPrice': position.m_dOpenPrice, + '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 + } + self.write(json.dumps(holding, ensure_ascii=False)) + +# get_trade_detail_data('account') - 查询资产 +class AssetsHandler(BaseHandler): + def post(self): + data = json.loads(self.request.body) + account = data.get('account', 'stock') + _data = safe_call(get_trade_detail_data, self.acc(), account, 'account') + info = _data[0] if _data else None + if not info: + raise HTTPError(500, "资金数据获取失败") + self.write(json.dumps({"total": round(info.m_dBalance, 2),"available": round(info.m_dAvailable, 2)}, ensure_ascii=False)) + + +# get_trade_detail_data('account') - 查询总资产 +class TotalMoneyHandler(BaseHandler): + def post(self): + data = json.loads(self.request.body) + account = data.get('account', 'stock') + _data = safe_call(get_trade_detail_data, self.acc(), account, 'account') + info = _data[0] if _data else None + if not info: + raise HTTPError(500, "资金数据获取失败") + self.write(json.dumps({"total_money": round(info.m_dBalance, 2)}, ensure_ascii=False)) + +# get_trade_detail_data('account') - 查询可用资金 +class AvailableMoneyHandler(BaseHandler): + def post(self): + data = json.loads(self.request.body) + account = data.get('account', 'stock') + _data = safe_call(get_trade_detail_data, self.acc(), account, 'account') + info = _data[0] if _data else None + if not info: + raise HTTPError(500, "资金数据获取失败") + self.write(json.dumps({"available_money": round(info.m_dAvailable, 2)}, ensure_ascii=False)) + +# passorder(23) - 简化买入下单(封装passorder) +class BuyHandler(BaseHandler): + def post(self): + try: + data = json.loads(self.request.body) + stock = data['stock'] + price = float(data['price']) + volume = int(data['volume']) + pr_type = data.get('prType', 11) + order_ref = passorder(23, 1101, self.acc(), stock, pr_type, price, volume, 'qmt', 2, self.ctx()) + self.write(json.dumps({ + "status": "success", "action": "buy", "stock": stock, + "order_ref": str(order_ref) if order_ref else "unknown" + }, ensure_ascii=False)) + except Exception as e: + logger.exception("买入下单异常") + raise HTTPError(400, f"下单失败: {str(e)}") + +# passorder(24) - 简化卖出下单(封装passorder) +class SellHandler(BaseHandler): + def post(self): + try: + data = json.loads(self.request.body) + stock = data['stock'] + price = float(data['price']) + volume = int(data['volume']) + pr_type = data.get('prType', 11) + order_ref = passorder(24, 1101, self.acc(), stock, pr_type, price, volume, 'qmt', 2, self.ctx()) + self.write(json.dumps({ + "status": "success", "action": "sell", "stock": stock, + "order_ref": str(order_ref) if order_ref else "unknown" + }, ensure_ascii=False)) + except Exception as e: + logger.exception("卖出下单异常") + raise HTTPError(400, f"下单失败: {str(e)}") + +# get_trade_detail_data('order') - 查询委托状态列表 +class OrderStatusHandler(BaseHandler): + def post(self): + data = json.loads(self.request.body) + account = data.get('account', 'stock') + orders = safe_call(get_trade_detail_data, self.acc(), account, 'order', 'qmt') or [] + rets = [] + for order in orders: + rets.append({ + "order_sys_id": order.m_strOrderSysID, + "status": order.m_nOrderStatus, + "volume_left": order.m_nVolumeTotal, + "volume_traded": order.m_nVolumeTraded, + }) + self.write(json.dumps({"orders": rets}, ensure_ascii=False)) + +# cancel() - 全部撤单 +class CancelAllHandler(BaseHandler): + def post(self): + try: + data = json.loads(self.request.body) + account = data.get('account', 'stock') + orders = safe_call(get_trade_detail_data, self.acc(), account, 'order', 'qmt') or [] + canceled_list = [] + for order in orders: + if can_cancel_order(order.m_strOrderSysID, self.acc(), account): + cancel(order.m_strOrderSysID, self.acc(), account, self.ctx()) + canceled_list.append({ + "order_sys_id": order.m_strOrderSysID, + "stock": order.m_strInstrumentID, + "volume_left": order.m_nVolumeTotal + }) + self.write(json.dumps({ + "status": "success", + "message": f"已发出 {len(canceled_list)} 笔撤单请求", + "canceled_orders": canceled_list + }, ensure_ascii=False)) + except Exception as e: + logger.exception("全部撤单异常") + raise HTTPError(500, f"撤单失败: {str(e)}") + + +class CancelByRuleHandler(BaseHandler): + def post(self): + try: + data = json.loads(self.request.body) + stock = data.get('stock') + cancel_volume = int(data.get('volume', 0)) + account = data.get('account', 'stock') + if not stock or cancel_volume <= 0: + raise HTTPError(400, "参数错误:必须提供 stock 且 volume > 0") + orders = safe_call(get_trade_detail_data, self.acc(), account, 'order', 'qmt') or [] + target_orders = [] + for order in orders: + order_code = f"{order.m_strInstrumentID}.{order.m_strExchangeID}" + if order.m_nVolumeTotal + order.m_nVolumeTraded == cancel_volume and order_code == stock and can_cancel_order(order.m_strOrderSysID, self.acc(), account): + target_orders.append(order) + if not target_orders: + self.write(json.dumps({"status": "failed", "message": "未找到符合条件的活跃订单"}, ensure_ascii=False)) + return + canceled_ids = [] + for t_order in target_orders: + cancel(t_order.m_strOrderSysID, self.acc(), account, self.ctx()) + canceled_ids.append(t_order.m_strOrderSysID) + self.write(json.dumps({ + "status": "success", + "message": f"匹配到 {len(target_orders)} 笔订单并发出撤单请求", + "canceled_sys_ids": canceled_ids + }, ensure_ascii=False)) + except Exception as e: + logger.exception("规则撤单异常") + raise HTTPError(500, f"撤单失败: {str(e)}") + +# cancel() - 按股票+数量匹配规则撤单 +# sys: Python版本信息 +class PythonVersionHandler(BaseHandler): + def get(self): + import sys + 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.dumps(version_info, ensure_ascii=False)) + +# sys: 关闭HTTP服务 +class ShutdownHandler(BaseHandler): + def post(self): + logger.info("收到关闭请求,服务器即将停止...") + self.write(json.dumps({"status": "success", "message": "服务器正在关闭..."}, ensure_ascii=False)) + self.finish() + IOLoop.current().add_callback(IOLoop.current().stop) + +# get_trade_detail_data('deal') - 查询成交明细 +class DealHandler(BaseHandler): + def post(self): + data = json.loads(self.request.body) + account = data.get('account', 'stock') + deals = safe_call(get_trade_detail_data, self.acc(), account, 'deal', 'qmt') or [] + rets = [] + for deal in deals: + attrs = {} + for attr in dir(deal): + if not attr.startswith('_'): + try: + val = getattr(deal, attr) + if not callable(val): + attrs[attr] = str(val) + except Exception: + pass + rets.append(attrs) + self.write(json.dumps({"deals": rets}, ensure_ascii=False)) + + +# ============= 路由注册 ============= +def make_app(): + return Application([ + # V2 + (r"/api/v2/positions", HoldingHandler), + (r"/api/v2/assets", AssetsHandler), + + # 原有兼容路由 + (r"/api/holding", HoldingHandler), + (r"/api/money/total", TotalMoneyHandler), + (r"/api/money/available", AvailableMoneyHandler), + (r"/api/order/buy", BuyHandler), + (r"/api/order/sell", SellHandler), + (r"/api/order/status", OrderStatusHandler), + (r"/api/order/cancel_all", CancelAllHandler), + (r"/api/order/cancel_order", CancelByRuleHandler), + (r"/api/order/deal", DealHandler), + + # ContextInfo 属性 + (r"/api/context/period", ContextPeriodHandler), + (r"/api/context/barpos", ContextBarposHandler), + (r"/api/context/time_tick_size", ContextTimeTickSizeHandler), + (r"/api/context/stockcode", ContextStockCodeHandler), + (r"/api/context/dividend_type", ContextDividendTypeHandler), + (r"/api/context/market", ContextMarketHandler), + (r"/api/context/do_back_test", ContextDoBackTestHandler), + (r"/api/context/benchmark", ContextBenchmarkHandler), + (r"/api/context/capital", ContextCapitalHandler), + (r"/api/context/universe", ContextUniverseHandler), + + # 数据查询 + (r"/api/data/stock_name", StockNameHandler), + (r"/api/data/open_date", OpenDateHandler), + (r"/api/data/last_volume", LastVolumeHandler), + (r"/api/data/bar_timetag", BarTimetagHandler), + (r"/api/data/tick_timetag", TickTimetagHandler), + (r"/api/data/sector", SectorHandler), + (r"/api/data/industry", IndustryHandler), + (r"/api/data/stock_list_in_sector", StockListInSectorHandler), + (r"/api/data/weight_in_index", WeightInIndexHandler), + (r"/api/data/contract_multiplier", ContractMultiplierHandler), + (r"/api/data/risk_free_rate", RiskFreeRateHandler), + (r"/api/data/date_location", DateLocationHandler), + (r"/api/data/history_data", HistoryDataHandler), + (r"/api/data/market_data", MarketDataHandler), + (r"/api/data/market_data_ex", MarketDataExHandler), + (r"/api/data/full_tick", FullTickHandler), + (r"/api/data/divid_factors", DividFactorsHandler), + (r"/api/data/main_contract", MainContractHandler), + (r"/api/data/timetag_to_datetime", TimetagToDatetimeHandler), + (r"/api/data/total_share", TotalShareHandler), + (r"/api/data/trading_dates", TradingDatesHandler), + (r"/api/data/svol", SvolHandler), + (r"/api/data/bvol", BvolHandler), + (r"/api/data/longhubang", LonghubangHandler), + (r"/api/data/top10_share_holder", Top10ShareHolderHandler), + (r"/api/data/option_detail", OptionDetailHandler), + (r"/api/data/turnover_rate", TurnoverRateHandler), + (r"/api/data/etf_info", EtfInfoHandler), + (r"/api/data/etf_iopv", EtfIopvHandler), + (r"/api/data/instrumentdetail", InstrumentDetailHandler), + (r"/api/data/contract_expire_date", ContractExpireDateHandler), + (r"/api/data/option_undl_data", OptionUndlDataHandler), + (r"/api/data/financial_data", FinancialDataHandler), + (r"/api/data/factor_data", FactorDataHandler), + (r"/api/data/his_st_data", HisStDataHandler), + (r"/api/data/his_index_data", HisIndexDataHandler), + (r"/api/data/all_subscription", AllSubscriptionHandler), + (r"/api/data/option_list", OptionListHandler), + (r"/api/data/his_contract_list", HisContractListHandler), + (r"/api/data/option_iv", OptionIvHandler), + (r"/api/data/bsm_price", BsmPriceHandler), + (r"/api/data/bsm_iv", BsmIvHandler), + (r"/api/data/local_data", LocalDataHandler), + + # 订阅 + (r"/api/data/subscribe_quote", SubscribeQuoteHandler), + (r"/api/data/unsubscribe_quote", UnsubscribeQuoteHandler), + + # 判定函数 + (r"/api/check/is_last_bar", IsLastBarHandler), + (r"/api/check/is_new_bar", IsNewBarHandler), + (r"/api/check/is_suspended_stock", IsSuspendedStockHandler), + (r"/api/check/is_sector_stock", IsSectorStockHandler), + (r"/api/check/is_typed_stock", IsTypedStockHandler), + (r"/api/check/get_industry_name_of_stock", GetIndustryNameOfStockHandler), + + # 交易 + (r"/api/trade/passorder", PassorderHandler), + (r"/api/trade/algo_passorder", AlgoPassorderHandler), + (r"/api/trade/smart_algo_passorder", SmartAlgoPassorderHandler), + (r"/api/trade/order_lots", OrderLotsHandler), + (r"/api/trade/order_value", OrderValueHandler), + (r"/api/trade/order_percent", OrderPercentHandler), + (r"/api/trade/order_target_value", OrderTargetValueHandler), + (r"/api/trade/order_target_percent", OrderTargetPercentHandler), + (r"/api/trade/order_shares", OrderSharesHandler), + + # 期货交易 + (r"/api/trade/futures/buy_open", FuturesBuyOpenHandler), + (r"/api/trade/futures/buy_close_tdayfirst", FuturesBuyCloseTdayFirstHandler), + (r"/api/trade/futures/buy_close_ydayfirst", FuturesBuyCloseYdayFirstHandler), + (r"/api/trade/futures/sell_open", FuturesSellOpenHandler), + (r"/api/trade/futures/sell_close_tdayfirst", FuturesSellCloseTdayFirstHandler), + (r"/api/trade/futures/sell_close_ydayfirst", FuturesSellCloseYdayFirstHandler), + + # 任务管理 + (r"/api/trade/cancel_task", CancelTaskHandler), + (r"/api/trade/pause_task", PauseTaskHandler), + (r"/api/trade/resume_task", ResumeTaskHandler), + (r"/api/trade/do_order", DoOrderHandler), + + # 账户/订单查询 + (r"/api/trade/trade_detail_data", TradeDetailDataHandler), + (r"/api/trade/value_by_order_id", ValueByOrderIdHandler), + (r"/api/trade/last_order_id", LastOrderIdHandler), + (r"/api/trade/can_cancel_order", CanCancelOrderHandler), + (r"/api/trade/debt_contract", DebtContractHandler), + (r"/api/trade/assure_contract", AssureContractHandler), + (r"/api/trade/enable_short_contract", EnableShortContractHandler), + (r"/api/trade/ipo_data", IpoDataHandler), + (r"/api/trade/new_purchase_limit", NewPurchaseLimitHandler), + + # 引用函数 + (r"/api/ext/ext_data", ExtDataHandler), + (r"/api/ext/ext_data_rank", ExtDataRankHandler), + (r"/api/ext/get_factor_value", GetFactorValueHandler), + (r"/api/ext/get_factor_rank", GetFactorRankHandler), + + # 系统 + (r"/api/sys/python_version", PythonVersionHandler), + (r"/api/sys/shutdown", ShutdownHandler), + + ], debug=False) + +# ============= Callback 注册 ============= +def json_serializer(obj): + """自定义 JSON 序列化器""" + if isinstance(obj, datetime.datetime): + return obj.strftime("%Y-%m-%d %H:%M:%S") + if isinstance(obj, datetime.date): + return obj.strftime("%Y-%m-%d") + raise TypeError(f"Type {type(obj)} not serializable") + +def write_json(file_key, data,order_id:str=''): + """ + 将数据写入 JSON 文件 + + 参数: + file_key (str): 文件名模板 + data: 要写入的数据 + order_id (str): 订单ID,可选 + """ + now=datetime.datetime.now() + day = now.strftime("%Y%m%d") + body = { + "created_at": now, + "body":data + } + # 构建路径 + if order_id: + file_path = Path(DATA_DIR) / (file_key % (day,order_id)) + else: + file_path = Path(DATA_DIR) / (file_key % day) + + # 创建目录 + file_path.parent.mkdir(parents=True, exist_ok=True) + + with open(file_path, 'w', encoding='utf-8') as f: + json.dump(body, f, ensure_ascii=False,indent=4, default=json_serializer) + +# 资金账号主推函数 +def account_callback(ContextInfo, accountInfo): + write_json("acount_%s.json",accountInfo) + +# 委托主推函数 +def order_callback(ContextInfo, orderInfo): + write_json("order_%s_%s.json",orderInfo,orderInfo.m_strOrderSysID) + +# 成交主推函数 +def deal_callback(ContextInfo, dealInfo): + write_json("deal_%s_%s.json",dealInfo,dealInfo.m_strOrderSysID) + +# 持仓主推函数 +def position_callback(ContextInfo, positonInfo): + write_json("position_%s.json",positonInfo) + +#下单出错回调函数 +def orderError_callback(ContextInfo, passOrderInfo, msg): + print('orderError_callback') + #输出下单信息以及错误信息 + print (passOrderInfo.orderCode) + print (msg) + +def init(ContextInfo): + if not (ACCOUNT_ID or "").strip(): + msg = "ACCOUNT_ID 为空,无法启动" + logger.error(msg) + raise ValueError(msg) + if not (DATA_DIR or "").strip(): + msg = "DATA_DIR 为空,无法启动" + logger.error(msg) + raise ValueError(msg) + try: + ContextInfo.accountID = ACCOUNT_ID + ContextInfo.set_account(ACCOUNT_ID) + # 按需加载股票池 + pass_codes_path = Path(DATA_DIR) / "pass_codes.json" + with pass_codes_path.open("r", encoding="utf-8") as stream: + codes = json.load(stream) + 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"初始化股票池: {len(codes)} 支标的") + logger.info(f"QMT HTTP Server 启动于 http://0.0.0.0:{PORT} (全部API已加载)") + IOLoop.current().start() + except Exception as e: + logger.exception(f"server start failed: {e}") diff --git a/api/__pycache__/QMT_API.cpython-311.pyc b/api/__pycache__/QMT_API.cpython-311.pyc new file mode 100644 index 0000000..6264d8d Binary files /dev/null and b/api/__pycache__/QMT_API.cpython-311.pyc differ diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..4a46fe5 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,257 @@ +# QMT HTTP API + +将迅投 QMT(MiniQMT / 投研版)策略进程内的 `ContextInfo`、行情、财务与交易函数,封装为 JSON HTTP 服务,供外部程序远程调用。 + +源码:[`api/QMT_API.py`](../api/QMT_API.py) + +完整接口清单、请求/响应字段与 curl 示例见 [api.md](./api.md)。 + +--- + +## 1. 它是什么 + +`QMT_API.py` **不是**可独立 `python QMT_API.py` 启动的普通脚本。它是一份 QMT Python 策略: + +- 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": "", "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) | + +兼容层买卖是对 `passorder` 的薄封装: + +- `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` 的账户类型参数。 + +--- + +## 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" +``` + +环境变量读入的是 `DATA_DIR`,`init` / `write_json` 使用的是 **从未赋值的** `QMT_DATA_DIR`。在普通 Python 里会 `NameError`。若你的 QMT 环境没有额外注入同名全局量,策略会在启动阶段失败。 + +### 8.2 `write_json` 不可用 + +- 使用未定义的 `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` 等枚举以官方文档为准;本仓库只记录本封装实际传入的值) diff --git a/docs/api.md b/docs/api.md new file mode 100644 index 0000000..e8074b1 --- /dev/null +++ b/docs/api.md @@ -0,0 +1,1214 @@ +# HTTP API 参考 + +本文档根据 [`api/QMT_API.py`](../api/QMT_API.py) 的 Handler 与 `make_app()` 路由逐条整理。架构、鉴权、启动方式见 [README.md](./README.md)。 + +**公共约定** + +- Base URL:`http://:10086`(`PORT=10086`,绑定 `0.0.0.0`) +- 鉴权:所有已注册接口均需请求头 `X-Token: `(源码当前值为 `QMTbyYanweidong`) +- POST Body:JSON 对象。未特别说明的字段均可省略并走源码默认值 +- 多值字符串:逗号分隔,例如 `"000001.SZ,600000.SH"` +- 失败响应:`{"error": "", "status_code": }` +- 「对应 QMT」列标明封装的原函数或属性;账户类调用会自动填入 `self.acc()`(即 `init` 时的 `ACCOUNT_ID`) + +文中 curl 的 `$TOKEN` 请自行替换。 + +--- + +## 目录 + +1. [兼容层 / v2](#1-兼容层--v2) +2. [策略上下文 `/api/context`](#2-策略上下文-apicontext) +3. [数据查询 `/api/data`](#3-数据查询-apidata) +4. [判定 `/api/check`](#4-判定-apicheck) +5. [交易 `/api/trade`](#5-交易-apitrade) +6. [扩展引用 `/api/ext`](#6-扩展引用-apiext) +7. [系统 `/api/sys`](#7-系统-apisys) + +--- + +## 1. 兼容层 / v2 + +旧客户端路径与 `/api/v2/*` 共用同一 Handler。查询类 POST 即使只用默认值,也需要传 `{}`。 + +### 1.1 持仓列表 + +- **POST** `/api/holding` +- **POST** `/api/v2/positions` +- 对应 QMT:`get_trade_detail_data(accountId, account, 'position')` + +**请求** + +| 字段 | 类型 | 默认 | 说明 | +| --- | --- | --- | --- | +| `account` | string | `"stock"` | 账户类型,传入 QMT | + +**响应**:对象,key 为 `InstrumentID.ExchangeID`。 + +```json +{ + "600000.SH": { + "StockCode": "600000.SH", + "StockName": "...", + "Direction": "...", + "Volume": 0, + "OpenPrice": 0, + "FloatProfit": 0, + "MarketValue": 0, + "StockHolder": "...", + "FrozenVolume": 0, + "CanUseVolume": 0, + "OnRoadVolume": 0, + "YesterdayVolume": 0, + "LastPrice": 0, + "ProfitRate": 0, + "FutureTradeType": "...", + "ExpireDate": "..." + } +} +``` + +无持仓时为 `{}`。QMT 调用失败时 `safe_call` 返回 `None`,按空列表处理。 + +```bash +curl -s -X POST -H "X-Token: $TOKEN" -H "Content-Type: application/json" \ + -d "{\"account\":\"stock\"}" http://127.0.0.1:10086/api/v2/positions +``` + +### 1.2 资产(总资产 + 可用) + +- **POST** `/api/v2/assets` +- 对应 QMT:`get_trade_detail_data(..., 'account')`,取第一条 + +**请求**:`account` 默认 `"stock"`。 + +**响应** + +```json +{"total": 0.0, "available": 0.0} +``` + +金额来自 `m_dBalance` / `m_dAvailable`,四舍五入到 2 位。无数据时 **500** `资金数据获取失败`。 + +### 1.3 总资产 + +- **POST** `/api/money/total` + +**响应**:`{"total_money": 0.0}`。无数据 500。 + +### 1.4 可用资金 + +- **POST** `/api/money/available` + +**响应**:`{"available_money": 0.0}`。无数据 500。 + +### 1.5 简化买入 + +- **POST** `/api/order/buy` +- 对应 QMT:`passorder(23, 1101, acc, stock, prType, price, volume, 'qmt', 2, ctx)` + +**请求** + +| 字段 | 类型 | 必填 | 默认 | 说明 | +| --- | --- | --- | --- | --- | +| `stock` | string | 是 | | 代码,如 `600000.SH` | +| `price` | number | 是 | | 价格 | +| `volume` | int | 是 | | 数量 | +| `prType` | int | 否 | `11` | 报价类型 | + +**响应** + +```json +{"status": "success", "action": "buy", "stock": "600000.SH", "order_ref": "..."} +``` + +`order_ref` 在 QMT 返回空时为 `"unknown"`。异常 **400** `下单失败: ...`。 + +### 1.6 简化卖出 + +- **POST** `/api/order/sell` +- 对应 QMT:`passorder(24, 1101, ...)`,其余同买入 + +**响应** `action` 为 `"sell"`。 + +### 1.7 委托状态列表 + +- **POST** `/api/order/status` +- 对应 QMT:`get_trade_detail_data(..., 'order', 'qmt')` + +**请求**:`account` 默认 `"stock"`。 + +**响应** + +```json +{ + "orders": [ + { + "order_sys_id": "...", + "status": 0, + "volume_left": 0, + "volume_traded": 0 + } + ] +} +``` + +字段分别对应 `m_strOrderSysID`、`m_nOrderStatus`、`m_nVolumeTotal`、`m_nVolumeTraded`。 + +### 1.8 全部撤单 + +- **POST** `/api/order/cancel_all` +- 对应 QMT:遍历委托,`can_cancel_order` 为真则 `cancel` + +**请求**:`account` 默认 `"stock"`。 + +**响应** + +```json +{ + "status": "success", + "message": "已发出 N 笔撤单请求", + "canceled_orders": [ + {"order_sys_id": "...", "stock": "...", "volume_left": 0} + ] +} +``` + +`stock` 仅 `m_strInstrumentID`(不含市场后缀)。异常 **500**。 + +### 1.9 按股票 + 数量规则撤单 + +- **POST** `/api/order/cancel_order` + +**请求** + +| 字段 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `stock` | string | 是 | 必须为 `代码.市场`,与持仓 key 相同 | +| `volume` | int | 是 | 必须 `> 0`;匹配条件为 `VolumeTotal + VolumeTraded == volume` | +| `account` | string | 否 | 默认 `"stock"` | + +匹配到 0 笔时仍 HTTP 200: + +```json +{"status": "failed", "message": "未找到符合条件的活跃订单"} +``` + +成功: + +```json +{ + "status": "success", + "message": "匹配到 N 笔订单并发出撤单请求", + "canceled_sys_ids": ["..."] +} +``` + +缺参 **400** `参数错误:必须提供 stock 且 volume > 0`。 + +### 1.10 成交明细 + +- **POST** `/api/order/deal` +- 对应 QMT:`get_trade_detail_data(..., 'deal', 'qmt')` + +**响应**:`{"deals": [ {对象全部非下划线、非可调用属性,值一律 str()} ] }`。 + +--- + +## 2. 策略上下文 `/api/context` + +全部 **GET**,无请求体。数据来自 `self.application.ContextInfo`。 + +| 方法 | 路径 | 对应 QMT | 响应 | +| --- | --- | --- | --- | +| GET | `/api/context/period` | `.period` | `{"period": ...}` | +| GET | `/api/context/barpos` | `.barpos` | `{"barpos": ...}` | +| GET | `/api/context/time_tick_size` | `.time_tick_size` | `{"time_tick_size": ...}` | +| GET | `/api/context/stockcode` | `.stockcode` | `{"stockcode": ...}` | +| GET | `/api/context/dividend_type` | `.dividend_type` | `{"dividend_type": ...}` | +| GET | `/api/context/market` | `.market` | `{"market": ...}` | +| GET | `/api/context/do_back_test` | `.do_back_test` | `{"do_back_test": ...}` | +| GET | `/api/context/benchmark` | `.benchmark` | `{"benchmark": ...}` | +| GET | `/api/context/capital` | `.capital` | `{"capital": ...}` | +| GET | `/api/context/universe` | `.get_universe()` | `{"universe": [...]}` | + +```bash +curl -s -H "X-Token: $TOKEN" http://127.0.0.1:10086/api/context/universe +``` + +--- + +## 3. 数据查询 `/api/data` + +除特别标明的 GET 外均为 POST。`safe_call` 失败时,部分接口返回字段为 `null`,部分返回 `{"error": "..."}` 或 500,以各条为准。 + +### 3.1 证券名称 + +- **POST** `/api/data/stock_name` → `ContextInfo.get_stock_name(stockcode)` + +| 字段 | 默认 | 说明 | +| --- | --- | --- | +| `stockcode` | `""` | 证券代码 | + +```json +{"stockcode": "600000.SH", "name": "..."} +``` + +### 3.2 上市日期 + +- **POST** `/api/data/open_date` → 全局 `get_open_date(stockcode)` + +```json +{"stockcode": "600000.SH", "open_date": "..."} +``` + +### 3.3 最新流通股本 + +- **POST** `/api/data/last_volume` → `get_last_volume` + +失败(`None`)时 **500** `获取流通股本失败`。 + +```json +{"stockcode": "600000.SH", "last_volume": ...} +``` + +### 3.4 K 线时间戳 + +- **POST** `/api/data/bar_timetag` → `get_bar_timetag(index)` + +| 字段 | 默认 | +| --- | --- | +| `index` | `-1` | + +```json +{"index": -1, "timetag": ...} +``` + +### 3.5 最新分笔时间戳 + +- **GET** `/api/data/tick_timetag` → `get_tick_timetag()` + +```json +{"timetag": ...} +``` + +### 3.6 指数成份股 + +- **POST** `/api/data/sector` → `get_sector(sector, realtime)` + +| 字段 | 必填 | 默认 | 说明 | +| --- | --- | --- | --- | +| `sector` | 是 | | 空则 400 `need args sector` | +| `realtime` | 否 | `"0"` | `"0"` 时第二参为 `0`,否则 `int(realtime)` | + +```json +{"sector": "000300.SH", "stocks": []} +``` + +`stocks` 在调用失败时为 `[]`。 + +### 3.7 行业成份股 + +- **POST** `/api/data/industry` → `get_industry(industry)` + +`industry` 为空则 400 `need args industry`。 + +```json +{"industry": "...", "stocks": []} +``` + +### 3.8 板块成份股 + +- **POST** `/api/data/stock_list_in_sector` → `get_stock_list_in_sector(sectorname)` + +`sectorname` 为空则 400 `need args sectorname`。 + +```json +{"sectorname": "沪深A股", "stocks": []} +``` + +### 3.9 指数权重 + +- **POST** `/api/data/weight_in_index` → `get_weight_in_index(indexcode, stockcode)` + +```json +{"indexcode": "000300.SH", "stockcode": "600000.SH", "weight": ...} +``` + +### 3.10 合约乘数 + +- **POST** `/api/data/contract_multiplier` → `get_contract_multiplier(contractcode)` + +```json +{"contractcode": "...", "multiplier": ...} +``` + +### 3.11 无风险利率 + +- **POST** `/api/data/risk_free_rate` → `get_risk_free_rate(index)` + +| 字段 | 默认 | +| --- | --- | +| `index` | `-1` | + +```json +{"index": -1, "risk_free_rate": ...} +``` + +### 3.12 日期对应 K 线索引 + +- **POST** `/api/data/date_location` → `get_date_location(strdate)` + +```json +{"strdate": "20240101", "location": ...} +``` + +### 3.13 历史行情(多品种字典) + +- **POST** `/api/data/history_data` → `get_history_data(len, period, field, dividend_type, skip_paused)` + +| 字段 | 默认 | 说明 | +| --- | --- | --- | +| `len` | `10` | 根数 | +| `period` | `"1d"` | 周期 | +| `field` | `"close"` | 字段 | +| `dividend_type` | `0` | 整数 | +| `skip_paused` | `"true"` | 小写等于 `"true"` 则为 Python `True` | + +成功:`{"data": ...}`。`safe_call` 得到假值时:`{"error": "获取历史数据失败"}`(仍可能是 HTTP 200)。 + +### 3.14 行情 DataFrame + +- **POST** `/api/data/market_data` → `get_market_data(fields, stocks, start, end, True, period, dividend_type, count)` + +注意第五参在封装里 **写死为 `True`**(QMT 该位置一般为 `skip_paused` 一类开关,以官方签名为准)。 + +| 字段 | 默认 | 说明 | +| --- | --- | --- | +| `fields` | `""` | 逗号分隔;空则 `[]` | +| `stock_code` | `""` | 逗号分隔代码 | +| `start_time` | `""` | | +| `end_time` | `""` | | +| `period` | `"1d"` | | +| `dividend_type` | `"none"` | | +| `count` | `-1` | | + +有 `to_dict` 则转 dict。失败 **500** `获取行情数据失败`。 + +```json +{"data": {}} +``` + +### 3.15 扩展行情(Level2) + +- **POST** `/api/data/market_data_ex` → `get_market_data_ex(...)` + +| 字段 | 默认 | +| --- | --- | +| `fields` | `""` | +| `stock_code` | `""` | +| `period` | `"follow"` | +| `start_time` | `""` | +| `end_time` | `""` | +| `count` | `-1` | +| `dividend_type` | `"follow"` | + +返回字典:每个 value 优先 `to_dict()`,否则 `str()`。失败 500 `获取扩展行情失败`。 + +### 3.16 分笔 / 全推行情 + +- **POST** `/api/data/full_tick` → `get_full_tick(code_list)` + +| 字段 | 必填 | +| --- | --- | +| `stocks` | 是,逗号分隔;空则 400 `need args stocks` | + +成功时响应体 **就是 QMT 返回对象本身**(不是 `{data: ...}` 包裹)。失败 500 `获取分笔行情失败`。 + +### 3.17 除权除息 / 复权因子 + +- **POST** `/api/data/divid_factors` + +```json +{"stockcode": "...", "factors": {}} +``` + +失败时 `factors` 为 `{}`。 + +### 3.18 期货主力合约 + +- **POST** `/api/data/main_contract` + +```json +{"codemarket": "...", "main_contract": ...} +``` + +### 3.19 毫秒时间戳转日期 + +- **POST** `/api/data/timetag_to_datetime` → 全局 `timetag_to_datetime(timetag, format)` + +| 字段 | 默认 | +| --- | --- | +| `timetag` | `0` | +| `format` | `"%Y-%m-%d %H:%M:%S"` | + +```json +{"timetag": 0, "datetime": "..."} +``` + +### 3.20 总股本 + +- **POST** `/api/data/total_share` + +```json +{"stockcode": "...", "total_share": ...} +``` + +### 3.21 交易日列表 + +- **POST** `/api/data/trading_dates` → `get_trading_dates(stockcode, start_date, end_date, count, period)` + +| 字段 | 默认 | +| --- | --- | +| `stockcode` | `""` | +| `start_date` | `""` | +| `end_date` | `""` | +| `count` | 空字符串 → 内部 `-1` | +| `period` | `"1d"` | + +```json +{"dates": []} +``` + +### 3.22 内盘 / 外盘成交量 + +- **POST** `/api/data/svol` → `get_svol` → `{"stockcode", "svol"}` +- **POST** `/api/data/bvol` → `get_bvol` → `{"stockcode", "bvol"}` + +### 3.23 龙虎榜 + +- **POST** `/api/data/longhubang` → `get_longhubang(stock_list, startTime, endTime)` + +| 字段 | 默认 | +| --- | --- | +| `stock_list` | `""` 逗号分隔 | +| `startTime` | `""` | +| `endTime` | `""` | + +成功 `{"data": ...}`(DataFrame 会 `to_dict`)。假值时 `{"error": "获取龙虎榜数据失败"}`。 + +### 3.24 十大股东 + +- **POST** `/api/data/top10_share_holder` → 全局 `get_top10_share_holder` + +| 字段 | 默认 | +| --- | --- | +| `stock_list` | `""` | +| `data_name` | `"holder"` | +| `start_time` | `""` | +| `end_time` | `""` | + +失败文案:`获取十大股东数据失败`。 + +### 3.25 期权详情 + +- **POST** `/api/data/option_detail` + +```json +{"optioncode": "...", "detail": {}} +``` + +### 3.26 换手率 + +- **POST** `/api/data/turnover_rate` → `get_turnover_rate(stock_list, startTime, endTime)` + +字段同龙虎榜风格(`stock_list` / `startTime` / `endTime`)。失败:`获取换手率失败`。 + +### 3.27 ETF 申赎清单 + +- **POST** `/api/data/etf_info` → `get_etf_info(stockcode)` + +```json +{"stockcode": "...", "info": {}} +``` + +### 3.28 ETF IOPV + +- **POST** `/api/data/etf_iopv` → `get_etf_iopv(stockcode)` + +```json +{"stockcode": "...", "iopv": ...} +``` + +### 3.29 合约详细信息 + +- **POST** `/api/data/instrumentdetail` → `get_instrumentdetail` + +```json +{"stockcode": "...", "detail": {}} +``` + +### 3.30 期货到期日 + +- **POST** `/api/data/contract_expire_date` + +```json +{"codemarket": "...", "expire_date": ...} +``` + +### 3.31 期权标的 → 期权列表 + +- **POST** `/api/data/option_undl_data` + +| 字段 | 说明 | +| --- | --- | +| `undl_code_ref` | 标的代码 | + +```json +{"data": []} +``` + +### 3.32 财务数据(两种调用约定) + +- **POST** `/api/data/financial_data` → `ContextInfo.get_financial_data` + +**约定 A**(单字段):`tabname`、`colname`、`market`、`code` **全部非空** 时调用: + +``` +get_financial_data(tabname, colname, market, code, report_type, barpos) +``` + +| 字段 | 默认 | +| --- | --- | +| `report_type` | `"report_time"` | +| `barpos` | `-1` | + +**约定 B**(否则走批量): + +``` +get_financial_data(fieldList, stockList, startDate, endDate, report_type) +``` + +| 字段 | 默认 | 说明 | +| --- | --- | --- | +| `fieldList` | `""` | 逗号分隔 | +| `stockList` | `""` | 逗号分隔 | +| `startDate` | `""` | | +| `endDate` | `""` | | +| `report_type` | `"announce_time"` | 注意与约定 A 默认值不同 | + +`ret is None` 时:`{"error": "获取财务数据失败"}`;否则 `{"data": ...}`。 + +### 3.33 多因子数据 + +- **POST** `/api/data/factor_data` → `get_factor_data` + +| 字段 | 说明 | +| --- | --- | +| `fieldList` | 逗号分隔字段 | +| `stockCode` | 若非空:按 **单个代码** 调用 | +| `stockList` | 否则按代码列表调用 | +| `startDate` / `endDate` | 区间 | + +失败:`{"error": "获取因子数据失败"}`。 + +### 3.34 历史 ST + +- **POST** `/api/data/his_st_data` + +```json +{"stockCode": "...", "data": {}} +``` + +注意请求字段是 **`stockCode`**(驼峰),与多数接口的 `stockcode` 不同。 + +### 3.35 历史指数 + +- **POST** `/api/data/his_index_data` + +```json +{"index": "...", "data": {}} +``` + +### 3.36 当前全部行情订阅 + +- **GET** `/api/data/all_subscription` → `get_all_subscription()` + +```json +{"subscriptions": {}} +``` + +### 3.37 指定期权列表 + +- **POST** `/api/data/option_list` + +| 字段 | 默认 | +| --- | --- | +| `undl_code` | `""` | +| `dedate` | `""` | +| `opttype` | `""` | +| `isavailable` | `"true"`(小写 `"true"` 为 True) | + +```json +{"option_list": []} +``` + +### 3.38 过期合约列表 + +- **POST** `/api/data/his_contract_list` + +```json +{"market": "...", "contracts": []} +``` + +### 3.39 期权隐含波动率(实时) + +- **POST** `/api/data/option_iv` + +```json +{"optioncode": "...", "iv": ...} +``` + +### 3.40 BS 理论价格 + +- **POST** `/api/data/bsm_price` → `bsm_price(optionType, objectPrices, strikePrice, riskFree, sigma, days, dividend)` + +| 字段 | 默认 | 说明 | +| --- | --- | --- | +| `optionType` | `"C"` | | +| `objectPrices` | `""` | 能 `float()` 则标量;否则按逗号拆成 float 列表 | +| `strikePrice` | `0` | | +| `riskFree` | `0` | | +| `sigma` | `0` | | +| `days` | `0` | | +| `dividend` | `0` | | + +```json +{"price": ...} +``` + +### 3.41 BS 隐含波动率 + +- **POST** `/api/data/bsm_iv` + +| 字段 | 默认 | +| --- | --- | +| `optionType` | `"C"` | +| `objectPrices` | `0`(float,与 bsm_price 不同) | +| `strikePrice` | `0` | +| `optionPrice` | `0` | +| `riskFree` | `0` | +| `days` | `0` | +| `dividend` | `0` | + +```json +{"iv": ...} +``` + +### 3.42 本地行情 + +- **POST** `/api/data/local_data` → `get_local_data(stock_code, start_time, end_time, period, divid_type, count)` + +| 字段 | 默认 | +| --- | --- | +| `stock_code` | `""` | +| `start_time` | `""` | +| `end_time` | `""` | +| `period` | `"1d"` | +| `divid_type` | `"none"` | +| `count` | `-1` | + +失败 500 `获取本地行情失败`。成功 `{"data": ...}`。 + +### 3.43 订阅行情 + +- **POST** `/api/data/subscribe_quote` → `subscribe_quote(stock_code, period, dividend_type)` + +| 字段 | 默认 | +| --- | --- | +| `stock_code` | `""` | +| `period` | `"follow"` | +| `dividend_type` | `"follow"` | + +```json +{"status": "success" | "failed", "sub_id": ...} +``` + +`status` 取决于返回值是否为 `None`。 + +### 3.44 反订阅 + +- **POST** `/api/data/unsubscribe_quote` → `unsubscribe_quote(sub_id)` + +| 字段 | 默认 | +| --- | --- | +| `sub_id` | `0` | + +无论底层是否成功,都返回: + +```json +{"status": "success", "sub_id": 0} +``` + +--- + +## 4. 判定 `/api/check` + +### 4.1 是否最后一根 K 线 + +- **GET** `/api/check/is_last_bar` → `is_last_bar()` + +```json +{"is_last_bar": ...} +``` + +### 4.2 是否新 K 线 + +- **GET** `/api/check/is_new_bar` + +```json +{"is_new_bar": ...} +``` + +### 4.3 是否停牌 + +- **POST** `/api/check/is_suspended_stock` + +```json +{"stockcode": "...", "is_suspended": ...} +``` + +### 4.4 是否在指定板块 + +- **POST** `/api/check/is_sector_stock` → 全局 `is_sector_stock(sectorname, market, stockcode)` + +```json +{"sectorname": "...", "stockcode": "...", "is_in_sector": ...} +``` + +响应未带回 `market`。 + +### 4.5 是否属于某类别 + +- **POST** `/api/check/is_typed_stock` → `is_typed_stock(stocktypenum, market, stockcode)` + +| 字段 | 默认 | +| --- | --- | +| `stocktypenum` | `0` | +| `market` | `""` | +| `stockcode` | `""` | + +```json +{"stocktypenum": 0, "stockcode": "...", "result": ...} +``` + +### 4.6 行业分类名称 + +- **POST** `/api/check/get_industry_name_of_stock` → `get_industry_name_of_stock(industryType, stockcode)` + +```json +{"industryType": "...", "stockcode": "...", "industry_name": ...} +``` + +--- + +## 5. 交易 `/api/trade` + +下单类在 `try/except` 中捕获异常后 **400**,并 `logger.exception`。 +`style` 类接口默认 `"LATEST"`,`accId` 默认当前 `ACCOUNT_ID`。 + +价格/数量等枚举含义以迅投 `passorder` 官方文档为准。本封装实际传入值如下。 + +### 5.1 综合下单 passorder + +- **POST** `/api/trade/passorder` + +调用: + +``` +passorder(opType, orderType, acc, stock, prType, price, volume, 'qmt', quickTrade, ctx) +``` + +策略名第三段写死为 `'qmt'`。 + +| 字段 | 必填 | 默认 | 说明 | +| --- | --- | --- | --- | +| `opType` | 是 | | 操作类型;兼容层买=23、卖=24 | +| `stock` | 是 | | | +| `price` | 是 | | | +| `volume` | 是 | | | +| `orderType` | 否 | `1101` | | +| `prType` | 否 | `11` | JSON 字段名为 `prType` | +| `quickTrade` | 否 | `2` | | + +**响应** + +```json +{"status": "success", "opType": 23, "stock": "600000.SH", "order_ref": "..."} +``` + +### 5.2 算法下单 + +- **POST** `/api/trade/algo_passorder` → `algo_passorder(...)` + +| 字段 | 必填 | 默认 | +| --- | --- | --- | +| `opType` | 是 | | +| `stock` | 是 | | +| `price` | 是 | | +| `volume` | 是 | | +| `orderType` | 否 | `1101` | +| `prType` | 否 | `-1`(与 passorder 默认 11 不同) | +| `strategyName` | 否 | `""` | +| `quickTrade` | 否 | `2` | +| `userOrderId` | 否 | `""` | +| `userOrderParam` | 否 | `{}` | + +```json +{"status": "success", "order_ref": "..."} +``` + +### 5.3 智能算法下单 + +- **POST** `/api/trade/smart_algo_passorder` + +| 字段 | 必填 | 默认 | +| --- | --- | --- | +| `opType` / `stock` / `price` / `volume` | 是 | | +| `smartAlgoType` | 是 | | +| `orderType` | 否 | `1101` | +| `prType` | 否 | `-1` | +| `limitOverRate` | 否 | `0` | +| `minAmountPerOrder` | 否 | `0` | +| `startTime` | 否 | `""` | +| `endTime` | 否 | `""` | + +### 5.4 指定手数 / 价值 / 比例 / 目标 / 股数 + +下列接口模式相同:成功返回 `{"status":"success","action":"<函数名>","stock":"..."}`。 + +| 路径 | QMT | 关键字段 | 其它默认 | +| --- | --- | --- | --- | +| POST `/api/trade/order_lots` | `order_lots` | `lots` int | `style=LATEST`, `price=0`, `accId` | +| POST `/api/trade/order_value` | `order_value` | `value` float | 同上 | +| POST `/api/trade/order_percent` | `order_percent` | `percent` float | 同上 | +| POST `/api/trade/order_target_value` | `order_target_value` | `tar_value` float | 同上 | +| POST `/api/trade/order_target_percent` | `order_target_percent` | `tar_percent` float | 同上 | +| POST `/api/trade/order_shares` | `order_shares` | `shares` int | 同上 | + +均需 `stock`。 + +```bash +curl -s -X POST -H "X-Token: $TOKEN" -H "Content-Type: application/json" \ + -d "{\"stock\":\"600000.SH\",\"shares\":100,\"style\":\"LATEST\",\"price\":0}" \ + http://127.0.0.1:10086/api/trade/order_shares +``` + +### 5.5 期货开平仓 + +均需 `stock`、`amount`(手数,int)。可选 `style`、`price`、`accId`。 + +| 路径 | QMT 函数 | `action` | +| --- | --- | --- | +| POST `/api/trade/futures/buy_open` | `buy_open` | `buy_open` | +| POST `/api/trade/futures/buy_close_tdayfirst` | `buy_close_tdayfirst` | `buy_close_tdayfirst` | +| POST `/api/trade/futures/buy_close_ydayfirst` | `buy_close_ydayfirst` | `buy_close_ydayfirst` | +| POST `/api/trade/futures/sell_open` | `sell_open` | `sell_open` | +| POST `/api/trade/futures/sell_close_tdayfirst` | `sell_close_tdayfirst` | `sell_close_tdayfirst` | +| POST `/api/trade/futures/sell_close_ydayfirst` | `sell_close_ydayfirst` | `sell_close_ydayfirst` | + +成功体例: + +```json +{"status": "success", "action": "buy_open", "stock": "IF2509.IF"} +``` + +### 5.6 任务:撤销 / 暂停 / 继续 + +| 路径 | QMT | +| --- | --- | +| POST `/api/trade/cancel_task` | `cancel_task(taskId, acc, accountType, ctx)` | +| POST `/api/trade/pause_task` | `pause_task` | +| POST `/api/trade/resume_task` | `resume_task` | + +| 字段 | 必填 | 默认 | +| --- | --- | --- | +| `taskId` | 是 | | +| `accountType` | 否 | `"stock"` | + +```json +{"status": "success" | "failed", "taskId": "..."} +``` + +`status` 由 QMT 返回值的真假决定。 + +### 5.7 触发前一根 bar 信号 + +- **POST** `/api/trade/do_order` → `do_order(ctx)` + +无需解析 Body(空 Body 也可)。 + +```json +{"status": "success", "message": "信号已触发"} +``` + +### 5.8 交易明细(原始对象属性) + +- **POST** `/api/trade/trade_detail_data` → `get_trade_detail_data(acc, account, datatype, 'qmt')` + +| 字段 | 默认 | 说明 | +| --- | --- | --- | +| `account` | `"stock"` | 账户类型 | +| `datatype` | `"position"` | 如 `position` / `order` / `deal` / `account`(以 QMT 为准) | + +每个元素展开为「非 `_` 开头且不可调用」的属性,值 `str()`。 + +```json +{"data": [{ "...": "..." }]} +``` + +调用失败时 `data` 为 `[]`。 + +### 5.9 按委托号取委托/成交 + +- **POST** `/api/trade/value_by_order_id` → `get_value_by_order_id(orderId, acc, accountType, datatype)` + +| 字段 | 默认 | +| --- | --- | +| `orderId` | `""` | +| `accountType` | `"stock"` | +| `datatype` | `"ORDER"` | + +```json +{"orderId": "...", "data": {}} +``` + +### 5.10 最新委托号 + +- **POST** `/api/trade/last_order_id` → `get_last_order_id(acc, account, datatype, 'qmt')` + +| 字段 | 默认 | +| --- | --- | +| `account` | `"stock"` | +| `datatype` | `"ORDER"` | + +```json +{"last_order_id": ...} +``` + +### 5.11 委托是否可撤 + +- **POST** `/api/trade/can_cancel_order` + +```json +{"orderId": "...", "can_cancel": ...} +``` + +`accountType` 默认 `"stock"`。 + +### 5.12 两融:负债 / 担保 / 可融券 + +均 POST,可选 `accId`(默认当前账号)。返回对象列表,属性展开方式同 5.8。 + +| 路径 | QMT | 响应 | +| --- | --- | --- | +| `/api/trade/debt_contract` | `get_debt_contract(accId)` | `{"data": [...]}` | +| `/api/trade/assure_contract` | `get_assure_contract` | 同上 | +| `/api/trade/enable_short_contract` | `get_enable_short_contract` | 同上 | + +### 5.13 当日新股新债 + +- **POST** `/api/trade/ipo_data` → `get_ipo_data(type)` + +| 字段 | 默认 | +| --- | --- | +| `type` | `""` | + +```json +{"data": {}} +``` + +### 5.14 新股申购额度 + +- **POST** `/api/trade/new_purchase_limit` → `get_new_purchase_limit(accid)` + +| 字段 | 默认 | +| --- | --- | +| `accid` | 当前账号 | + +```json +{"data": {}} +``` + +--- + +## 6. 扩展引用 `/api/ext` + +四个接口结构相同:`name` + `stockcode` + `deviation`(默认 0),并传入 `ContextInfo`。 + +| 路径 | QMT | 响应对 | +| --- | --- | --- | +| POST `/api/ext/ext_data` | `ext_data(extdataname, stockcode, deviation, ctx)` | `extdataname` + `value` | +| POST `/api/ext/ext_data_rank` | `ext_data_rank` | `rank` | +| POST `/api/ext/get_factor_value` | `get_factor_value(factorname, ...)` | `factorname` + `value` | +| POST `/api/ext/get_factor_rank` | `get_factor_rank` | `rank` | + +示例: + +```json +{"factorname": "...", "stockcode": "600000.SH", "value": ...} +``` + +--- + +## 7. 系统 `/api/sys` + +这两支同样需要 `X-Token`。 + +### 7.1 Python 版本 + +- **GET** `/api/sys/python_version` + +```json +{ + "python_version": "...", + "python_version_info": { + "major": 3, + "minor": 0, + "micro": 0, + "releaselevel": "final", + "serial": 0 + } +} +``` + +用于确认 QMT 内嵌解释器版本。 + +### 7.2 关闭 HTTP 服务 + +- **POST** `/api/sys/shutdown` + +先写入响应再 `finish()`,然后 `IOLoop.stop()`。策略进程内的 HTTP 循环结束;是否退出整个 QMT 策略取决于宿主行为。 + +```json +{"status": "success", "message": "服务器正在关闭..."} +``` + +```bash +curl -s -X POST -H "X-Token: $TOKEN" http://127.0.0.1:10086/api/sys/shutdown +``` + +--- + +## 8. 快速对照表 + +| 方法 | 路径 | +| --- | --- | +| POST | `/api/v2/positions` | +| POST | `/api/v2/assets` | +| POST | `/api/holding` | +| POST | `/api/money/total` | +| POST | `/api/money/available` | +| POST | `/api/order/buy` | +| POST | `/api/order/sell` | +| POST | `/api/order/status` | +| POST | `/api/order/cancel_all` | +| POST | `/api/order/cancel_order` | +| POST | `/api/order/deal` | +| GET | `/api/context/period` | +| GET | `/api/context/barpos` | +| GET | `/api/context/time_tick_size` | +| GET | `/api/context/stockcode` | +| GET | `/api/context/dividend_type` | +| GET | `/api/context/market` | +| GET | `/api/context/do_back_test` | +| GET | `/api/context/benchmark` | +| GET | `/api/context/capital` | +| GET | `/api/context/universe` | +| POST | `/api/data/stock_name` | +| POST | `/api/data/open_date` | +| POST | `/api/data/last_volume` | +| POST | `/api/data/bar_timetag` | +| GET | `/api/data/tick_timetag` | +| POST | `/api/data/sector` | +| POST | `/api/data/industry` | +| POST | `/api/data/stock_list_in_sector` | +| POST | `/api/data/weight_in_index` | +| POST | `/api/data/contract_multiplier` | +| POST | `/api/data/risk_free_rate` | +| POST | `/api/data/date_location` | +| POST | `/api/data/history_data` | +| POST | `/api/data/market_data` | +| POST | `/api/data/market_data_ex` | +| POST | `/api/data/full_tick` | +| POST | `/api/data/divid_factors` | +| POST | `/api/data/main_contract` | +| POST | `/api/data/timetag_to_datetime` | +| POST | `/api/data/total_share` | +| POST | `/api/data/trading_dates` | +| POST | `/api/data/svol` | +| POST | `/api/data/bvol` | +| POST | `/api/data/longhubang` | +| POST | `/api/data/top10_share_holder` | +| POST | `/api/data/option_detail` | +| POST | `/api/data/turnover_rate` | +| POST | `/api/data/etf_info` | +| POST | `/api/data/etf_iopv` | +| POST | `/api/data/instrumentdetail` | +| POST | `/api/data/contract_expire_date` | +| POST | `/api/data/option_undl_data` | +| POST | `/api/data/financial_data` | +| POST | `/api/data/factor_data` | +| POST | `/api/data/his_st_data` | +| POST | `/api/data/his_index_data` | +| GET | `/api/data/all_subscription` | +| POST | `/api/data/option_list` | +| POST | `/api/data/his_contract_list` | +| POST | `/api/data/option_iv` | +| POST | `/api/data/bsm_price` | +| POST | `/api/data/bsm_iv` | +| POST | `/api/data/local_data` | +| POST | `/api/data/subscribe_quote` | +| POST | `/api/data/unsubscribe_quote` | +| GET | `/api/check/is_last_bar` | +| GET | `/api/check/is_new_bar` | +| POST | `/api/check/is_suspended_stock` | +| POST | `/api/check/is_sector_stock` | +| POST | `/api/check/is_typed_stock` | +| POST | `/api/check/get_industry_name_of_stock` | +| POST | `/api/trade/passorder` | +| POST | `/api/trade/algo_passorder` | +| POST | `/api/trade/smart_algo_passorder` | +| POST | `/api/trade/order_lots` | +| POST | `/api/trade/order_value` | +| POST | `/api/trade/order_percent` | +| POST | `/api/trade/order_target_value` | +| POST | `/api/trade/order_target_percent` | +| POST | `/api/trade/order_shares` | +| POST | `/api/trade/futures/buy_open` | +| POST | `/api/trade/futures/buy_close_tdayfirst` | +| POST | `/api/trade/futures/buy_close_ydayfirst` | +| POST | `/api/trade/futures/sell_open` | +| POST | `/api/trade/futures/sell_close_tdayfirst` | +| POST | `/api/trade/futures/sell_close_ydayfirst` | +| POST | `/api/trade/cancel_task` | +| POST | `/api/trade/pause_task` | +| POST | `/api/trade/resume_task` | +| POST | `/api/trade/do_order` | +| POST | `/api/trade/trade_detail_data` | +| POST | `/api/trade/value_by_order_id` | +| POST | `/api/trade/last_order_id` | +| POST | `/api/trade/can_cancel_order` | +| POST | `/api/trade/debt_contract` | +| POST | `/api/trade/assure_contract` | +| POST | `/api/trade/enable_short_contract` | +| POST | `/api/trade/ipo_data` | +| POST | `/api/trade/new_purchase_limit` | +| POST | `/api/ext/ext_data` | +| POST | `/api/ext/ext_data_rank` | +| POST | `/api/ext/get_factor_value` | +| POST | `/api/ext/get_factor_rank` | +| GET | `/api/sys/python_version` | +| POST | `/api/sys/shutdown` | + +合计 **104** 条路由(`/api/holding` 与 `/api/v2/positions`、`/api/v2/assets` 与资金类为不同路径、部分共用 Handler)。 diff --git a/go-client/apps/cmd/main.go b/go-client/apps/cmd/main.go new file mode 100644 index 0000000..f6c29a5 --- /dev/null +++ b/go-client/apps/cmd/main.go @@ -0,0 +1,117 @@ +package main + +import ( + "context" + "fmt" + "os" + "sort" + "strings" + "time" + + "big-qmt/go-client/sdk" +) + +var ( + BaseURL = "http://127.0.0.1:10086" + Token = "QMTbyYanweidong" + AccountType = "stock" + PassCodes = []string{} + Timeout = 15 * time.Second +) + +func main() { + client := sdk.New(BaseURL, Token, AccountType, Timeout) + ctx, cancel := context.WithTimeout(context.Background(), Timeout) + defer cancel() + + assets, err := client.Assets(ctx, AccountType) + if err != nil { + fatal("获取资产失败: %v", err) + } + positions, err := client.Positions(ctx, AccountType) + 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, + ) + } + + printTicks(client, Timeout, PassCodes) +} + +func printTicks(client *sdk.Client, timeout time.Duration, codes []string) { + fmt.Println(strings.Repeat("-", 80)) + 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 splitCSV(s string) []string { + parts := strings.Split(s, ",") + out := make([]string, 0, len(parts)) + for _, p := range parts { + p = strings.TrimSpace(p) + if p != "" { + out = append(out, p) + } + } + return out +} + +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 envOr(key, fallback string) string { + if v := strings.TrimSpace(os.Getenv(key)); v != "" { + return v + } + return fallback +} + +func fatal(format string, args ...any) { + fmt.Fprintf(os.Stderr, format+"\n", args...) + os.Exit(1) +} diff --git a/go-client/apps/zt/boot.go b/go-client/apps/zt/boot.go new file mode 100644 index 0000000..6d09b17 --- /dev/null +++ b/go-client/apps/zt/boot.go @@ -0,0 +1,70 @@ +package main + +import ( + "context" + "fmt" + "strings" + "time" + + "big-qmt/go-client/sdk" +) + +func overview(cfg Config, 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 open_money: %.0f\n", cfg.AccountID, cfg.HostKey, cfg.OpenMoney) + 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 := normalizeCode(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 runRound(ctx context.Context, client *sdk.Client, books *orderBook, cfg Config, assets *sdk.Assets, ticks map[string]sdk.Tick, positions []sdk.Position) { + signals := fetchSignal(cfg, "dcm_signal") + books.cancelExpired(ctx, client, cfg) + + hold := positionCodes(positions) + openSignals := map[string]map[string]any{} + for code, signal := range signals { + norm := normalizeCode(code, "") + if norm == "" { + norm = code + } + if _, held := hold[norm]; held { + continue + } + openSignals[norm] = signal + } + if len(openSignals) > 0 { + if books.refresh(ctx, client, cfg) { + buys, _, ok := books.activeSets(ctx, client, cfg) + if ok { + filtered := map[string]map[string]any{} + for code, signal := range openSignals { + if _, buying := buys[code]; buying { + continue + } + filtered[code] = signal + } + openSignals = filtered + } + } + } + marketOK := marketAllowOpen(cfg) + if len(openSignals) > 0 { + openSignal(ctx, client, books, cfg, assets, ticks, openSignals, marketOK) + } + managePositions(ctx, client, books, cfg, ticks, positions, marketOK) +} diff --git a/go-client/apps/zt/config.go b/go-client/apps/zt/config.go new file mode 100644 index 0000000..f316c93 --- /dev/null +++ b/go-client/apps/zt/config.go @@ -0,0 +1,118 @@ +package main + +import ( + "os" + "strconv" + "strings" + "time" +) + +type Config struct { + QMTBaseURL string + QMTToken string + AccountType string + AccountID string + HostKey string + APIHost string + DataDir string + HTTPTimeout time.Duration + OrderTimeout time.Duration + LoopInterval time.Duration + OpenMoney float64 + MinCashRatio float64 + LossTriggerPct float64 + GridStepPct float64 + MinProfitPct float64 + AdoptExisting bool + ReadyCacheStart int + WatchTimeout time.Duration + ReboundThreshold float64 +} + +func loadConfig() Config { + cfg := Config{ + QMTBaseURL: env("QMT_BASE_URL", "http://127.0.0.1:10086"), + QMTToken: env("QMT_TOKEN", "QMTbyYanweidong"), + AccountType: env("QMT_ACCOUNT", "stock"), + AccountID: env("ACCOUNT_ID", ""), + HostKey: env("HOST_KEY", ""), + APIHost: strings.TrimRight(env("API_HOST", "http://139.224.247.176:13499"), "/"), + DataDir: env("DATA_DIR", "D:/qmt_strategy_state"), + HTTPTimeout: durationEnv("HTTP_TIMEOUT_SEC", 5) * time.Second, + OrderTimeout: durationEnv("ORDER_TIMEOUT_SEC", 60) * time.Second, + LoopInterval: durationEnv("LOOP_INTERVAL_SEC", 30) * time.Second, + OpenMoney: floatEnv("OPEN_MONEY", 5000), + MinCashRatio: floatEnv("MIN_CASH_RATIO", 0.1), + LossTriggerPct: floatEnv("LOSS_TRIGGER_PCT", -30), + GridStepPct: floatEnv("GRID_STEP_PCT", 1), + MinProfitPct: floatEnv("MIN_PROFIT_PCT", 2), + AdoptExisting: boolEnv("ADOPT_EXISTING_POSITIONS", true), + ReadyCacheStart: intEnv("READY_CACHE_START", 925), + WatchTimeout: durationEnv("WATCH_TIMEOUT_SEC", 300) * time.Second, + ReboundThreshold: floatEnv("REBOUND_THRESHOLD", 0.61), + } + if strings.TrimSpace(cfg.AccountID) == "" { + logf("ERROR", "ACCOUNT_ID 为空") + os.Exit(1) + } + if strings.TrimSpace(cfg.HostKey) == "" { + logf("ERROR", "HOST_KEY 为空") + os.Exit(1) + } + if cfg.MinCashRatio < 0 || cfg.MinCashRatio >= 1 { + logf("ERROR", "MIN_CASH_RATIO 必须在 [0, 1)") + os.Exit(1) + } + if cfg.OpenMoney <= 0 { + logf("ERROR", "OPEN_MONEY 必须大于 0") + os.Exit(1) + } + if err := os.MkdirAll(cfg.DataDir, 0o755); err != nil { + logf("ERROR", "创建 DATA_DIR 失败: %v", err) + os.Exit(1) + } + return cfg +} + +func env(key, fallback string) string { + if v := strings.TrimSpace(os.Getenv(key)); v != "" { + return v + } + return fallback +} + +func intEnv(key string, fallback int) int { + v := strings.TrimSpace(os.Getenv(key)) + if v == "" { + return fallback + } + n, err := strconv.Atoi(v) + if err != nil { + return fallback + } + return n +} + +func floatEnv(key string, fallback float64) float64 { + v := strings.TrimSpace(os.Getenv(key)) + if v == "" { + return fallback + } + f, err := strconv.ParseFloat(v, 64) + if err != nil { + return fallback + } + return f +} + +func durationEnv(key string, fallbackSec int) time.Duration { + return time.Duration(intEnv(key, fallbackSec)) +} + +func boolEnv(key string, fallback bool) bool { + v := strings.ToLower(strings.TrimSpace(os.Getenv(key))) + if v == "" { + return fallback + } + return v == "1" || v == "true" || v == "yes" +} diff --git a/go-client/apps/zt/log.go b/go-client/apps/zt/log.go new file mode 100644 index 0000000..dc0a2b9 --- /dev/null +++ b/go-client/apps/zt/log.go @@ -0,0 +1,10 @@ +package main + +import ( + "fmt" + "log" +) + +func logf(level, format string, args ...any) { + log.Printf("[%s] %s", level, fmt.Sprintf(format, args...)) +} diff --git a/go-client/apps/zt/main.go b/go-client/apps/zt/main.go new file mode 100644 index 0000000..016932c --- /dev/null +++ b/go-client/apps/zt/main.go @@ -0,0 +1,105 @@ +package main + +import ( + "context" + "log" + "os" + "os/signal" + "strings" + "syscall" + "time" + + "big-qmt/go-client/sdk" +) + +func main() { + log.SetFlags(log.LstdFlags | log.Lmicroseconds) + cfg := loadConfig() + client := sdk.New(cfg.QMTBaseURL, cfg.QMTToken, cfg.AccountType, cfg.HTTPTimeout) + books := newOrderBook() + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + startup := context.Background() + assets, err := client.Assets(startup, cfg.AccountType) + if err != nil { + logf("ERROR", "启动获取资产失败: %v", err) + } + positions, err := client.Positions(startup, cfg.AccountType) + if err != nil { + logf("ERROR", "启动获取持仓失败: %v", err) + positions = []sdk.Position{} + } + overview(cfg, assets, positions) + logf("INFO", "[ZT] host_key=%s interval=%s", cfg.HostKey, cfg.LoopInterval) + logf("INFO", "[ZT] Init Success, waiting trading session") + + ticker := time.NewTicker(cfg.LoopInterval) + defer ticker.Stop() + runOnce(ctx, client, books, cfg) + for { + select { + case <-ctx.Done(): + logf("INFO", "[ZT] 停止") + return + case <-ticker.C: + runOnce(ctx, client, books, cfg) + } + } +} + +func runOnce(ctx context.Context, client *sdk.Client, books *orderBook, cfg Config) { + if !tradingTime(time.Now()) { + return + } + roundCtx, cancel := context.WithTimeout(ctx, cfg.HTTPTimeout*4) + defer cancel() + + assets, err := client.Assets(roundCtx, cfg.AccountType) + if err != nil { + logf("ERROR", "获取资产失败: %v", err) + return + } + positions, err := client.Positions(roundCtx, cfg.AccountType) + if err != nil { + logf("ERROR", "获取持仓失败: %v", err) + return + } + codes := passCodes(cfg) + seen := map[string]struct{}{} + stockList := make([]string, 0, len(codes)+len(positions)) + addCode := func(code string) { + n := normalizeCode(code, "") + if n == "" { + n = strings.ToUpper(strings.TrimSpace(code)) + } + if n == "" { + return + } + if _, ok := seen[n]; ok { + return + } + seen[n] = struct{}{} + stockList = append(stockList, n) + } + for _, code := range codes { + addCode(code) + } + for _, p := range positions { + addCode(p.StockCode) + } + ticks := map[string]sdk.Tick{} + if len(stockList) > 0 { + raw, err := client.FullTick(roundCtx, stockList) + if err != nil { + logf("ERROR", "获取行情失败: %v", err) + return + } + for code, tick := range raw { + ticks[normalizeCode(code, "")] = tick + ticks[code] = tick + } + } + runRound(roundCtx, client, books, cfg, assets, ticks, positions) +} diff --git a/go-client/apps/zt/open.go b/go-client/apps/zt/open.go new file mode 100644 index 0000000..b4ca305 --- /dev/null +++ b/go-client/apps/zt/open.go @@ -0,0 +1,114 @@ +package main + +import ( + "context" + "math" + "sync" + "time" + + "big-qmt/go-client/sdk" +) + +type dipWatch struct { + LastClose float64 + ExpiresAt time.Time +} + +var openDip = struct { + mu sync.Mutex + store map[string]dipWatch +}{store: map[string]dipWatch{}} + +func openSignal(ctx context.Context, client *sdk.Client, books *orderBook, cfg Config, assets *sdk.Assets, ticks map[string]sdk.Tick, openSignals map[string]map[string]any, marketOK bool) { + if !marketOK { + return + } + if assets == nil { + return + } + if assets.Available < assets.Total*cfg.MinCashRatio { + return + } + state := getState(cfg) + if state.LoadError != "" { + logf("ERROR", "[ZT][开仓] 状态文件异常,禁止新开仓: %s", state.LoadError) + return + } + for signalCode, signal := range openSignals { + code := normalizeCode(signalCode, "") + if code == "" { + if c, ok := signal["code"].(string); ok { + code = normalizeCode(c, "") + } + } + if code == "" { + logf("ERROR", "[ZT][开仓] 无效股票代码=%s", signalCode) + continue + } + if state.Get(code) != nil { + continue + } + price := ticks[code].LastPrice + if price <= 0 { + continue + } + if !dipTriggered(&openDip.mu, openDip.store, cfg, "开仓", code, price) { + continue + } + volume := calcOpenVolume(price, cfg.OpenMoney) + if volume <= 0 { + continue + } + if !books.place(ctx, client, cfg, "buy", code, volume, newOrderTag("base")) { + continue + } + state.Ensure(code).Pending = "base_opening" + state.Save() + logf("INFO", "[ZT][开仓] %s 买入 %d 股", code, volume) + } + state.Save() +} + +func calcOpenVolume(price, openMoney float64) int { + if price <= 0 || openMoney <= 0 { + return 0 + } + hands := int(math.Floor(openMoney / (price * 100))) + if hands == 0 { + hands = 1 + } + return hands * 100 +} + +func dipTriggered(mu *sync.Mutex, store map[string]dipWatch, cfg Config, tag, code string, price float64) bool { + if price <= 0 { + return false + } + mu.Lock() + defer mu.Unlock() + now := time.Now() + watch, ok := store[code] + if !ok || now.After(watch.ExpiresAt) || now.Equal(watch.ExpiresAt) { + store[code] = dipWatch{LastClose: price, ExpiresAt: now.Add(cfg.WatchTimeout)} + logf("INFO", "[%s-观察] %s 现价=%.2f", tag, code, price) + return false + } + if price < watch.LastClose { + watch.LastClose = price + watch.ExpiresAt = now.Add(cfg.WatchTimeout) + store[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 < cfg.ReboundThreshold { + logf("INFO", "[%s-等待] %s 反弹=%.2f%% 阈值=%.2f%%", tag, code, rebound, cfg.ReboundThreshold) + return false + } + delete(store, code) + logf("INFO", "[%s-触发] %s 反弹=%.2f%% 低点=%.2f", tag, code, rebound, watch.LastClose) + return true +} diff --git a/go-client/apps/zt/order.go b/go-client/apps/zt/order.go new file mode 100644 index 0000000..e34ef3d --- /dev/null +++ b/go-client/apps/zt/order.go @@ -0,0 +1,374 @@ +package main + +import ( + "context" + "crypto/rand" + "encoding/hex" + "fmt" + "strconv" + "strings" + "sync" + "time" + + "big-qmt/go-client/sdk" +) + +const ( + opBuyStock = 23 + opBuyAlt = 48 +) + +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 + Volume int + At time.Time + Tag string +} + +type orderBook struct { + mu sync.Mutex + cached []parsedOrder + hasCache bool + buyLocks map[string]time.Time + sellLocks map[string]time.Time + subs []submission +} + +func newOrderBook() *orderBook { + return &orderBook{ + buyLocks: map[string]time.Time{}, + sellLocks: map[string]time.Time{}, + } +} + +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, cfg Config) ([]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, cfg.AccountType, "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) refresh(ctx context.Context, client *sdk.Client, cfg Config) bool { + o.invalidate() + _, err := o.query(ctx, client, cfg) + return err == nil +} + +func (o *orderBook) activeSets(ctx context.Context, client *sdk.Client, cfg Config) (buys, sells map[string]struct{}, ok bool) { + orders, err := o.query(ctx, client, cfg) + 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 == "buy" { + buys[item.StockCode] = struct{}{} + } else { + sells[item.StockCode] = struct{}{} + } + } + return buys, sells, true +} + +func (o *orderBook) cancelExpired(ctx context.Context, client *sdk.Client, cfg Config) bool { + o.invalidate() + orders, err := o.query(ctx, client, cfg) + if err != nil { + return false + } + state := getState(cfg) + now := time.Now() + timeout := cfg.OrderTimeout + seen := map[string]struct{}{} + 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, cfg.AccountType) + 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, cfg.AccountType) + 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) + logf("INFO", "[ZT][委托] 撤销超时单 %s %s %s volume=%d", order.OrderID, order.StockCode, order.Side, vol) + } + return true +} + +func (o *orderBook) claimed(state *ZTState, 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 := state.Get(order.StockCode) + if item == nil || item.Pending == "" { + return false + } + switch item.Pending { + case "base_opening", "add": + return order.Side == "buy" + case "sell_add", "sell_base": + return order.Side == "sell" + default: + return false + } +} + +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(cfg Config, code, side string, active map[string]struct{}) bool { + if _, ok := active[code]; ok { + return true + } + return o.locked(cfg, code, side) +} + +func (o *orderBook) locked(cfg Config, code, side string) bool { + o.mu.Lock() + defer o.mu.Unlock() + ts, ok := o.locks(side)[code] + return ok && time.Since(ts) < cfg.OrderTimeout +} + +func (o *orderBook) locks(side string) map[string]time.Time { + if side == "buy" { + return o.buyLocks + } + return o.sellLocks +} + +func (o *orderBook) hasActive(ctx context.Context, client *sdk.Client, cfg Config, code, side string) bool { + orders, err := o.query(ctx, client, cfg) + 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, cfg Config, 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(cfg, code, side) { + logf("INFO", "[ZT][委托] %s %s锁定中", code, side) + return false + } + if o.hasActive(ctx, client, cfg, code, side) { + logf("INFO", "[ZT][委托] %s 已有%s在途委托", code, side) + return false + } + _, err := client.PassorderLatest(ctx, side == "buy", code, volume) + 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, Volume: volume, At: time.Now(), Tag: tag}) + 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 := asIntS(mapGet(item, "m_nOffsetFlag", "m_nOrderType", "order_type")) + status := asIntS(mapGet(item, "m_nOrderStatus", "order_status", "status")) + tag := mapGet(item, "m_strRemark", "m_strUserOrderId", "order_remark") + orderTime := int64(asIntS(mapGet(item, "m_nOrderTime", "order_time"))) + if orderTime > 1e11 { + orderTime /= 1000 + } + if orderTime <= 0 { + date := mapGet(item, "m_strInsertDate") + clock := strings.ReplaceAll(mapGet(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 := "sell" + if operation == opBuyStock || operation == opBuyAlt { + side = "buy" + } + left := asIntS(mapGet(item, "m_nVolumeTotal", "volume_left")) + traded := asIntS(mapGet(item, "m_nVolumeTraded", "volume_traded")) + orig := asIntS(mapGet(item, "m_nVolumeTotalOriginal", "volume")) + _, active := activeStatuses[status] + return parsedOrder{ + OrderID: mapGet(item, "m_strOrderSysID", "m_nOrderID", "order_id"), + StockCode: stockCodeFromMap(item), + 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[:]) + 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 +} + +func tradingTime(now time.Time) bool { + hm := parseHM(now) + return (hm >= 930 && hm <= 1130) || (hm >= 1300 && hm <= 1500) +} diff --git a/go-client/apps/zt/positions.go b/go-client/apps/zt/positions.go new file mode 100644 index 0000000..a026488 --- /dev/null +++ b/go-client/apps/zt/positions.go @@ -0,0 +1,298 @@ +package main + +import ( + "context" + "math" + "sync" + + "big-qmt/go-client/sdk" +) + +var posDip = struct { + mu sync.Mutex + store map[string]dipWatch +}{store: map[string]dipWatch{}} + +var peakMu sync.Mutex +var peakGrids = map[string]int{} + +func peakKey(code, leg string) string { return code + "|" + leg } + +func positionCodes(positions []sdk.Position) map[string]struct{} { + out := map[string]struct{}{} + for _, p := range positions { + if p.Volume <= 0 { + continue + } + code := normalizeCode(p.StockCode, "") + if code != "" { + out[code] = struct{}{} + } + } + return out +} + +func managePositions(ctx context.Context, client *sdk.Client, books *orderBook, cfg Config, ticks map[string]sdk.Tick, positions []sdk.Position, marketOK bool) { + if positions == nil { + logf("ERROR", "[ZT][持仓] 持仓查询失败,本轮跳过") + return + } + state := getState(cfg) + if !books.cancelExpired(ctx, client, cfg) { + logf("ERROR", "[ZT][持仓] 委托查询失败,本轮跳过") + return + } + buys, sells, ok := books.activeSets(ctx, client, cfg) + if !ok { + return + } + before := map[string]struct{}{} + for _, code := range state.Codes() { + before[code] = struct{}{} + } + if ticks == nil { + ticks = map[string]sdk.Tick{} + } + logf("INFO", "[ZT][持仓] 开始处理 %d 只", len(positions)) + type row struct { + volume, usable int + avg, price float64 + stock string + item *SymbolState + } + rows := make([]row, 0, len(positions)) + seen := map[string]struct{}{} + for _, pos := range positions { + code := normalizeCode(pos.StockCode, "") + if code == "" { + continue + } + seen[code] = struct{}{} + item := syncItem(cfg, state, code, pos.Volume, pos.OpenPrice, buys, sells, books) + if pos.Volume <= 0 { + continue + } + price := ticks[code].LastPrice + rows = append(rows, row{stock: code, volume: pos.Volume, usable: pos.CanUseVolume, avg: pos.OpenPrice, price: price, item: item}) + } + for _, code := range state.Codes() { + if _, ok := seen[code]; !ok { + syncItem(cfg, state, code, 0, 0, buys, sells, books) + } + } + after := map[string]struct{}{} + for _, code := range state.Codes() { + after[code] = struct{}{} + } + for code := range before { + if _, ok := after[code]; !ok { + forget(code) + } + } + for _, r := range rows { + if r.item == nil || r.item.Pending != "" { + continue + } + if r.avg <= 0 || r.price <= 0 || r.volume%100 != 0 { + continue + } + if r.volume != r.item.BaseQty+r.item.AddQty { + logf("INFO", "[ZT][持仓] %s 数量异常,底仓=%d 补仓=%d 现有=%d", r.stock, r.item.BaseQty, r.item.AddQty, r.volume) + continue + } + holdingAdd := r.item.AddQty > 0 + legName := "底仓" + if holdingAdd { + legName = "补仓腿" + } + logf("INFO", "[ZT][持仓] %s 现价=%.2f 成本=%.2f 可用=%d %s", r.stock, r.price, r.avg, r.usable, legName) + if holdingAdd { + addPnL := -999.0 + if r.item.AddCost > 0 { + addPnL = (r.price - r.item.AddCost) / r.item.AddCost * 100 + } + if retreated(cfg, r.item, "add", addPnL) { + sellLeg(ctx, client, books, cfg, r.item, r.usable, r.item.AddQty, "add", addPnL) + } + continue + } + basePnL := -999.0 + if r.item.BaseCost > 0 { + basePnL = (r.price - r.item.BaseCost) / r.item.BaseCost * 100 + } + if retreated(cfg, r.item, "base", basePnL) { + sellLeg(ctx, client, books, cfg, r.item, r.usable, r.item.BaseQty, "base", basePnL) + } else if r.item.AddQty <= 0 && r.item.AddCost <= 0 && basePnL <= cfg.LossTriggerPct { + addOnRebound(ctx, client, books, cfg, r.item, r.price, marketOK) + } + } + state.Save() +} + +func syncItem(cfg Config, state *ZTState, code string, volume int, avgPrice float64, buys, sells map[string]struct{}, books *orderBook) *SymbolState { + item := state.Get(code) + if item == nil { + if volume > 0 { + if cfg.AdoptExisting && avgPrice > 0 { + item = state.Ensure(code) + item.BaseQty, item.BaseCost, item.Pending = volume, avgPrice, "" + logf("WARNING", "[ZT][持仓] %s 接管为底仓", code) + return item + } + logf("ERROR", "[ZT][持仓] %s 无本地状态,跳过", code) + } + return nil + } + switch item.Pending { + case "base_opening": + syncOpen(cfg, state, item, volume, avgPrice, buys, books) + case "add": + syncAdd(cfg, state, item, volume, avgPrice, buys, books) + case "sell_add": + syncSellAdd(cfg, state, item, volume, avgPrice, sells, books) + case "sell_base": + syncSellBase(cfg, state, item, volume, avgPrice, sells, books) + default: + if volume <= 0 { + state.Remove(code) + logf("INFO", "[ZT][持仓] %s 已无持仓,清除状态", code) + return nil + } + } + return state.Get(code) +} + +func syncOpen(cfg Config, state *ZTState, item *SymbolState, volume int, avgPrice float64, buys map[string]struct{}, books *orderBook) { + if volume > 0 { + item.BaseQty, item.BaseCost = volume, avgPrice + } + if books.sideBusy(cfg, item.Code, "buy", buys) { + return + } + if volume <= 0 { + state.Remove(item.Code) + logf("INFO", "[ZT][委托] %s 开仓委托已失效,允许重新开仓", item.Code) + return + } + item.Pending = "" + logf("INFO", "[ZT][持仓] %s 开仓确认 数量=%d 成本=%.2f", item.Code, item.BaseQty, item.BaseCost) +} + +func syncAdd(cfg Config, state *ZTState, item *SymbolState, volume int, avgPrice float64, buys map[string]struct{}, books *orderBook) { + if volume > item.BaseQty { + item.AddQty = volume - item.BaseQty + if item.AddQty > 0 { + item.AddCost = math.Max(0, (avgPrice*float64(volume)-item.BaseCost*float64(item.BaseQty))/float64(item.AddQty)) + } + } + if books.sideBusy(cfg, item.Code, "buy", buys) { + return + } + if volume <= 0 { + state.Remove(item.Code) + logf("INFO", "[ZT][委托] %s 补仓后无持仓,清除状态", item.Code) + return + } + if volume <= item.BaseQty { + item.AddQty = 0 + item.AddCost = 0 + logf("INFO", "[ZT][持仓] %s 补仓未成交,回退底仓", item.Code) + } + item.Pending = "" +} + +func syncSellAdd(cfg Config, state *ZTState, item *SymbolState, volume int, avgPrice float64, sells map[string]struct{}, books *orderBook) { + if volume <= 0 { + if !books.sideBusy(cfg, item.Code, "sell", sells) { + state.Remove(item.Code) + logf("INFO", "[ZT][委托] %s 卖出后已无持仓,清除状态", item.Code) + } + return + } + if volume <= item.BaseQty { + item.BaseQty, item.BaseCost = volume, avgPrice + item.AddQty = 0 + peakMu.Lock() + delete(peakGrids, peakKey(item.Code, "add")) + peakMu.Unlock() + } else { + item.AddQty = volume - item.BaseQty + } + if !books.sideBusy(cfg, item.Code, "sell", sells) { + item.Pending = "" + } +} + +func syncSellBase(cfg Config, state *ZTState, item *SymbolState, volume int, avgPrice float64, sells map[string]struct{}, books *orderBook) { + if volume <= 0 { + if !books.sideBusy(cfg, item.Code, "sell", sells) { + state.Remove(item.Code) + logf("INFO", "[ZT][委托] %s 卖出后已无持仓,清除状态", item.Code) + } + return + } + item.BaseQty, item.BaseCost = volume, avgPrice + if !books.sideBusy(cfg, item.Code, "sell", sells) { + item.Pending = "" + } +} + +func addOnRebound(ctx context.Context, client *sdk.Client, books *orderBook, cfg Config, item *SymbolState, price float64, marketOK bool) { + if !marketOK || !dipTriggered(&posDip.mu, posDip.store, cfg, "补仓", item.Code, price) { + return + } + if books.place(ctx, client, cfg, "buy", item.Code, item.BaseQty, newOrderTag("add")) { + item.AddCost = price + item.Pending = "add" + getState(cfg).Save() + logf("INFO", "[ZT][补仓] %s 买入 %d 股", item.Code, item.BaseQty) + } +} + +func retreated(cfg Config, item *SymbolState, leg string, pnl float64) bool { + if pnl < cfg.MinProfitPct { + return false + } + grid := int(math.Floor(pnl / cfg.GridStepPct)) + key := peakKey(item.Code, leg) + peakMu.Lock() + defer peakMu.Unlock() + peak, ok := peakGrids[key] + if !ok || grid > peak { + peakGrids[key] = grid + logf("INFO", "[ZT][止盈] %s %s峰值网格=%d", item.Code, leg, grid) + return false + } + return grid < peak +} + +func sellLeg(ctx context.Context, client *sdk.Client, books *orderBook, cfg Config, item *SymbolState, usable, volume int, leg string, pnl float64) { + volume -= volume % 100 + if volume <= 0 || usable < volume { + logf("INFO", "[ZT][止盈] %s 可用股数不足,需要=%d 可用=%d", item.Code, volume, usable) + return + } + if !books.place(ctx, client, cfg, "sell", item.Code, volume, newOrderTag(leg)) { + return + } + if leg == "add" { + item.Pending = "sell_add" + } else { + item.Pending = "sell_base" + } + getState(cfg).Save() + logf("INFO", "[ZT][止盈] %s 卖出 %d 股,%s腿盈利=%.2f%%", item.Code, volume, leg, pnl) +} + +func forget(code string) { + openDip.mu.Lock() + delete(openDip.store, code) + openDip.mu.Unlock() + posDip.mu.Lock() + delete(posDip.store, code) + posDip.mu.Unlock() + peakMu.Lock() + delete(peakGrids, peakKey(code, "base")) + delete(peakGrids, peakKey(code, "add")) + peakMu.Unlock() +} diff --git a/go-client/apps/zt/remote.go b/go-client/apps/zt/remote.go new file mode 100644 index 0000000..3bb8fb6 --- /dev/null +++ b/go-client/apps/zt/remote.go @@ -0,0 +1,278 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "sync" + "time" +) + +type dailyCache struct { + Date string `json:"date"` + FetchedAt string `json:"fetched_at"` + OK bool `json:"ok"` + Data any `json:"data"` +} + +var memCache sync.Map + +func getJSON(rawURL string, params url.Values, timeout time.Duration) (map[string]any, error) { + if params != nil { + if strings.Contains(rawURL, "?") { + rawURL += "&" + params.Encode() + } else { + rawURL += "?" + params.Encode() + } + } + 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-zt/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))) + } + out := map[string]any{} + if err := json.Unmarshal(body, &out); err != nil { + return nil, err + } + return out, nil +} + +func daily(cfg Config, name, filename string, loader func() (any, error), now time.Time) *dailyCache { + if int(parseHM(now)) < cfg.ReadyCacheStart { + return nil + } + day := now.Format("20060102") + path := filepath.Join(cfg.DataDir, fmt.Sprintf(filename, day)) + if v, ok := memCache.Load(path); ok { + if c, ok := v.(*dailyCache); ok && c.Date == day { + return c + } + } + cached := loadDailyFile(path) + if cached != nil && cached.Date == day { + memCache.Store(path, cached) + return cached + } + data, err := loader() + ok := err == nil + if err != nil { + logf("ERROR", "%s 当日请求失败: %v", name, err) + data = map[string]any{} + } + cached = &dailyCache{ + Date: day, + FetchedAt: now.Format("2006-01-02 15:04:05"), + OK: ok, + Data: data, + } + raw, _ := json.MarshalIndent(map[string]any{"version": 1, "data": map[string]any{ + "date": cached.Date, "fetched_at": cached.FetchedAt, "ok": cached.OK, "data": cached.Data, + }}, "", " ") + if err := os.WriteFile(path+".tmp", raw, 0o644); err == nil { + _ = os.Rename(path+".tmp", path) + } + memCache.Store(path, cached) + return cached +} + +func loadDailyFile(path string) *dailyCache { + raw, err := os.ReadFile(path) + if err != nil { + return nil + } + var payload struct { + Version int `json:"version"` + Data map[string]any `json:"data"` + } + if json.Unmarshal(raw, &payload) != nil || payload.Version != 1 || payload.Data == nil { + return nil + } + c := &dailyCache{} + b, _ := json.Marshal(payload.Data) + if json.Unmarshal(b, c) != nil { + return nil + } + return c +} + +func fetchSignal(cfg Config, name string) map[string]map[string]any { + cached := daily(cfg, name, "open_%s.json", func() (any, error) { + q := url.Values{"host_key": {cfg.HostKey}} + payload, err := getJSON(cfg.APIHost+"/a/"+name, q, cfg.HTTPTimeout) + if err != nil { + return nil, err + } + return normalizeZT(payload), nil + }, time.Now()) + if cached == nil || !cached.OK { + return map[string]map[string]any{} + } + return asSignalMap(cached.Data) +} + +func asSignalMap(data any) map[string]map[string]any { + out := map[string]map[string]any{} + switch v := data.(type) { + case map[string]map[string]any: + return v + case map[string]any: + for code, val := range v { + if m, ok := val.(map[string]any); ok { + out[code] = m + } else { + out[code] = map[string]any{"code": code} + } + } + } + return out +} + +func normalizeZT(payload map[string]any) map[string]map[string]any { + data, _ := payload["data"] + out := map[string]map[string]any{} + switch v := data.(type) { + case []any: + for _, item := range v { + m, ok := item.(map[string]any) + if !ok { + continue + } + code, _ := m["code"].(string) + if code != "" { + out[code] = m + } + } + case map[string]any: + if code, _ := v["code"].(string); code != "" { + out[code] = v + return out + } + for code, val := range v { + if m, ok := val.(map[string]any); ok { + if _, has := m["code"]; !has { + m["code"] = code + } + out[code] = m + } else { + out[code] = map[string]any{"code": code} + } + } + } + return out +} + +func passCodes(cfg Config) []string { + load := func() (any, error) { + payload, err := getJSON(cfg.APIHost+"/a/pass_codes", nil, cfg.HTTPTimeout) + if err != nil { + return nil, err + } + data, _ := payload["data"].([]any) + if data == nil { + return nil, fmt.Errorf("接口 data 不是数组") + } + codes := make([]string, 0, len(data)) + for _, item := range data { + s := strings.ToUpper(strings.TrimSpace(fmt.Sprint(item))) + if s != "" && s != "" { + codes = append(codes, s) + } + } + return codes, nil + } + cached := daily(cfg, "pass_codes", "pass_codes_%s.json", load, time.Now()) + codes := codesFromAny(cached) + if len(codes) > 0 { + return codes + } + logf("INFO", "pass_codes 为空,重新获取") + data, err := load() + if err != nil { + logf("ERROR", "pass_codes 重新获取失败: %v", err) + return nil + } + list, _ := data.([]string) + return list +} + +func codesFromAny(cached *dailyCache) []string { + if cached == nil || !cached.OK { + return nil + } + switch v := cached.Data.(type) { + case []string: + return v + case []any: + out := make([]string, 0, len(v)) + for _, item := range v { + s := strings.ToUpper(strings.TrimSpace(fmt.Sprint(item))) + if s != "" && s != "" { + out = append(out, s) + } + } + return out + } + return nil +} + +func marketAllowOpen(cfg Config) bool { + payload, err := getJSON(cfg.APIHost+"/a/market", url.Values{"period": {"60m"}}, cfg.HTTPTimeout) + if err != nil { + logf("ERROR", "获取60m大盘信号失败: %s %v", cfg.APIHost+"/a/market", err) + return false + } + status := marketStatus(payload) + logf("INFO", "大盘信号: status=%s", status) + return status == "UP" +} + +func marketStatus(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" + } +} diff --git a/go-client/apps/zt/state.go b/go-client/apps/zt/state.go new file mode 100644 index 0000000..4850a12 --- /dev/null +++ b/go-client/apps/zt/state.go @@ -0,0 +1,179 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sync" +) + +type SymbolState struct { + Code string `json:"code"` + BaseQty int `json:"base_qty"` + BaseCost float64 `json:"base_cost"` + AddQty int `json:"add_qty"` + AddCost float64 `json:"add_cost"` + Pending string `json:"pending"` +} + +type filePayload struct { + Version int `json:"version"` + Data map[string]any `json:"data"` +} + +type ZTState struct { + path string + Items map[string]*SymbolState + LoadError string + mu sync.Mutex +} + +var ( + statesMu sync.Mutex + states = map[string]*ZTState{} +) + +func getState(cfg Config) *ZTState { + statesMu.Lock() + defer statesMu.Unlock() + if s, ok := states[cfg.AccountID]; ok { + return s + } + s := loadZTState(cfg.DataDir, cfg.AccountID) + states[cfg.AccountID] = s + return s +} + +func loadZTState(dataDir, accountID string) *ZTState { + st := &ZTState{ + path: filepath.Join(dataDir, fmt.Sprintf("zt_%s_state.json", accountID)), + Items: map[string]*SymbolState{}, + } + raw, err := os.ReadFile(st.path) + if err != nil { + if os.IsNotExist(err) { + return st + } + st.rebuild(err) + return st + } + var payload filePayload + if err := json.Unmarshal(raw, &payload); err != nil || payload.Version != 1 { + st.rebuild(fmt.Errorf("状态文件版本无效")) + return st + } + data := payload.Data + if data == nil { + st.rebuild(fmt.Errorf("状态文件内容无效")) + return st + } + symbolsAny, _ := data["symbols"] + symbols, _ := symbolsAny.(map[string]any) + if symbols == nil { + if _, ok := data["code"]; ok { + symbols = map[string]any{} + } else { + symbols = data + } + } + for code, value := range symbols { + m, ok := value.(map[string]any) + if !ok { + continue + } + item := &SymbolState{Code: code} + b, _ := json.Marshal(m) + _ = json.Unmarshal(b, item) + item.Code = code + st.Items[code] = item + } + return st +} + +func (s *ZTState) rebuild(err error) { + if err := os.Remove(s.path); err != nil && !os.IsNotExist(err) { + s.LoadError = err.Error() + logf("ERROR", "[ZT][状态] 状态文件重建失败: %s", s.LoadError) + return + } + s.Items = map[string]*SymbolState{} + if saveErr := s.saveUnlocked(); saveErr != nil { + s.LoadError = fmt.Sprintf("%v;重建失败: %v", err, saveErr) + logf("ERROR", "[ZT][状态] 状态文件重建失败: %s", s.LoadError) + return + } + logf("WARNING", "[ZT][状态] 状态文件损坏,已删除并重建: %v", err) +} + +func (s *ZTState) Get(code string) *SymbolState { + s.mu.Lock() + defer s.mu.Unlock() + return s.Items[code] +} + +func (s *ZTState) Ensure(code string) *SymbolState { + s.mu.Lock() + defer s.mu.Unlock() + if item, ok := s.Items[code]; ok { + return item + } + item := &SymbolState{Code: code} + s.Items[code] = item + return item +} + +func (s *ZTState) Remove(code string) { + s.mu.Lock() + defer s.mu.Unlock() + delete(s.Items, code) +} + +func (s *ZTState) Codes() []string { + s.mu.Lock() + defer s.mu.Unlock() + out := make([]string, 0, len(s.Items)) + for code := range s.Items { + out = append(out, code) + } + return out +} + +func (s *ZTState) Save() { + s.mu.Lock() + defer s.mu.Unlock() + if s.LoadError != "" { + return + } + if err := s.saveUnlocked(); err != nil { + logf("ERROR", "[ZT][状态] 保存失败: %v", err) + } +} + +func (s *ZTState) saveUnlocked() error { + symbols := map[string]any{} + for code, item := range s.Items { + symbols[code] = item + } + payload := filePayload{Version: 1, Data: map[string]any{"symbols": symbols}} + raw, err := json.Marshal(payload) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil { + return err + } + tmp := s.path + ".tmp" + if err := os.WriteFile(tmp, raw, 0o644); err != nil { + return err + } + return replaceFile(tmp, s.path) +} + +func replaceFile(tmp, dest string) error { + if err := os.Rename(tmp, dest); err == nil { + return nil + } + _ = os.Remove(dest) + return os.Rename(tmp, dest) +} diff --git a/go-client/apps/zt/stock.go b/go-client/apps/zt/stock.go new file mode 100644 index 0000000..01288ce --- /dev/null +++ b/go-client/apps/zt/stock.go @@ -0,0 +1,104 @@ +package main + +import ( + "fmt" + "strings" + "unicode" +) + +var exchangeAlias = map[string]string{ + "SSE": "SH", "SHSE": "SH", "XSHG": "SH", + "SZSE": "SZ", "XSHE": "SZ", + "BSE": "BJ", "BJSE": "BJ", +} + +func stockCodeFromMap(item map[string]string) string { + code := strings.ToUpper(strings.TrimSpace(mapGet(item, "m_strInstrumentID", "StockCode", "stock_code", "code"))) + ex := mapGet(item, "m_strExchangeID", "exchange", "exchange_id") + return normalizeCode(code, ex) +} + +func normalizeCode(code, exchange string) string { + code = strings.ToUpper(strings.TrimSpace(code)) + if code == "" { + return "" + } + if i := strings.LastIndex(code, "."); i >= 0 { + symbol, ex := code[:i], code[i+1:] + ex = canonExchange(ex) + if ex == "SH" || ex == "SZ" || ex == "BJ" { + return symbol + "." + ex + } + return "" + } + ex := canonExchange(exchange) + if ex == "" && looksDigits(code, 6) { + switch { + case strings.HasPrefix(code, "92") || code[0] == '4' || code[0] == '8': + ex = "BJ" + case code[0] == '5' || code[0] == '6' || code[0] == '9' || strings.HasPrefix(code, "11"): + ex = "SH" + case code[0] == '0' || code[0] == '1' || code[0] == '2' || code[0] == '3': + ex = "SZ" + } + } + if ex == "SH" || ex == "SZ" || ex == "BJ" { + return code + "." + ex + } + return "" +} + +func canonExchange(ex string) string { + ex = strings.ToUpper(strings.TrimSpace(ex)) + if v, ok := exchangeAlias[ex]; ok { + return v + } + return ex +} + +func looksDigits(s string, n int) bool { + if len(s) != n { + return false + } + for _, r := range s { + if !unicode.IsDigit(r) { + return false + } + } + return true +} + +func mapGet(item map[string]string, names ...string) string { + for _, name := range names { + if v := strings.TrimSpace(item[name]); v != "" { + return v + } + } + return "" +} + +func asIntS(s string) int { + s = strings.TrimSpace(s) + if s == "" { + return 0 + } + var n int + _, _ = fmt.Sscanf(s, "%d", &n) + if n == 0 { + var f float64 + if _, err := fmt.Sscanf(s, "%f", &f); err == nil { + return int(f) + } + } + return n +} + +func asFloatS(s string) float64 { + s = strings.TrimSpace(s) + if s == "" { + return 0 + } + var f float64 + _, _ = fmt.Sscanf(s, "%f", &f) + return f +} diff --git a/go-client/go.mod b/go-client/go.mod new file mode 100644 index 0000000..8d6bbae --- /dev/null +++ b/go-client/go.mod @@ -0,0 +1,3 @@ +module big-qmt/go-client + +go 1.22 diff --git a/go-client/sdk/account.go b/go-client/sdk/account.go new file mode 100644 index 0000000..560dea3 --- /dev/null +++ b/go-client/sdk/account.go @@ -0,0 +1,193 @@ +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"` +} + +// Assets 对应 /api/v2/assets。 +type Assets struct { + Total float64 `json:"total"` + Available float64 `json:"available"` +} + +type accountBody struct { + Account string `json:"account"` +} + +func (c *Client) Positions(ctx context.Context, account string) ([]Position, error) { + raw := map[string]json.RawMessage{} + if err := c.post(ctx, "/api/v2/positions", accountBody{Account: c.Account(account)}, &raw); err != nil { + return nil, err + } + out := make([]Position, 0, len(raw)) + for code, blob := range raw { + var p Position + if err := json.Unmarshal(blob, &p); err != nil { + return nil, fmt.Errorf("position %s: %w", code, err) + } + if p.StockCode == "" { + p.StockCode = code + } + out = append(out, p) + } + return out, nil +} + +func (c *Client) Holding(ctx context.Context, account string) ([]Position, error) { + raw := map[string]json.RawMessage{} + if err := c.post(ctx, "/api/holding", accountBody{Account: c.Account(account)}, &raw); err != nil { + return nil, err + } + out := make([]Position, 0, len(raw)) + for code, blob := range raw { + var p Position + if err := json.Unmarshal(blob, &p); err != nil { + return nil, fmt.Errorf("holding %s: %w", code, err) + } + if p.StockCode == "" { + p.StockCode = code + } + out = append(out, p) + } + return out, nil +} + +func (c *Client) Assets(ctx context.Context, account string) (*Assets, error) { + var out Assets + if err := c.post(ctx, "/api/v2/assets", accountBody{Account: c.Account(account)}, &out); err != nil { + return nil, err + } + return &out, nil +} + +func (c *Client) TotalMoney(ctx context.Context, account string) (float64, error) { + var out struct { + TotalMoney float64 `json:"total_money"` + } + if err := c.post(ctx, "/api/money/total", accountBody{Account: c.Account(account)}, &out); err != nil { + return 0, err + } + return out.TotalMoney, nil +} + +func (c *Client) AvailableMoney(ctx context.Context, account string) (float64, error) { + var out struct { + AvailableMoney float64 `json:"available_money"` + } + if err := c.post(ctx, "/api/money/available", accountBody{Account: c.Account(account)}, &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, account string) ([]OrderStatus, error) { + var out struct { + Orders []OrderStatus `json:"orders"` + } + if err := c.post(ctx, "/api/order/status", accountBody{Account: c.Account(account)}, &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, account string) (*CancelAllResult, error) { + var out CancelAllResult + if err := c.post(ctx, "/api/order/cancel_all", accountBody{Account: c.Account(account)}, &out); err != nil { + return nil, err + } + return &out, nil +} + +// CancelByRule 按「代码.市场 + (剩余+已成)」匹配撤单。HTTP 没有按委托号撤单,ZT 用它代替 cancel(orderId)。 +func (c *Client) CancelByRule(ctx context.Context, stock string, volume int, account string) (*CancelAllResult, error) { + var out CancelAllResult + body := map[string]any{"stock": stock, "volume": volume, "account": c.Account(account)} + 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, account string) ([]map[string]string, error) { + var out struct { + Deals []map[string]string `json:"deals"` + } + if err := c.post(ctx, "/api/order/deal", accountBody{Account: c.Account(account)}, &out); err != nil { + return nil, err + } + return out.Deals, nil +} diff --git a/go-client/sdk/check.go b/go-client/sdk/check.go new file mode 100644 index 0000000..7a473b9 --- /dev/null +++ b/go-client/sdk/check.go @@ -0,0 +1,67 @@ +package sdk + +import "context" + +func (c *Client) IsLastBar(ctx context.Context) (any, error) { + var out struct { + IsLastBar any `json:"is_last_bar"` + } + if err := c.get(ctx, "/api/check/is_last_bar", &out); err != nil { + return nil, err + } + return out.IsLastBar, nil +} + +func (c *Client) IsNewBar(ctx context.Context) (any, error) { + var out struct { + IsNewBar any `json:"is_new_bar"` + } + if err := c.get(ctx, "/api/check/is_new_bar", &out); err != nil { + return nil, err + } + return out.IsNewBar, nil +} + +func (c *Client) IsSuspendedStock(ctx context.Context, stockcode string) (any, error) { + var out struct { + Stockcode string `json:"stockcode"` + IsSuspended any `json:"is_suspended"` + } + if err := c.post(ctx, "/api/check/is_suspended_stock", map[string]any{"stockcode": stockcode}, &out); err != nil { + return nil, err + } + return out.IsSuspended, nil +} + +func (c *Client) IsSectorStock(ctx context.Context, sectorname, market, stockcode string) (any, error) { + var out struct { + IsInSector any `json:"is_in_sector"` + } + body := map[string]any{"sectorname": sectorname, "market": market, "stockcode": stockcode} + if err := c.post(ctx, "/api/check/is_sector_stock", body, &out); err != nil { + return nil, err + } + return out.IsInSector, nil +} + +func (c *Client) IsTypedStock(ctx context.Context, stocktypenum int, market, stockcode string) (any, error) { + var out struct { + Result any `json:"result"` + } + body := map[string]any{"stocktypenum": stocktypenum, "market": market, "stockcode": stockcode} + if err := c.post(ctx, "/api/check/is_typed_stock", body, &out); err != nil { + return nil, err + } + return out.Result, nil +} + +func (c *Client) IndustryNameOfStock(ctx context.Context, industryType, stockcode string) (any, error) { + var out struct { + IndustryName any `json:"industry_name"` + } + body := map[string]any{"industryType": industryType, "stockcode": stockcode} + if err := c.post(ctx, "/api/check/get_industry_name_of_stock", body, &out); err != nil { + return nil, err + } + return out.IndustryName, nil +} diff --git a/go-client/sdk/client.go b/go-client/sdk/client.go new file mode 100644 index 0000000..38d7db6 --- /dev/null +++ b/go-client/sdk/client.go @@ -0,0 +1,111 @@ +package sdk + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + + +// Client 调用 QMT HTTP API。 +type Client struct { + baseURL string + token string + accountType string + http *http.Client +} + +func New(baseURL, token, accountType string, timeout time.Duration) *Client { + base := strings.TrimRight(baseURL, "/") + return &Client{baseURL: base, token: token, accountType: accountType, http: &http.Client{Timeout: timeout}} +} + +func (c *Client) Account(override string) string { + if strings.TrimSpace(override) != "" { + return override + } + return c.accountType +} + +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 truncate(b []byte, n int) string { + if len(b) <= n { + return string(b) + } + return string(b[:n]) + "..." +} + +func ArrayJoin(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, ",") +} diff --git a/go-client/sdk/coerce.go b/go-client/sdk/coerce.go new file mode 100644 index 0000000..487ce85 --- /dev/null +++ b/go-client/sdk/coerce.go @@ -0,0 +1,94 @@ +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() + case []byte: + return string(x) + default: + return strings.TrimSpace(fmtAny(v)) + } +} + +func fmtAny(v any) string { + b, err := json.Marshal(v) + if err != nil { + return "" + } + s := strings.Trim(string(b), `"`) + return s +} + +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 asInt(v any) int { + return int(asFloat(v)) +} + +func asBool(v any) bool { + switch x := v.(type) { + case bool: + return x + case string: + s := strings.ToLower(strings.TrimSpace(x)) + return s == "true" || s == "1" || s == "yes" + default: + return asFloat(v) != 0 + } +} + +func mapField(m map[string]any, names ...string) any { + for _, name := range names { + if name == "" { + continue + } + if v, ok := m[name]; ok && v != nil { + return v + } + } + return nil +} + +func mapFieldS(m map[string]string, names ...string) string { + for _, name := range names { + if v, ok := m[name]; ok && strings.TrimSpace(v) != "" { + return v + } + } + return "" +} diff --git a/go-client/sdk/context.go b/go-client/sdk/context.go new file mode 100644 index 0000000..a1e1b8f --- /dev/null +++ b/go-client/sdk/context.go @@ -0,0 +1,136 @@ +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) { + var out struct { + Period any `json:"period"` + } + if err := c.get(ctx, "/api/context/period", &out); err != nil { + return nil, err + } + return out.Period, nil +} + +func (c *Client) ContextBarpos(ctx context.Context) (any, error) { + var out struct { + Barpos any `json:"barpos"` + } + if err := c.get(ctx, "/api/context/barpos", &out); err != nil { + return nil, err + } + return out.Barpos, nil +} + +func (c *Client) ContextTimeTickSize(ctx context.Context) (any, error) { + var out struct { + TimeTickSize any `json:"time_tick_size"` + } + if err := c.get(ctx, "/api/context/time_tick_size", &out); err != nil { + return nil, err + } + return out.TimeTickSize, nil +} + +func (c *Client) ContextStockcode(ctx context.Context) (any, error) { + var out struct { + Stockcode any `json:"stockcode"` + } + if err := c.get(ctx, "/api/context/stockcode", &out); err != nil { + return nil, err + } + return out.Stockcode, nil +} + +func (c *Client) ContextDividendType(ctx context.Context) (any, error) { + var out struct { + DividendType any `json:"dividend_type"` + } + if err := c.get(ctx, "/api/context/dividend_type", &out); err != nil { + return nil, err + } + return out.DividendType, nil +} + +func (c *Client) ContextMarket(ctx context.Context) (any, error) { + var out struct { + Market any `json:"market"` + } + if err := c.get(ctx, "/api/context/market", &out); err != nil { + return nil, err + } + return out.Market, nil +} + +func (c *Client) ContextDoBackTest(ctx context.Context) (any, error) { + var out struct { + DoBackTest any `json:"do_back_test"` + } + if err := c.get(ctx, "/api/context/do_back_test", &out); err != nil { + return nil, err + } + return out.DoBackTest, nil +} + +func (c *Client) ContextBenchmark(ctx context.Context) (any, error) { + var out struct { + Benchmark any `json:"benchmark"` + } + if err := c.get(ctx, "/api/context/benchmark", &out); err != nil { + return nil, err + } + return out.Benchmark, nil +} + +func (c *Client) ContextCapital(ctx context.Context) (any, error) { + var out struct { + Capital any `json:"capital"` + } + if err := c.get(ctx, "/api/context/capital", &out); err != nil { + return nil, err + } + return out.Capital, nil +} + +func (c *Client) ContextUniverse(ctx context.Context) ([]string, error) { + var out struct { + Universe any `json:"universe"` + } + if err := c.get(ctx, "/api/context/universe", &out); err != nil { + return nil, err + } + switch v := out.Universe.(type) { + case nil: + return nil, nil + case []any: + codes := make([]string, 0, len(v)) + for _, item := range v { + s := asString(item) + if s != "" { + codes = append(codes, s) + } + } + return codes, nil + case []string: + return v, nil + default: + s := asString(v) + if s == "" { + return nil, nil + } + return []string{s}, nil + } +} diff --git a/go-client/sdk/data.go b/go-client/sdk/data.go new file mode 100644 index 0000000..046a903 --- /dev/null +++ b/go-client/sdk/data.go @@ -0,0 +1,581 @@ +package sdk + +import ( + "context" + "fmt" +) + +type Tick struct { + LastPrice float64 + LastClose float64 + Raw map[string]any +} + +type HistoryDataRequest struct { + Len int `json:"len"` + Period string `json:"period,omitempty"` + Field string `json:"field,omitempty"` + DividendType int `json:"dividend_type"` + SkipPaused string `json:"skip_paused,omitempty"` +} + +type MarketDataRequest struct { + Fields string `json:"fields,omitempty"` + StockCode string `json:"stock_code,omitempty"` + StartTime string `json:"start_time,omitempty"` + EndTime string `json:"end_time,omitempty"` + Period string `json:"period,omitempty"` + DividendType string `json:"dividend_type,omitempty"` + Count int `json:"count"` +} + +type SubscribeResult struct { + Status string `json:"status"` + SubID any `json:"sub_id"` +} + +func (c *Client) StockName(ctx context.Context, stockcode string) (any, error) { + var out struct { + Name any `json:"name"` + } + if err := c.post(ctx, "/api/data/stock_name", map[string]any{"stockcode": stockcode}, &out); err != nil { + return nil, err + } + return out.Name, nil +} + +func (c *Client) OpenDate(ctx context.Context, stockcode string) (any, error) { + var out struct { + OpenDate any `json:"open_date"` + } + if err := c.post(ctx, "/api/data/open_date", map[string]any{"stockcode": stockcode}, &out); err != nil { + return nil, err + } + return out.OpenDate, nil +} + +func (c *Client) LastVolume(ctx context.Context, stockcode string) (any, error) { + var out struct { + LastVolume any `json:"last_volume"` + } + if err := c.post(ctx, "/api/data/last_volume", map[string]any{"stockcode": stockcode}, &out); err != nil { + return nil, err + } + return out.LastVolume, nil +} + +func (c *Client) BarTimetag(ctx context.Context, index int) (any, error) { + var out struct { + Timetag any `json:"timetag"` + } + if err := c.post(ctx, "/api/data/bar_timetag", map[string]any{"index": index}, &out); err != nil { + return nil, err + } + return out.Timetag, nil +} + +func (c *Client) TickTimetag(ctx context.Context) (any, error) { + var out struct { + Timetag any `json:"timetag"` + } + if err := c.get(ctx, "/api/data/tick_timetag", &out); err != nil { + return nil, err + } + return out.Timetag, nil +} + +func (c *Client) Sector(ctx context.Context, sector string, realtime string) ([]any, error) { + body := map[string]any{"sector": sector} + if realtime != "" { + body["realtime"] = realtime + } + var out struct { + Stocks []any `json:"stocks"` + } + if err := c.post(ctx, "/api/data/sector", body, &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) { + var out struct { + Weight any `json:"weight"` + } + body := map[string]any{"indexcode": indexcode, "stockcode": stockcode} + if err := c.post(ctx, "/api/data/weight_in_index", body, &out); err != nil { + return nil, err + } + return out.Weight, nil +} + +func (c *Client) ContractMultiplier(ctx context.Context, contractcode string) (any, error) { + var out struct { + Multiplier any `json:"multiplier"` + } + if err := c.post(ctx, "/api/data/contract_multiplier", map[string]any{"contractcode": contractcode}, &out); err != nil { + return nil, err + } + return out.Multiplier, nil +} + +func (c *Client) RiskFreeRate(ctx context.Context, index int) (any, error) { + var out struct { + RiskFreeRate any `json:"risk_free_rate"` + } + if err := c.post(ctx, "/api/data/risk_free_rate", map[string]any{"index": index}, &out); err != nil { + return nil, err + } + return out.RiskFreeRate, nil +} + +func (c *Client) DateLocation(ctx context.Context, strdate string) (any, error) { + var out struct { + Location any `json:"location"` + } + if err := c.post(ctx, "/api/data/date_location", map[string]any{"strdate": strdate}, &out); err != nil { + return nil, err + } + return out.Location, nil +} + +func (c *Client) HistoryData(ctx context.Context, req HistoryDataRequest) (any, error) { + if req.Len == 0 { + req.Len = 10 + } + if req.SkipPaused == "" { + req.SkipPaused = "true" + } + var out map[string]any + if err := c.post(ctx, "/api/data/history_data", req, &out); err != nil { + return nil, err + } + if msg, ok := out["error"].(string); ok && msg != "" { + return nil, &BusinessError{Message: msg} + } + return out["data"], nil +} + +func (c *Client) MarketData(ctx context.Context, req MarketDataRequest) (any, error) { + var out struct { + Data any `json:"data"` + } + if err := c.post(ctx, "/api/data/market_data", req, &out); err != nil { + return nil, err + } + return out.Data, nil +} + +func (c *Client) MarketDataEx(ctx context.Context, req MarketDataRequest) (any, error) { + body := map[string]any{ + "fields": req.Fields, + "stock_code": req.StockCode, + "period": req.Period, + "start_time": req.StartTime, + "end_time": req.EndTime, + "count": req.Count, + "dividend_type": req.DividendType, + } + var out struct { + Data any `json:"data"` + } + if err := c.post(ctx, "/api/data/market_data_ex", body, &out); err != nil { + return nil, err + } + return out.Data, nil +} + +func (c *Client) FullTick(ctx context.Context, stocks []string) (map[string]Tick, error) { + joined := ArrayJoin(stocks) + if joined == "" { + return nil, fmt.Errorf("full_tick: stocks empty") + } + raw := map[string]any{} + if err := c.post(ctx, "/api/data/full_tick", map[string]any{"stocks": joined}, &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) { + var out struct { + Factors any `json:"factors"` + } + if err := c.post(ctx, "/api/data/divid_factors", map[string]any{"stockcode": stockcode}, &out); err != nil { + return nil, err + } + return out.Factors, nil +} + +func (c *Client) MainContract(ctx context.Context, codemarket string) (any, error) { + var out struct { + MainContract any `json:"main_contract"` + } + if err := c.post(ctx, "/api/data/main_contract", map[string]any{"codemarket": codemarket}, &out); err != nil { + return nil, err + } + return out.MainContract, nil +} + +func (c *Client) TimetagToDatetime(ctx context.Context, timetag int64, format string) (any, error) { + body := map[string]any{"timetag": timetag} + if format != "" { + body["format"] = format + } + var out struct { + Datetime any `json:"datetime"` + } + if err := c.post(ctx, "/api/data/timetag_to_datetime", body, &out); err != nil { + return nil, err + } + return out.Datetime, nil +} + +func (c *Client) TotalShare(ctx context.Context, stockcode string) (any, error) { + var out struct { + TotalShare any `json:"total_share"` + } + if err := c.post(ctx, "/api/data/total_share", map[string]any{"stockcode": stockcode}, &out); err != nil { + return nil, err + } + return out.TotalShare, nil +} + +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) { + var out struct { + Svol any `json:"svol"` + } + if err := c.post(ctx, "/api/data/svol", map[string]any{"stockcode": stockcode}, &out); err != nil { + return nil, err + } + return out.Svol, nil +} + +func (c *Client) Bvol(ctx context.Context, stockcode string) (any, error) { + var out struct { + Bvol any `json:"bvol"` + } + if err := c.post(ctx, "/api/data/bvol", map[string]any{"stockcode": stockcode}, &out); err != nil { + return nil, err + } + return out.Bvol, nil +} + +func (c *Client) dataPayload(ctx context.Context, path string, body map[string]any) (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 v, ok := out["data"]; ok { + return v, nil + } + return out, nil +} + +func (c *Client) Longhubang(ctx context.Context, stockList, startTime, endTime string) (any, error) { + return c.dataPayload(ctx, "/api/data/longhubang", map[string]any{ + "stock_list": stockList, "startTime": startTime, "endTime": endTime, + }) +} + +func (c *Client) Top10ShareHolder(ctx context.Context, stockList, dataName, startTime, endTime string) (any, error) { + return c.dataPayload(ctx, "/api/data/top10_share_holder", map[string]any{ + "stock_list": stockList, "data_name": dataName, "start_time": startTime, "end_time": endTime, + }) +} + +func (c *Client) OptionDetail(ctx context.Context, optioncode string) (any, error) { + var out struct { + Detail any `json:"detail"` + } + if err := c.post(ctx, "/api/data/option_detail", map[string]any{"optioncode": optioncode}, &out); err != nil { + return nil, err + } + return out.Detail, nil +} + +func (c *Client) TurnoverRate(ctx context.Context, stockList, startTime, endTime string) (any, error) { + return c.dataPayload(ctx, "/api/data/turnover_rate", map[string]any{ + "stock_list": stockList, "startTime": startTime, "endTime": endTime, + }) +} + +func (c *Client) ETFInfo(ctx context.Context, stockcode string) (any, error) { + var out struct { + Info any `json:"info"` + } + if err := c.post(ctx, "/api/data/etf_info", map[string]any{"stockcode": stockcode}, &out); err != nil { + return nil, err + } + return out.Info, nil +} + +func (c *Client) ETFIOPV(ctx context.Context, stockcode string) (any, error) { + var out struct { + IOPV any `json:"iopv"` + } + if err := c.post(ctx, "/api/data/etf_iopv", map[string]any{"stockcode": stockcode}, &out); err != nil { + return nil, err + } + return out.IOPV, nil +} + +func (c *Client) InstrumentDetail(ctx context.Context, stockcode string) (any, error) { + var out struct { + Detail any `json:"detail"` + } + if err := c.post(ctx, "/api/data/instrumentdetail", map[string]any{"stockcode": stockcode}, &out); err != nil { + return nil, err + } + return out.Detail, nil +} + +func (c *Client) ContractExpireDate(ctx context.Context, codemarket string) (any, error) { + var out struct { + ExpireDate any `json:"expire_date"` + } + if err := c.post(ctx, "/api/data/contract_expire_date", map[string]any{"codemarket": codemarket}, &out); err != nil { + return nil, err + } + return out.ExpireDate, nil +} + +func (c *Client) OptionUndlData(ctx context.Context, undlCodeRef string) (any, error) { + var out struct { + Data any `json:"data"` + } + if err := c.post(ctx, "/api/data/option_undl_data", map[string]any{"undl_code_ref": undlCodeRef}, &out); err != nil { + return nil, err + } + return out.Data, nil +} + +type FinancialDataRequest struct { + Tabname string `json:"tabname,omitempty"` + Colname string `json:"colname,omitempty"` + Market string `json:"market,omitempty"` + Code string `json:"code,omitempty"` + ReportType string `json:"report_type,omitempty"` + Barpos int `json:"barpos"` + FieldList string `json:"fieldList,omitempty"` + StockList string `json:"stockList,omitempty"` + StartDate string `json:"startDate,omitempty"` + EndDate string `json:"endDate,omitempty"` +} + +func (c *Client) FinancialData(ctx context.Context, req FinancialDataRequest) (any, error) { + var out map[string]any + if err := c.post(ctx, "/api/data/financial_data", req, &out); err != nil { + return nil, err + } + if msg, ok := out["error"].(string); ok && msg != "" { + return nil, &BusinessError{Message: msg} + } + return out["data"], nil +} + +type FactorDataRequest struct { + FieldList string `json:"fieldList,omitempty"` + StockList string `json:"stockList,omitempty"` + StockCode string `json:"stockCode,omitempty"` + StartDate string `json:"startDate,omitempty"` + EndDate string `json:"endDate,omitempty"` +} + +func (c *Client) FactorData(ctx context.Context, req FactorDataRequest) (any, error) { + var out map[string]any + if err := c.post(ctx, "/api/data/factor_data", req, &out); err != nil { + return nil, err + } + if msg, ok := out["error"].(string); ok && msg != "" { + return nil, &BusinessError{Message: msg} + } + return out["data"], nil +} + +func (c *Client) HisSTData(ctx context.Context, stockCode string) (any, error) { + var out struct { + Data any `json:"data"` + } + if err := c.post(ctx, "/api/data/his_st_data", map[string]any{"stockCode": stockCode}, &out); err != nil { + return nil, err + } + return out.Data, nil +} + +func (c *Client) HisIndexData(ctx context.Context, index string) (any, error) { + var out struct { + Data any `json:"data"` + } + if err := c.post(ctx, "/api/data/his_index_data", map[string]any{"index": index}, &out); err != nil { + return nil, err + } + return out.Data, nil +} + +func (c *Client) AllSubscription(ctx context.Context) (any, error) { + var out struct { + Subscriptions any `json:"subscriptions"` + } + if err := c.get(ctx, "/api/data/all_subscription", &out); err != nil { + return nil, err + } + return out.Subscriptions, nil +} + +func (c *Client) OptionList(ctx context.Context, undlCode, dedate, opttype, isavailable string) (any, error) { + body := map[string]any{"undl_code": undlCode, "dedate": dedate, "opttype": opttype} + if isavailable != "" { + body["isavailable"] = isavailable + } + var out struct { + OptionList any `json:"option_list"` + } + if err := c.post(ctx, "/api/data/option_list", body, &out); err != nil { + return nil, err + } + return out.OptionList, nil +} + +func (c *Client) HisContractList(ctx context.Context, market string) (any, error) { + var out struct { + Contracts any `json:"contracts"` + } + if err := c.post(ctx, "/api/data/his_contract_list", map[string]any{"market": market}, &out); err != nil { + return nil, err + } + return out.Contracts, nil +} + +func (c *Client) OptionIV(ctx context.Context, optioncode string) (any, error) { + var out struct { + IV any `json:"iv"` + } + if err := c.post(ctx, "/api/data/option_iv", map[string]any{"optioncode": optioncode}, &out); err != nil { + return nil, err + } + return out.IV, nil +} + +type BSMPriceRequest struct { + OptionType string `json:"optionType"` + ObjectPrices string `json:"objectPrices"` + StrikePrice float64 `json:"strikePrice"` + RiskFree float64 `json:"riskFree"` + Sigma float64 `json:"sigma"` + Days int `json:"days"` + Dividend float64 `json:"dividend"` +} + +func (c *Client) BSMPrice(ctx context.Context, req BSMPriceRequest) (any, error) { + var out struct { + Price any `json:"price"` + } + if err := c.post(ctx, "/api/data/bsm_price", req, &out); err != nil { + return nil, err + } + return out.Price, nil +} + +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) { + var out struct { + IV any `json:"iv"` + } + if err := c.post(ctx, "/api/data/bsm_iv", req, &out); err != nil { + return nil, err + } + return out.IV, nil +} + +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) { + var out struct { + Data any `json:"data"` + } + if err := c.post(ctx, "/api/data/local_data", req, &out); err != nil { + return nil, err + } + return out.Data, nil +} + +func (c *Client) SubscribeQuote(ctx context.Context, stockCode, period, dividendType string) (*SubscribeResult, error) { + body := map[string]any{"stock_code": stockCode, "period": period, "dividend_type": dividendType} + var out SubscribeResult + 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 +} diff --git a/go-client/sdk/doc.go b/go-client/sdk/doc.go new file mode 100644 index 0000000..8ca1231 --- /dev/null +++ b/go-client/sdk/doc.go @@ -0,0 +1,4 @@ +// Package sdk 是 QMT_API.py HTTP 服务的 Go 客户端。 +// +// 默认地址 http://127.0.0.1:10086,所有已注册接口都需要请求头 X-Token。 +package sdk diff --git a/go-client/sdk/error.go b/go-client/sdk/error.go new file mode 100644 index 0000000..cd888e4 --- /dev/null +++ b/go-client/sdk/error.go @@ -0,0 +1,38 @@ +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 +} diff --git a/go-client/sdk/ext.go b/go-client/sdk/ext.go new file mode 100644 index 0000000..d7ef15f --- /dev/null +++ b/go-client/sdk/ext.go @@ -0,0 +1,47 @@ +package sdk + +import "context" + +func (c *Client) ExtData(ctx context.Context, extdataname, stockcode string, deviation int) (any, error) { + var out struct { + Value any `json:"value"` + } + body := map[string]any{"extdataname": extdataname, "stockcode": stockcode, "deviation": deviation} + if err := c.post(ctx, "/api/ext/ext_data", body, &out); err != nil { + return nil, err + } + return out.Value, nil +} + +func (c *Client) ExtDataRank(ctx context.Context, extdataname, stockcode string, deviation int) (any, error) { + var out struct { + Rank any `json:"rank"` + } + body := map[string]any{"extdataname": extdataname, "stockcode": stockcode, "deviation": deviation} + if err := c.post(ctx, "/api/ext/ext_data_rank", body, &out); err != nil { + return nil, err + } + return out.Rank, nil +} + +func (c *Client) GetFactorValue(ctx context.Context, factorname, stockcode string, deviation int) (any, error) { + var out struct { + Value any `json:"value"` + } + body := map[string]any{"factorname": factorname, "stockcode": stockcode, "deviation": deviation} + if err := c.post(ctx, "/api/ext/get_factor_value", body, &out); err != nil { + return nil, err + } + return out.Value, nil +} + +func (c *Client) GetFactorRank(ctx context.Context, factorname, stockcode string, deviation int) (any, error) { + var out struct { + Rank any `json:"rank"` + } + body := map[string]any{"factorname": factorname, "stockcode": stockcode, "deviation": deviation} + if err := c.post(ctx, "/api/ext/get_factor_rank", body, &out); err != nil { + return nil, err + } + return out.Rank, nil +} diff --git a/go-client/sdk/sys.go b/go-client/sdk/sys.go new file mode 100644 index 0000000..e7d3f03 --- /dev/null +++ b/go-client/sdk/sys.go @@ -0,0 +1,30 @@ +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 +} diff --git a/go-client/sdk/trade.go b/go-client/sdk/trade.go new file mode 100644 index 0000000..7c88354 --- /dev/null +++ b/go-client/sdk/trade.go @@ -0,0 +1,302 @@ +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"` +} + +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 按最新价下单。服务端策略名写死为 qmt,无法传投资备注。 +func (c *Client) PassorderLatest(ctx context.Context, buy bool, stock string, volume int) (*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, + }) +} + +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, accID string) (*StyleOrderResult, error) { + return c.styleOrder(ctx, "/api/trade/order_lots", styleBody(stock, style, price, accID, "lots", lots)) +} + +func (c *Client) OrderValue(ctx context.Context, stock string, value float64, style string, price float64, accID string) (*StyleOrderResult, error) { + return c.styleOrder(ctx, "/api/trade/order_value", styleBody(stock, style, price, accID, "value", value)) +} + +func (c *Client) OrderPercent(ctx context.Context, stock string, percent float64, style string, price float64, accID string) (*StyleOrderResult, error) { + return c.styleOrder(ctx, "/api/trade/order_percent", styleBody(stock, style, price, accID, "percent", percent)) +} + +func (c *Client) OrderTargetValue(ctx context.Context, stock string, tarValue float64, style string, price float64, accID string) (*StyleOrderResult, error) { + return c.styleOrder(ctx, "/api/trade/order_target_value", styleBody(stock, style, price, accID, "tar_value", tarValue)) +} + +func (c *Client) OrderTargetPercent(ctx context.Context, stock string, tarPercent float64, style string, price float64, accID string) (*StyleOrderResult, error) { + return c.styleOrder(ctx, "/api/trade/order_target_percent", styleBody(stock, style, price, accID, "tar_percent", tarPercent)) +} + +func (c *Client) OrderShares(ctx context.Context, stock string, shares int, style string, price float64, accID string) (*StyleOrderResult, error) { + return c.styleOrder(ctx, "/api/trade/order_shares", styleBody(stock, style, price, accID, "shares", shares)) +} + +func styleBody(stock, style string, price float64, accID, key string, val any) map[string]any { + body := map[string]any{"stock": stock, key: val, "price": price} + if style != "" { + body["style"] = style + } + if accID != "" { + body["accId"] = accID + } + return body +} + +func (c *Client) futures(ctx context.Context, path, stock string, amount int, style string, price float64, accID string) (*StyleOrderResult, error) { + body := map[string]any{"stock": stock, "amount": amount, "price": price} + if style != "" { + body["style"] = style + } + if accID != "" { + body["accId"] = accID + } + return c.styleOrder(ctx, path, body) +} + +func (c *Client) FuturesBuyOpen(ctx context.Context, stock string, amount int, style string, price float64, accID string) (*StyleOrderResult, error) { + return c.futures(ctx, "/api/trade/futures/buy_open", stock, amount, style, price, accID) +} +func (c *Client) FuturesBuyCloseTdayFirst(ctx context.Context, stock string, amount int, style string, price float64, accID string) (*StyleOrderResult, error) { + return c.futures(ctx, "/api/trade/futures/buy_close_tdayfirst", stock, amount, style, price, accID) +} +func (c *Client) FuturesBuyCloseYdayFirst(ctx context.Context, stock string, amount int, style string, price float64, accID string) (*StyleOrderResult, error) { + return c.futures(ctx, "/api/trade/futures/buy_close_ydayfirst", stock, amount, style, price, accID) +} +func (c *Client) FuturesSellOpen(ctx context.Context, stock string, amount int, style string, price float64, accID string) (*StyleOrderResult, error) { + return c.futures(ctx, "/api/trade/futures/sell_open", stock, amount, style, price, accID) +} +func (c *Client) FuturesSellCloseTdayFirst(ctx context.Context, stock string, amount int, style string, price float64, accID string) (*StyleOrderResult, error) { + return c.futures(ctx, "/api/trade/futures/sell_close_tdayfirst", stock, amount, style, price, accID) +} +func (c *Client) FuturesSellCloseYdayFirst(ctx context.Context, stock string, amount int, style string, price float64, accID string) (*StyleOrderResult, error) { + return c.futures(ctx, "/api/trade/futures/sell_close_ydayfirst", stock, amount, style, price, accID) +} + +type TaskResult struct { + Status string `json:"status"` + TaskID any `json:"taskId"` +} + +func (c *Client) task(ctx context.Context, path, taskID, accountType string) (*TaskResult, error) { + body := map[string]any{"taskId": taskID} + if accountType != "" { + body["accountType"] = accountType + } + var out TaskResult + if err := c.post(ctx, path, body, &out); err != nil { + return nil, err + } + return &out, nil +} + +func (c *Client) CancelTask(ctx context.Context, taskID, accountType string) (*TaskResult, error) { + return c.task(ctx, "/api/trade/cancel_task", taskID, accountType) +} +func (c *Client) PauseTask(ctx context.Context, taskID, accountType string) (*TaskResult, error) { + return c.task(ctx, "/api/trade/pause_task", taskID, accountType) +} +func (c *Client) ResumeTask(ctx context.Context, taskID, accountType string) (*TaskResult, error) { + return c.task(ctx, "/api/trade/resume_task", taskID, accountType) +} + +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", map[string]any{}, &out); err != nil { + return nil, err + } + return out, nil +} + +func (c *Client) TradeDetailData(ctx context.Context, account, datatype string) ([]map[string]string, error) { + body := map[string]any{ + "account": c.Account(account), + "datatype": datatype, + } + var out struct { + Data []map[string]string `json:"data"` + } + if err := c.post(ctx, "/api/trade/trade_detail_data", body, &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, accountType, datatype string) (map[string]string, error) { + body := map[string]any{"orderId": orderID, "accountType": accountType, "datatype": datatype} + var out struct { + OrderID string `json:"orderId"` + Data map[string]string `json:"data"` + } + 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, account, datatype string) (any, error) { + body := map[string]any{"account": c.Account(account), "datatype": datatype} + var out struct { + LastOrderID any `json:"last_order_id"` + } + if err := c.post(ctx, "/api/trade/last_order_id", body, &out); err != nil { + return nil, err + } + return out.LastOrderID, nil +} + +func (c *Client) CanCancelOrder(ctx context.Context, orderID, accountType string) (any, error) { + body := map[string]any{"orderId": orderID, "accountType": accountType} + var out struct { + CanCancel any `json:"can_cancel"` + } + if err := c.post(ctx, "/api/trade/can_cancel_order", body, &out); err != nil { + return nil, err + } + return out.CanCancel, nil +} + +func (c *Client) contractList(ctx context.Context, path, accID string) ([]map[string]string, error) { + body := map[string]any{} + if accID != "" { + body["accId"] = accID + } + var out struct { + Data []map[string]string `json:"data"` + } + if err := c.post(ctx, path, body, &out); err != nil { + return nil, err + } + return out.Data, nil +} + +func (c *Client) DebtContract(ctx context.Context, accID string) ([]map[string]string, error) { + return c.contractList(ctx, "/api/trade/debt_contract", accID) +} +func (c *Client) AssureContract(ctx context.Context, accID string) ([]map[string]string, error) { + return c.contractList(ctx, "/api/trade/assure_contract", accID) +} +func (c *Client) EnableShortContract(ctx context.Context, accID string) ([]map[string]string, error) { + return c.contractList(ctx, "/api/trade/enable_short_contract", accID) +} + +func (c *Client) IPOData(ctx context.Context, typ string) (any, error) { + var out struct { + Data any `json:"data"` + } + if err := c.post(ctx, "/api/trade/ipo_data", map[string]any{"type": typ}, &out); err != nil { + return nil, err + } + return out.Data, nil +} + +func (c *Client) NewPurchaseLimit(ctx context.Context, accid string) (any, error) { + body := map[string]any{} + if accid != "" { + body["accid"] = accid + } + var out struct { + Data any `json:"data"` + } + if err := c.post(ctx, "/api/trade/new_purchase_limit", body, &out); err != nil { + return nil, err + } + return out.Data, nil +} diff --git a/scripts/run.bat b/scripts/run.bat new file mode 100644 index 0000000..443e139 --- /dev/null +++ b/scripts/run.bat @@ -0,0 +1,20 @@ +@echo off +setlocal +cd /d "%~dp0.." +cd go-client + +if not defined QMT_BASE_URL set "QMT_BASE_URL=http://127.0.0.1:10086" +if not defined QMT_TOKEN set "QMT_TOKEN=QMTbyYanweidong" +if not defined QMT_ACCOUNT set "QMT_ACCOUNT=stock" + +echo QMT_BASE_URL=%QMT_BASE_URL% +echo QMT_ACCOUNT=%QMT_ACCOUNT% +echo. + +go run ./apps/cmd %* +if errorlevel 1 ( + echo. + echo 运行失败。 + pause + exit /b 1 +)