From fa62436a7374f5dc92eb14ca43214c85e67126be Mon Sep 17 00:00:00 2001 From: yanweidong Date: Tue, 25 Aug 2026 16:40:18 +0800 Subject: [PATCH] dev 1 --- api/QMT_API.py | 1537 +++++++++++++++++++++++ api/__pycache__/QMT_API.cpython-311.pyc | Bin 0 -> 115856 bytes docs/README.md | 257 ++++ docs/api.md | 1214 ++++++++++++++++++ go-client/apps/cmd/main.go | 117 ++ go-client/apps/zt/boot.go | 70 ++ go-client/apps/zt/config.go | 118 ++ go-client/apps/zt/log.go | 10 + go-client/apps/zt/main.go | 105 ++ go-client/apps/zt/open.go | 114 ++ go-client/apps/zt/order.go | 374 ++++++ go-client/apps/zt/positions.go | 298 +++++ go-client/apps/zt/remote.go | 278 ++++ go-client/apps/zt/state.go | 179 +++ go-client/apps/zt/stock.go | 104 ++ go-client/go.mod | 3 + go-client/sdk/account.go | 193 +++ go-client/sdk/check.go | 67 + go-client/sdk/client.go | 111 ++ go-client/sdk/coerce.go | 94 ++ go-client/sdk/context.go | 136 ++ go-client/sdk/data.go | 581 +++++++++ go-client/sdk/doc.go | 4 + go-client/sdk/error.go | 38 + go-client/sdk/ext.go | 47 + go-client/sdk/sys.go | 30 + go-client/sdk/trade.go | 302 +++++ scripts/run.bat | 20 + 28 files changed, 6401 insertions(+) create mode 100644 api/QMT_API.py create mode 100644 api/__pycache__/QMT_API.cpython-311.pyc create mode 100644 docs/README.md create mode 100644 docs/api.md create mode 100644 go-client/apps/cmd/main.go create mode 100644 go-client/apps/zt/boot.go create mode 100644 go-client/apps/zt/config.go create mode 100644 go-client/apps/zt/log.go create mode 100644 go-client/apps/zt/main.go create mode 100644 go-client/apps/zt/open.go create mode 100644 go-client/apps/zt/order.go create mode 100644 go-client/apps/zt/positions.go create mode 100644 go-client/apps/zt/remote.go create mode 100644 go-client/apps/zt/state.go create mode 100644 go-client/apps/zt/stock.go create mode 100644 go-client/go.mod create mode 100644 go-client/sdk/account.go create mode 100644 go-client/sdk/check.go create mode 100644 go-client/sdk/client.go create mode 100644 go-client/sdk/coerce.go create mode 100644 go-client/sdk/context.go create mode 100644 go-client/sdk/data.go create mode 100644 go-client/sdk/doc.go create mode 100644 go-client/sdk/error.go create mode 100644 go-client/sdk/ext.go create mode 100644 go-client/sdk/sys.go create mode 100644 go-client/sdk/trade.go create mode 100644 scripts/run.bat 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 0000000000000000000000000000000000000000..6264d8dd238f1f860297c7920635612c9ce79162 GIT binary patch literal 115856 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 NYG$2G3JIz%{6B<&f3pAp literal 0 HcmV?d00001 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 +)