diff --git a/AUDIT_AND_REMEDIATION.md b/AUDIT_AND_REMEDIATION.md new file mode 100644 index 0000000..507fdc2 --- /dev/null +++ b/AUDIT_AND_REMEDIATION.md @@ -0,0 +1,372 @@ +# big-qmt 项目审计与整改建议 + +- 审计日期:2026-08-28 +- 审计范围:服务端 `api/`、客户端 `py-client/` +- 审计方式:静态代码检查、调用链核对、只读语法编译 +- 当前状态:仅供人工确认,尚未实施代码整改 + +## 一、总体结论 + +当前版本不建议直接进入实盘运行。 + +服务端存在文件编码导致的启动级错误,且所有 QMT 调用和大对象序列化都在 Tornado 主线程同步执行。客户端的持仓管理、止盈、补仓、撤单和下单确认链存在多处必现错误或状态不一致风险。 + +建议按以下顺序处理: + +1. 恢复服务端和客户端的基本可运行性。 +2. 修复交易安全相关的订单确认、撤单和持仓数据模型。 +3. 为止盈、补仓、订单状态机建立测试。 +4. 在确认 QMT 线程约束后优化服务端响应速度。 +5. 最后进行结构简化和重复代码清理。 + +--- + +## 二、P0:启动及交易安全问题 + +### 2.1 服务端文件编码不一致,程序无法正常编译 + +位置:`api/QMT_API.py:1` + +现状: + +```python +# -*- coding: gbk -*- +``` + +文件实际内容包含 UTF-8 字节,只读编译时报错: + +```text +SyntaxError: 'gbk' codec can't decode byte ... +``` + +影响:服务端可能在载入阶段直接退出,所有 API 不可用。 + +解决方案: + +如果运行环境强制要求 GBK,则必须把整个文件真实转换为 GBK,不能只修改声明。 + +验收标准: + +- 中文日志和错误响应无乱码。 + +### 2.2 客户端持仓对象被错误当成字典和二元组使用 + +位置:`py-client/strategy/trend/positions.py:32-40` + +现状:`client.positions()` 返回 `list[Position]`,但代码同时使用: + +```python +for idx, pos in positions: +code = pos["stock_code"] +avg_price = pos.get("avg_price", 0) +``` + +行情结果同样是 `Tick` dataclass,却使用字典的 `.get()`。 + +影响:进入持仓管理后必然抛出 `TypeError` 或 `AttributeError`,止盈和补仓完全无法执行。 + +解决方案: + +全项目统一使用 SDK dataclass + __slots__,不再混用原始字典。 + +验收标准: + +- 使用真实 `Position`、`Tick` 对象执行一轮不抛异常。 + +### 2.3 `handle_profit` 调用参数和函数签名不一致 + +位置: + +- 调用:`py-client/strategy/trend/positions.py:58` +- 定义:`py-client/strategy/trend/positions.py:73` + +影响:修复持仓遍历后,下一步仍会立即触发 `TypeError`。 + +解决方案: +删除未使用的 `open_price`、`strategy_name` 或把它们纳入统一模型。 + +推荐接口: + +```python +def handle_profit( + runtime: Runtime, + position: Position, + tick: Tick, + pnl_rate: float, +) -> ProfitDecision: + ... +``` + +验收标准: + +- 静态类型检查能够发现参数数量错误。 +- ARMED、RAISED、STEADY、RETREAT 四种状态都有测试。 + +### 2.4 补仓流程存在多处必现错误 + +位置:`py-client/strategy/trend/positions.py:118-159` + +问题包括: + +- `StateItem` 被当成字典调用 `.get()`。 +- `orders.busy()` 多传入一个 `run` 参数。 +- 某些分支只返回 `False`,调用方却解包两个值。 +- 使用不存在的 `run.state.STATUS_ING`。 +- `state.added_num = +1` 每次都赋值为 1,并非累加。 +- `LOSS_TIERS[added_num]` 可能数组越界。 +- 下单后没有扣减本轮剩余预算,多持仓可能超额补仓。 + +解决方案: + +1. 所有 `StateItem` 字段改为属性访问。 +2. `orders.busy(code, "BUY")` 使用正确签名。 +3. 所有返回分支统一返回结构,推荐使用 dataclass + __slots__: + +```python +@dataclass(frozen=True) +class TradeDecision: + submitted: bool + message: str = "" + reserved_cash: float = 0.0 +``` + +4. 使用模块常量 `STATUS_ING`,或把状态定义成 `Enum`。 +5. 补仓次数使用 `state.added_num += 1`。 +6. 当 `added_num >= len(LOSS_TIERS)` 时明确禁止继续补仓。 +7. `RunOnce` 创建本轮 `remaining_cash`,每次成功提交补仓后立即扣减。 + +验收标准: + +- 第 0、1、2 次补仓边界均有测试。 +- 超过最大补仓次数不会抛异常或继续下单。 + +### 2.5 止盈跟踪器每轮重建,无法形成跨轮回撤 + +位置:`py-client/strategy/trend/positions.py:31` + +影响:每轮都会清空最高盈利网格,止盈状态无法从 ARMED/RAISED 演进至 RETREAT。 + +解决方案: + +1. `GridTrailingTracker` 应作为 `Runtime` 字段,在策略启动时只创建一次。 +2. 检查是否有定时清理的功能 + +验收标准: + +- 连续输入 2.1%、3.1%、2.9% 能产生 ARMED、RAISED、RETREAT。 +- 相同股票不同账户的峰值互不污染。 +- 清仓后重新建仓不会继承旧峰值。 + +### 2.6 “取消过期订单”只查询可撤状态,没有执行撤单 + +位置: + +- 客户端:`py-client/strategy/trend/order.py:74-88` +- 服务端:`api/QMT_API.py:962-969` + +影响:过期订单一直保留,订单锁可能长期阻止新交易。 + +解决方案: + +方案 A,推荐:新增按真实委托号撤单接口。 + +```text +POST /api/order/cancel_by_id +body: {order_id, account_type} +``` + +服务端先执行 `can_cancel_order()`,可撤时调用真正的 `cancel()`,并返回撤单请求结果。 + + +验收标准: + +- 暂时不做验证,后期验证 + +### 2.7 低现金资金闸同时跳过卖出管理 + +位置:`py-client/strategy/trend/boot.py:126-134` + +影响:可用资金不足时直接结束整轮流程,持仓止盈和风险退出也被禁止。 + +解决方案: + +把“是否允许新开仓/补仓”和“是否允许卖出”拆成不同条件。 + +验收标准: + +- 可用现金低于阈值时不开仓、可补仓。 +- 同一情况下满足止盈条件的持仓仍然能够提交卖单。 + + +### 2.9 客户端订单标签未真正传给 QMT + +位置: + +- 客户端发送:`py-client/sdk/trade.py:10-18` +- 服务端丢弃:`api/QMT_API.py:663` + +现状:客户端发送 `strategyName`,服务端调用 `passorder()` 时却硬编码为 `qmt`。 + +解决方案: + +1. 统一:strategy_name 为信号的key,m_strRemark为本地业务订单号。 +2. 同时修改QMT_API.py + +验收标准: + +- 下单后在 QMT 委托明细中可以看到客户端标签。 +- 能从本地订单 ID 追踪到真实委托号和最终成交。 + +--- + +## 三、P1:服务端响应速度整改 + + +验收标准: + +- 一轮策略账户查询由三次以上 QMT 调用下降为一次快照调用。 +- 下单后下一次快照不会返回过期的订单状态。 + +### 3.3 大量使用 `dir()` 和 `getattr()` 反射序列化 + +位置: + +- `api/QMT_API.py:920-930` +- `api/QMT_API.py:943-950` +- `api/QMT_API.py:979-1031` +- `api/QMT_API.py:1454-1473` + +影响:对每个对象遍历全部属性、捕获异常并转字符串,CPU 开销大,返回字段也不稳定。 + +解决方案: + +1. 为订单、成交、资产、持仓等类型定义固定字段映射。 +2. 只返回客户端实际使用的字段。 +3. 使用统一的轻量转换函数,不在每个 Handler 复制反射循环。 +4. 对未知扩展类型单独保留调试接口,不进入高频生产路径。 + +验收标准: + +- 高频订单查询不再调用 `dir()`。 +- 返回 JSON 字段固定并有接口契约测试。 +- 相同数据量下序列化 CPU 时间明显下降。 + + +### 3.5 回调同步写 JSON 文件 + +位置:`api/QMT_API.py:1475-1516` + +影响:目录创建、反射序列化和格式化写盘可能阻塞 QMT 回调线程。 + +解决方案: + +3. 生产环境关闭 `indent=4`。 +4. 使用临时文件替换,避免半写文件。 + +验收标准: + +- 回调函数本身在毫秒级返回。 +- 磁盘慢或不可写时不会阻塞交易回调。 +- 写入失败可监控且不会静默丢失。 + +### 3.6 客户端 HTTP 没有连接池 + +位置:`py-client/sdk/client.py:28-47` + +影响:每次 `urlopen()` 都可能新建连接,高频轮询产生额外 TCP 开销。 + +解决方案: + +1. 改用支持连接池的 HTTP 客户端,如 `httpx.Client` 或 `requests.Session`。 +2. 整个策略生命周期复用一个 Client。 +3. 设置连接、读取和总超时,不只设置单一 timeout。 +4. 只对幂等查询配置有限重试;下单和撤单不能自动盲重试。 + +验收标准: + +- 连续请求复用 TCP 连接。 +- 查询超时能重试,下单超时进入“结果未知、需对账”状态而不是重复下单。 + +--- + +## 四、P1:客户端其他逻辑与可靠性问题 + +### 4.1 新开仓订单锁可能在同一轮失效 + +位置: + +- `py-client/strategy/trend/open.py:24` +- `py-client/strategy/trend/order.py:61-65` +- `py-client/strategy/trend/order.py:99-101` + +现状:开仓检查 `busy()`,该方法只查看 `data`;新下单后只把键加入 `index`,没有加入 `data`。 + +解决方案: + +1. 统一锁判断,只保留一个权威接口。 +2. 下单成功后立即插入本地 pending `OrderItem`。 +3. 信号进入处理前按证券代码去重。 +4. 每轮刷新券商订单后用真实订单覆盖本地 pending 状态。 + +验收标准:同一轮两个来源返回同一证券信号时最多提交一笔买单。 + +### 4.2 `Runtime` 文档和字段不一致 + +位置:`py-client/strategy/trend/runtime.py` + +现状:文档描述 `peak_grids`,实际 dataclass 没有该字段;持仓代码仍可能访问它。 + +解决方案: + +2. 如果统一使用 `GridTrailingTracker`,删除 `peak_grids` 及所有引用。 +3. 不应同时保留两套止盈峰值实现。 + +验收标准:项目中只有一种网格峰值状态来源。 + +### 4.3 `ping_api_host()` 参数无效且吞掉退出信号 + +位置:`py-client/main.py:59-76` + +问题: + +- `rpc_host` 参数没有使用。 +- `connect_timeout` 参数没有使用。 +- 使用裸 `except:`,会捕获 `KeyboardInterrupt` 和 `SystemExit`。 +- 无限重试没有最大日志节流或取消事件。 + +解决方案: + +1. 函数重命名为 `wait_for_qmt_api()`,删除无用参数。 +2. 仅捕获网络类异常和 `APIError`。 +3. 允许 `KeyboardInterrupt` 正常终止。 +4. 使用 `threading.Event.wait()` 或可取消等待。 + +验收标准:API 不可用时可以通过 Ctrl+C 立即退出。 + + + +### 4.5 状态文件缺少完整对账和生命周期 + +位置:`py-client/strategy/trend/state.py` + +解决方案: + +1. 启动时用真实持仓、订单和成交三方对账。 +2. `ING` 状态必须根据真实订单结果转为 `OK`、`FAILED`、`CANCELED` 或 `UNKNOWN`。 +3. 已清仓证券应从状态中删除,并清除观察器和止盈峰值。 +4. 状态文件不增加版本号,不增加新字段。 + +验收标准:程序在下单后崩溃并重启,能够从券商真实状态恢复,而不会重复下单。 + +### 4.6 日志调用格式错误且异常上下文不足 + +位置:`py-client/` + +现状:不符合 logging 格式化规则。 + +解决方案: + +1. 统一优化日志打印同时输出至文本文件(每天一个文件)。 + +验收标准:日志输出期间不出现 logging 自身的格式化异常。 + diff --git a/api/QMT_API.py b/api/QMT_API.py index a8be7b0..79e9d09 100644 --- a/api/QMT_API.py +++ b/api/QMT_API.py @@ -8,7 +8,7 @@ from tornado.web import Application, RequestHandler, HTTPError from tornado.ioloop import IOLoop import logging -# 定义 +# Configuration ACCOUNT_ID = os.environ.get('QMT_ACCOUNT_ID', '') DATA_DIR = os.environ.get('QMT_DATA_DIR', 'D:\qmt_strategy_data') TOKEN="QMTbyYanweidong" @@ -24,7 +24,7 @@ def safe_call(func, *args, **kwargs): try: return func(*args, **kwargs) except Exception as e: - logger.error(f"{func.__name__} 调用失败: {e}") + logger.error(f"{func.__name__} call failed: {e}") return None @@ -43,7 +43,7 @@ class BaseHandler(RequestHandler): if self.__class__ not in AUTH_EXEMPT: token = self.request.headers.get('X-Token') if token != TOKEN: - raise HTTPError(401, "认证失败:token 无效或缺失") + raise HTTPError(401, "Authentication failed: invalid or missing token") def set_default_headers(self): self.set_header("Content-Type", "application/json; charset=utf-8") @@ -61,60 +61,60 @@ class BaseHandler(RequestHandler): return self.application.accountID -# ============= 1. ContextInfo 属性 ============= -# ContextInfo.period - 获取当前周期 +# ============= 1. ContextInfo properties ============= +# ContextInfo.period - Get the current period class ContextPeriodHandler(BaseHandler): def get(self): self.write(json.dumps({"period": self.ctx().period}, ensure_ascii=False)) -# ContextInfo.barpos - 获取当前K线索引号 +# ContextInfo.barpos - Get the current bar index class ContextBarposHandler(BaseHandler): def get(self): self.write(json.dumps({"barpos": self.ctx().barpos}, ensure_ascii=False)) -# ContextInfo.time_tick_size - 获取当前K线数目 +# ContextInfo.time_tick_size - Get the current bar count class ContextTimeTickSizeHandler(BaseHandler): def get(self): self.write(json.dumps({"time_tick_size": self.ctx().time_tick_size}, ensure_ascii=False)) -# ContextInfo.stockcode - 获取当前主图品种代码 +# ContextInfo.stockcode - Get the current chart symbol class ContextStockCodeHandler(BaseHandler): def get(self): self.write(json.dumps({"stockcode": self.ctx().stockcode}, ensure_ascii=False)) -# ContextInfo.dividend_type - 获取当前复权方式 +# ContextInfo.dividend_type - Get the current adjustment mode class ContextDividendTypeHandler(BaseHandler): def get(self): self.write(json.dumps({"dividend_type": self.ctx().dividend_type}, ensure_ascii=False)) -# ContextInfo.market - 获取当前主图市场 +# ContextInfo.market - Get the current chart market class ContextMarketHandler(BaseHandler): def get(self): self.write(json.dumps({"market": self.ctx().market}, ensure_ascii=False)) -# ContextInfo.do_back_test - 是否开启回测模式 +# ContextInfo.do_back_test - Check whether backtesting is enabled class ContextDoBackTestHandler(BaseHandler): def get(self): self.write(json.dumps({"do_back_test": self.ctx().do_back_test}, ensure_ascii=False)) -# ContextInfo.benchmark - 获取回测基准 +# ContextInfo.benchmark - Get the backtest benchmark class ContextBenchmarkHandler(BaseHandler): def get(self): self.write(json.dumps({"benchmark": self.ctx().benchmark}, ensure_ascii=False)) -# ContextInfo.capital - 获取回测初始资金 +# ContextInfo.capital - Get the initial backtest capital class ContextCapitalHandler(BaseHandler): def get(self): self.write(json.dumps({"capital": self.ctx().capital}, ensure_ascii=False)) -# ContextInfo.get_universe() - 获取股票池中的股票 +# ContextInfo.get_universe() - Get symbols in the 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() - 根据代码获取股票名称 +# ============= 2. Data queries (ContextInfo get_*) ============= +# ContextInfo.get_stock_name() - Get a stock name by symbol class StockNameHandler(BaseHandler): def post(self): data = json.loads(self.request.body) @@ -122,7 +122,7 @@ class StockNameHandler(BaseHandler): ret = safe_call(self.ctx().get_stock_name, stockcode) self.write(json.dumps({"stockcode": stockcode, "name": ret}, ensure_ascii=False)) -# get_open_date() - 根据代码获取上市时间 +# get_open_date() - Get the listing date by symbol class OpenDateHandler(BaseHandler): def post(self): data = json.loads(self.request.body) @@ -130,17 +130,17 @@ class OpenDateHandler(BaseHandler): ret = safe_call(get_open_date, stockcode) self.write(json.dumps({"stockcode": stockcode, "open_date": ret}, ensure_ascii=False)) -# ContextInfo.get_last_volume() - 获取最新流通股本 +# ContextInfo.get_last_volume() - Get the latest outstanding shares 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, "获取流通股本失败") + raise HTTPError(500, "Failed to get outstanding shares") self.write(json.dumps({"stockcode": stockcode, "last_volume": ret}, ensure_ascii=False)) -# ContextInfo.get_bar_timetag() - 获取K线时间戳 +# ContextInfo.get_bar_timetag() - Get the bar timestamp class BarTimetagHandler(BaseHandler): def post(self): data = json.loads(self.request.body) @@ -148,13 +148,13 @@ class BarTimetagHandler(BaseHandler): ret = safe_call(self.ctx().get_bar_timetag, index) self.write(json.dumps({"index": index, "timetag": ret}, ensure_ascii=False)) -# ContextInfo.get_tick_timetag() - 获取最新分笔时间戳 +# ContextInfo.get_tick_timetag() - Get the latest tick timestamp 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() - 获取指数成份股 +# ContextInfo.get_sector() - Get index constituents class SectorHandler(BaseHandler): def post(self): data = json.loads(self.request.body) @@ -165,7 +165,7 @@ class SectorHandler(BaseHandler): 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() - 获取行业成份股 +# ContextInfo.get_industry() - Get industry constituents class IndustryHandler(BaseHandler): def post(self): data = json.loads(self.request.body) @@ -176,7 +176,7 @@ class IndustryHandler(BaseHandler): 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() - 获取板块成份股 +# ContextInfo.get_stock_list_in_sector() - Get sector constituents class StockListInSectorHandler(BaseHandler): def post(self): data = json.loads(self.request.body) @@ -186,7 +186,7 @@ class StockListInSectorHandler(BaseHandler): 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() - 获取指数中权重 +# ContextInfo.get_weight_in_index() - Get weights in an index class WeightInIndexHandler(BaseHandler): def post(self): data = json.loads(self.request.body) @@ -195,7 +195,7 @@ class WeightInIndexHandler(BaseHandler): 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() - 获取合约乘数 +# ContextInfo.get_contract_multiplier() - Get the contract multiplier class ContractMultiplierHandler(BaseHandler): def post(self): data = json.loads(self.request.body) @@ -203,7 +203,7 @@ class ContractMultiplierHandler(BaseHandler): 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() - 获取无风险利率 +# ContextInfo.get_risk_free_rate() - Get the risk-free rate class RiskFreeRateHandler(BaseHandler): def post(self): data = json.loads(self.request.body) @@ -211,7 +211,7 @@ class RiskFreeRateHandler(BaseHandler): 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线索引 +# ContextInfo.get_date_location() - Get the bar index for a date class DateLocationHandler(BaseHandler): def post(self): data = json.loads(self.request.body) @@ -219,7 +219,7 @@ class DateLocationHandler(BaseHandler): ret = safe_call(self.ctx().get_date_location, strdate) self.write(json.dumps({"strdate": strdate, "location": ret}, ensure_ascii=False)) -# ContextInfo.get_history_data() - 获取历史行情数据(多品种字典) +# ContextInfo.get_history_data() - Get historical market data for multiple symbols class HistoryDataHandler(BaseHandler): def post(self): data = json.loads(self.request.body) @@ -229,9 +229,9 @@ class HistoryDataHandler(BaseHandler): 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)) + self.write(json.dumps({"data": ret} if ret else {"error": "Failed to get historical data"}, ensure_ascii=False)) -# ContextInfo.get_market_data() - 获取行情数据(DataFrame) +# ContextInfo.get_market_data() - Get market data as a DataFrame class MarketDataHandler(BaseHandler): def post(self): data = json.loads(self.request.body) @@ -246,12 +246,12 @@ class MarketDataHandler(BaseHandler): 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, "获取行情数据失败") + raise HTTPError(500, "Failed to get market data") 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) +# ContextInfo.get_market_data_ex() - Get extended Level 2 market data class MarketDataExHandler(BaseHandler): def post(self): data = json.loads(self.request.body) @@ -266,7 +266,7 @@ class MarketDataExHandler(BaseHandler): 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, "获取扩展行情失败") + raise HTTPError(500, "Failed to get extended market data") result = {} for k, v in ret.items(): if hasattr(v, 'to_dict'): @@ -275,7 +275,7 @@ class MarketDataExHandler(BaseHandler): result[k] = str(v) self.write(json.dumps({"data": result}, ensure_ascii=False, default=str)) -# ContextInfo.get_full_tick() - 获取分笔数据 +# ContextInfo.get_full_tick() - Get full tick data class FullTickHandler(BaseHandler): def post(self): data = json.loads(self.request.body) @@ -284,10 +284,10 @@ class FullTickHandler(BaseHandler): # raise HTTPError(400, "need args stocks") ret = safe_call(self.ctx().get_full_tick, stocks) if not ret: - raise HTTPError(500, "获取分笔行情失败") + raise HTTPError(500, "Failed to get tick data") self.write(json.dumps(ret, ensure_ascii=False, default=str)) -# ContextInfo.get_divid_factors() - 获取除权除息和复权因子 +# ContextInfo.get_divid_factors() - Get dividend and adjustment factors class DividFactorsHandler(BaseHandler): def post(self): data = json.loads(self.request.body) @@ -295,7 +295,7 @@ class DividFactorsHandler(BaseHandler): 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() - 获取期货主力合约 +# ContextInfo.get_main_contract() - Get the main futures contract class MainContractHandler(BaseHandler): def post(self): data = json.loads(self.request.body) @@ -303,7 +303,7 @@ class MainContractHandler(BaseHandler): ret = safe_call(self.ctx().get_main_contract, codemarket) self.write(json.dumps({"codemarket": codemarket, "main_contract": ret}, ensure_ascii=False)) -# timetag_to_datetime() - 毫秒时间戳转日期时间 +# timetag_to_datetime() - Convert a millisecond timestamp to datetime class TimetagToDatetimeHandler(BaseHandler): def post(self): data = json.loads(self.request.body) @@ -312,7 +312,7 @@ class TimetagToDatetimeHandler(BaseHandler): ret = safe_call(timetag_to_datetime, timetag, fmt) self.write(json.dumps({"timetag": timetag, "datetime": ret}, ensure_ascii=False)) -# ContextInfo.get_total_share() - 获取总股本 +# ContextInfo.get_total_share() - Get total shares class TotalShareHandler(BaseHandler): def post(self): data = json.loads(self.request.body) @@ -320,7 +320,7 @@ class TotalShareHandler(BaseHandler): 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() - 获取交易日列表 +# ContextInfo.get_trading_dates() - Get the trading-day list class TradingDatesHandler(BaseHandler): def post(self): data = json.loads(self.request.body) @@ -333,7 +333,7 @@ class TradingDatesHandler(BaseHandler): 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() - 获取内盘成交量 +# ContextInfo.get_svol() - Get sell-side volume class SvolHandler(BaseHandler): def post(self): data = json.loads(self.request.body) @@ -341,7 +341,7 @@ class SvolHandler(BaseHandler): ret = safe_call(self.ctx().get_svol, stockcode) self.write(json.dumps({"stockcode": stockcode, "svol": ret}, ensure_ascii=False)) -# ContextInfo.get_bvol() - 获取外盘成交量 +# ContextInfo.get_bvol() - Get buy-side volume class BvolHandler(BaseHandler): def post(self): data = json.loads(self.request.body) @@ -349,7 +349,7 @@ class BvolHandler(BaseHandler): ret = safe_call(self.ctx().get_bvol, stockcode) self.write(json.dumps({"stockcode": stockcode, "bvol": ret}, ensure_ascii=False)) -# ContextInfo.get_longhubang() - 获取龙虎榜数据 +# ContextInfo.get_longhubang() - Get Dragon-Tiger List data class LonghubangHandler(BaseHandler): def post(self): data = json.loads(self.request.body) @@ -360,9 +360,9 @@ class LonghubangHandler(BaseHandler): 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)) + self.write(json.dumps({"data": ret} if ret else {"error": "Failed to get Dragon-Tiger List data"}, ensure_ascii=False, default=str)) -# get_top10_share_holder() - 获取十大股东数据 +# get_top10_share_holder() - Get top-ten shareholder data class Top10ShareHolderHandler(BaseHandler): def post(self): data = json.loads(self.request.body) @@ -374,9 +374,9 @@ class Top10ShareHolderHandler(BaseHandler): 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)) + self.write(json.dumps({"data": ret} if ret else {"error": "Failed to get top-ten shareholder data"}, ensure_ascii=False, default=str)) -# ContextInfo.get_option_detail_data() - 获取期权详细信息 +# ContextInfo.get_option_detail_data() - Get option details class OptionDetailHandler(BaseHandler): def post(self): data = json.loads(self.request.body) @@ -384,7 +384,7 @@ class OptionDetailHandler(BaseHandler): 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() - 获取换手率 +# ContextInfo.get_turnover_rate() - Get turnover rate class TurnoverRateHandler(BaseHandler): def post(self): data = json.loads(self.request.body) @@ -395,9 +395,9 @@ class TurnoverRateHandler(BaseHandler): 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)) + self.write(json.dumps({"data": ret} if ret else {"error": "Failed to get turnover rate"}, ensure_ascii=False, default=str)) -# get_etf_info() - 获取ETF申赎清单及成分股 +# get_etf_info() - Get ETF creation/redemption and constituent data class EtfInfoHandler(BaseHandler): def post(self): data = json.loads(self.request.body) @@ -405,7 +405,7 @@ class EtfInfoHandler(BaseHandler): 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基金份额参考净值 +# get_etf_iopv() - Get the ETF indicative optimized portfolio value class EtfIopvHandler(BaseHandler): def post(self): data = json.loads(self.request.body) @@ -413,7 +413,7 @@ class EtfIopvHandler(BaseHandler): ret = safe_call(get_etf_iopv, stockcode) self.write(json.dumps({"stockcode": stockcode, "iopv": ret}, ensure_ascii=False)) -# ContextInfo.get_instrumentdetail() - 获取合约详细信息 +# ContextInfo.get_instrumentdetail() - Get instrument details class InstrumentDetailHandler(BaseHandler): def post(self): data = json.loads(self.request.body) @@ -421,7 +421,7 @@ class InstrumentDetailHandler(BaseHandler): 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() - 获取期货合约到期日 +# ContextInfo.get_contract_expire_date() - Get the futures contract expiration date class ContractExpireDateHandler(BaseHandler): def post(self): data = json.loads(self.request.body) @@ -429,7 +429,7 @@ class ContractExpireDateHandler(BaseHandler): 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() - 获取期权标的对应的期权品种列表 +# ContextInfo.get_option_undl_data() - Get option products for an underlying asset class OptionUndlDataHandler(BaseHandler): def post(self): data = json.loads(self.request.body) @@ -437,7 +437,7 @@ class OptionUndlDataHandler(BaseHandler): 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() - 获取财务数据 +# ContextInfo.get_financial_data() - Get financial data class FinancialDataHandler(BaseHandler): def post(self): data = json.loads(self.request.body) @@ -460,9 +460,9 @@ class FinancialDataHandler(BaseHandler): 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)) + self.write(json.dumps({"data": ret} if ret is not None else {"error": "Failed to get financial data"}, ensure_ascii=False, default=str)) -# ContextInfo.get_factor_data() - 获取多因子数据 +# ContextInfo.get_factor_data() - Get multi-factor data class FactorDataHandler(BaseHandler): def post(self): data = json.loads(self.request.body) @@ -479,9 +479,9 @@ class FactorDataHandler(BaseHandler): 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)) + self.write(json.dumps({"data": ret} if ret is not None else {"error": "Failed to get factor data"}, ensure_ascii=False, default=str)) -# ContextInfo.get_his_st_data() - 获取历史ST数据 +# ContextInfo.get_his_st_data() - Get historical ST data class HisStDataHandler(BaseHandler): def post(self): data = json.loads(self.request.body) @@ -489,7 +489,7 @@ class HisStDataHandler(BaseHandler): 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() - 获取历史指数数据 +# ContextInfo.get_his_index_data() - Get historical index data class HisIndexDataHandler(BaseHandler): def post(self): data = json.loads(self.request.body) @@ -497,13 +497,13 @@ class HisIndexDataHandler(BaseHandler): 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() - 获取当前所有行情订阅信息 +# ContextInfo.get_all_subscription() - Get all current market-data subscriptions 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() - 获取指定期权列表 +# ContextInfo.get_option_list() - Get a specified option list class OptionListHandler(BaseHandler): def post(self): data = json.loads(self.request.body) @@ -514,7 +514,7 @@ class OptionListHandler(BaseHandler): 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() - 获取过期合约列表 +# ContextInfo.get_his_contract_list() - Get expired contracts class HisContractListHandler(BaseHandler): def post(self): data = json.loads(self.request.body) @@ -522,7 +522,7 @@ class HisContractListHandler(BaseHandler): 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() - 获取期权实时隐含波动率 +# ContextInfo.get_option_iv() - Get real-time option implied volatility class OptionIvHandler(BaseHandler): def post(self): data = json.loads(self.request.body) @@ -530,7 +530,7 @@ class OptionIvHandler(BaseHandler): ret = safe_call(self.ctx().get_option_iv, optioncode) self.write(json.dumps({"optioncode": optioncode, "iv": ret}, ensure_ascii=False)) -# ContextInfo.bsm_price() - BS模型计算欧式期权理论价格 +# ContextInfo.bsm_price() - Calculate a European option price with the Black-Scholes model class BsmPriceHandler(BaseHandler): def post(self): data = json.loads(self.request.body) @@ -548,7 +548,7 @@ class BsmPriceHandler(BaseHandler): 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模型计算欧式期权隐含波动率 +# ContextInfo.bsm_iv() - Calculate European option implied volatility class BsmIvHandler(BaseHandler): def post(self): data = json.loads(self.request.body) @@ -562,7 +562,7 @@ class BsmIvHandler(BaseHandler): 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() - 从本地获取行情数据 +# ContextInfo.get_local_data() - Get market data from local storage class LocalDataHandler(BaseHandler): def post(self): data = json.loads(self.request.body) @@ -574,10 +574,10 @@ class LocalDataHandler(BaseHandler): 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, "获取本地行情失败") + raise HTTPError(500, "Failed to get local market data") self.write(json.dumps({"data": ret}, ensure_ascii=False, default=str)) -# ContextInfo.subscribe_quote() - 订阅行情数据 +# ContextInfo.subscribe_quote() - Subscribe to market data class SubscribeQuoteHandler(BaseHandler): def post(self): data = json.loads(self.request.body) @@ -587,7 +587,7 @@ class SubscribeQuoteHandler(BaseHandler): 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() - 反订阅行情数据 +# ContextInfo.unsubscribe_quote() - Unsubscribe from market data class UnsubscribeQuoteHandler(BaseHandler): def post(self): data = json.loads(self.request.body) @@ -596,20 +596,20 @@ class UnsubscribeQuoteHandler(BaseHandler): self.write(json.dumps({"status": "success", "sub_id": sub_id}, ensure_ascii=False)) -# ============= 3. 判定函数 (is_*) ============= -# ContextInfo.is_last_bar() - 判定是否为最后一根K线 +# ============= 3. Predicate functions (is_*) ============= +# ContextInfo.is_last_bar() - Check whether this is the last bar 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线 +# ContextInfo.is_new_bar() - Check whether this is a new bar 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() - 判定股票是否停牌 +# ContextInfo.is_suspended_stock() - Check whether a stock is suspended class IsSuspendedStockHandler(BaseHandler): def post(self): data = json.loads(self.request.body) @@ -617,7 +617,7 @@ class IsSuspendedStockHandler(BaseHandler): ret = safe_call(self.ctx().is_suspended_stock, stockcode) self.write(json.dumps({"stockcode": stockcode, "is_suspended": ret}, ensure_ascii=False)) -# is_sector_stock() - 判定股票是否在指定板块中 +# is_sector_stock() - Check whether a stock belongs to a sector class IsSectorStockHandler(BaseHandler): def post(self): data = json.loads(self.request.body) @@ -627,7 +627,7 @@ class IsSectorStockHandler(BaseHandler): 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() - 判定股票是否属于某个类别 +# is_typed_stock() - Check whether a stock belongs to a category class IsTypedStockHandler(BaseHandler): def post(self): data = json.loads(self.request.body) @@ -637,7 +637,7 @@ class IsTypedStockHandler(BaseHandler): 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() - 获取股票行业分类名称 +# get_industry_name_of_stock() - Get a stock's industry classification class GetIndustryNameOfStockHandler(BaseHandler): def post(self): data = json.loads(self.request.body) @@ -647,8 +647,8 @@ class GetIndustryNameOfStockHandler(BaseHandler): self.write(json.dumps({"industryType": industryType, "stockcode": stockcode, "industry_name": ret}, ensure_ascii=False)) -# ============= 4. 交易函数 ============= -# passorder() - 综合交易下单(支持股票买卖等) +# ============= 4. Trading functions ============= +# passorder() - Submit a general trading order class PassorderHandler(BaseHandler): def post(self): try: @@ -660,18 +660,27 @@ class PassorderHandler(BaseHandler): 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()) + strategy_name = str(data.get('strategyName', '')).strip() + order_id = str(data.get('orderId', '')).strip() + # QMT stores strategyName in the order remark; preserve the signal key and local order ID. + # Put the local ID first so restart reconciliation still works if QMT truncates the remark. + remark = '|'.join(part for part in (order_id, strategy_name) if part)[:24] + order_ref = passorder(opType, orderType, self.acc(), stock, pr_type, price, volume, remark, quickTrade, self.ctx()) + if not order_ref: + raise HTTPError(502, "QMT did not return a valid order reference") self.write(json.dumps({ "status": "success", "opType": opType, "stock": stock, - "order_ref": str(order_ref) if order_ref else "unknown" + "strategy_name": strategy_name, + "local_order_id": order_id, + "order_ref": str(order_ref) }, ensure_ascii=False)) except Exception as e: - logger.exception("passorder下单异常") - raise HTTPError(400, f"下单失败: {str(e)}") + logger.exception("passorder failed") + raise HTTPError(400, f"Order submission failed: {str(e)}") -# algo_passorder() - 算法交易下单 +# algo_passorder() - Submit an algorithmic order class AlgoPassorderHandler(BaseHandler): def post(self): try: @@ -686,10 +695,10 @@ class AlgoPassorderHandler(BaseHandler): ) 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)}") + logger.exception("algo_passorder failed") + raise HTTPError(400, f"Algorithmic order submission failed: {str(e)}") -# smart_algo_passorder() - 智能算法交易下单 +# smart_algo_passorder() - Submit a smart algorithmic order class SmartAlgoPassorderHandler(BaseHandler): def post(self): try: @@ -705,10 +714,10 @@ class SmartAlgoPassorderHandler(BaseHandler): ) 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)}") + logger.exception("smart_algo_passorder failed") + raise HTTPError(400, f"Smart algorithmic order submission failed: {str(e)}") -# order_lots() - 指定手数交易 +# order_lots() - Trade a specified number of lots class OrderLotsHandler(BaseHandler): def post(self): try: @@ -717,10 +726,10 @@ class OrderLotsHandler(BaseHandler): 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)}") + logger.exception("order_lots failed") + raise HTTPError(400, f"Order submission failed: {str(e)}") -# order_value() - 指定价值交易 +# order_value() - Trade a specified value class OrderValueHandler(BaseHandler): def post(self): try: @@ -729,10 +738,10 @@ class OrderValueHandler(BaseHandler): 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)}") + logger.exception("order_value failed") + raise HTTPError(400, f"Order submission failed: {str(e)}") -# order_percent() - 指定比例交易 +# order_percent() - Trade a specified percentage class OrderPercentHandler(BaseHandler): def post(self): try: @@ -741,10 +750,10 @@ class OrderPercentHandler(BaseHandler): 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)}") + logger.exception("order_percent failed") + raise HTTPError(400, f"Order submission failed: {str(e)}") -# order_target_value() - 指定目标价值交易 +# order_target_value() - Trade to a target value class OrderTargetValueHandler(BaseHandler): def post(self): try: @@ -753,10 +762,10 @@ class OrderTargetValueHandler(BaseHandler): 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)}") + logger.exception("order_target_value failed") + raise HTTPError(400, f"Order submission failed: {str(e)}") -# order_target_percent() - 指定目标比例交易 +# order_target_percent() - Trade to a target percentage class OrderTargetPercentHandler(BaseHandler): def post(self): try: @@ -765,10 +774,10 @@ class OrderTargetPercentHandler(BaseHandler): 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)}") + logger.exception("order_target_percent failed") + raise HTTPError(400, f"Order submission failed: {str(e)}") -# order_shares() - 指定股数交易 +# order_shares() - Trade a specified number of shares class OrderSharesHandler(BaseHandler): def post(self): try: @@ -777,12 +786,12 @@ class OrderSharesHandler(BaseHandler): 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)}") + logger.exception("order_shares failed") + raise HTTPError(400, f"Order submission failed: {str(e)}") -# ============= 5. 期货交易 ============= -# buy_open() - 期货买入开仓 +# ============= 5. Futures trading ============= +# buy_open() - Open a long futures position class FuturesBuyOpenHandler(BaseHandler): def post(self): try: @@ -791,10 +800,10 @@ class FuturesBuyOpenHandler(BaseHandler): 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)}") + logger.exception("buy_open failed") + raise HTTPError(400, f"Failed to open a long futures position: {str(e)}") -# buy_close_tdayfirst() - 期货买入平仓(平今优先) +# buy_close_tdayfirst() - Close a short futures position, today first class FuturesBuyCloseTdayFirstHandler(BaseHandler): def post(self): try: @@ -803,10 +812,10 @@ class FuturesBuyCloseTdayFirstHandler(BaseHandler): 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)}") + logger.exception("buy_close_tdayfirst failed") + raise HTTPError(400, f"Failed to close a short futures position (today first): {str(e)}") -# buy_close_ydayfirst() - 期货买入平仓(平昨优先) +# buy_close_ydayfirst() - Close a short futures position, yesterday first class FuturesBuyCloseYdayFirstHandler(BaseHandler): def post(self): try: @@ -815,10 +824,10 @@ class FuturesBuyCloseYdayFirstHandler(BaseHandler): 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)}") + logger.exception("buy_close_ydayfirst failed") + raise HTTPError(400, f"Failed to close a short futures position (yesterday first): {str(e)}") -# sell_open() - 期货卖出开仓 +# sell_open() - Open a short futures position class FuturesSellOpenHandler(BaseHandler): def post(self): try: @@ -827,10 +836,10 @@ class FuturesSellOpenHandler(BaseHandler): 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)}") + logger.exception("sell_open failed") + raise HTTPError(400, f"Failed to open a short futures position: {str(e)}") -# sell_close_tdayfirst() - 期货卖出平仓(平今优先) +# sell_close_tdayfirst() - Close a long futures position, today first class FuturesSellCloseTdayFirstHandler(BaseHandler): def post(self): try: @@ -839,10 +848,10 @@ class FuturesSellCloseTdayFirstHandler(BaseHandler): 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)}") + logger.exception("sell_close_tdayfirst failed") + raise HTTPError(400, f"Failed to close a long futures position (today first): {str(e)}") -# sell_close_ydayfirst() - 期货卖出平仓(平昨优先) +# sell_close_ydayfirst() - Close a long futures position, yesterday first class FuturesSellCloseYdayFirstHandler(BaseHandler): def post(self): try: @@ -851,12 +860,12 @@ class FuturesSellCloseYdayFirstHandler(BaseHandler): 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)}") + logger.exception("sell_close_ydayfirst failed") + raise HTTPError(400, f"Failed to close a long futures position (yesterday first): {str(e)}") -# ============= 6. 任务管理 ============= -# cancel_task() - 撤销任务 +# ============= 6. Task management ============= +# cancel_task() - Cancel a task class CancelTaskHandler(BaseHandler): def post(self): try: @@ -866,10 +875,10 @@ class CancelTaskHandler(BaseHandler): 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)}") + logger.exception("cancel_task failed") + raise HTTPError(400, f"Failed to cancel task: {str(e)}") -# pause_task() - 暂停任务 +# pause_task() - Pause a task class PauseTaskHandler(BaseHandler): def post(self): try: @@ -879,10 +888,10 @@ class PauseTaskHandler(BaseHandler): 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)}") + logger.exception("pause_task failed") + raise HTTPError(400, f"Failed to pause task: {str(e)}") -# resume_task() - 继续任务 +# resume_task() - Resume a task class ResumeTaskHandler(BaseHandler): def post(self): try: @@ -892,22 +901,50 @@ class ResumeTaskHandler(BaseHandler): 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)}") + logger.exception("resume_task failed") + raise HTTPError(400, f"Failed to resume task: {str(e)}") -# do_order() - 实时触发前一根bar信号函数 +# do_order() - Trigger the previous bar's signal in real time class DoOrderHandler(BaseHandler): def post(self): try: do_order(self.ctx()) - self.write(json.dumps({"status": "success", "message": "信号已触发"}, ensure_ascii=False)) + self.write(json.dumps({"status": "success", "message": "Signal triggered"}, ensure_ascii=False)) except Exception as e: - logger.exception("do_order异常") - raise HTTPError(400, f"触发信号失败: {str(e)}") + logger.exception("do_order failed") + raise HTTPError(400, f"Failed to trigger signal: {str(e)}") -# ============= 7. 账户/订单查询 ============= -# get_trade_detail_data() - 获取交易明细(持仓/委托/成交/资金) +# ============= 7. Account and order queries ============= +# Use fixed fields for high-frequency objects to avoid full dir/getattr reflection. +TRADE_DETAIL_FIELDS = ( + 'm_strOrderSysID', 'm_strInstrumentID', 'm_strExchangeID', + 'm_strInstrumentName', 'm_nOffsetFlag', 'm_nOrderStatus', + 'm_nVolumeTotal', 'm_nVolumeTraded', 'm_nOrderTime', + 'm_strInsertDate', 'm_strInsertTime', 'm_strRemark', + 'm_dPrice', 'm_dTradePrice', 'm_dTradeAmount', +) + + +def fixed_fields(obj, fields=TRADE_DETAIL_FIELDS): + result = {} + for field in fields: + try: + value = getattr(obj, field) + except (AttributeError, TypeError): + continue + if not callable(value): + result[field] = str(value) + if not result: + attrs = getattr(obj, '__dict__', {}) + result = { + key: str(value) for key, value in attrs.items() + if not key.startswith('_') and not callable(value) + } + return result + + +# get_trade_detail_data() - Get positions, orders, trades, or account details class TradeDetailDataHandler(BaseHandler): def post(self): data = json.loads(self.request.body) @@ -916,21 +953,10 @@ class TradeDetailDataHandler(BaseHandler): ret = safe_call(get_trade_detail_data, self.acc(), account, datatype) 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) + result = [fixed_fields(obj) for obj in ret] self.write(json.dumps({"data": result}, ensure_ascii=False)) -# get_value_by_order_id() - 根据委托号获取委托/成交信息 +# get_value_by_order_id() - Get order or trade details by order ID class ValueByOrderIdHandler(BaseHandler): def post(self): data = json.loads(self.request.body) @@ -938,19 +964,10 @@ class ValueByOrderIdHandler(BaseHandler): 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 + attrs = fixed_fields(ret) if ret else {} self.write(json.dumps({"orderId": orderId, "data": attrs}, ensure_ascii=False)) -# get_last_order_id() - 获取最新委托/成交的委托号 +# get_last_order_id() - Get the latest order ID class LastOrderIdHandler(BaseHandler): def post(self): data = json.loads(self.request.body) @@ -959,7 +976,7 @@ class LastOrderIdHandler(BaseHandler): ret = safe_call(get_last_order_id, self.acc(), account, datatype) self.write(json.dumps({"last_order_id": ret}, ensure_ascii=False)) -# can_cancel_order() - 查询委托是否可撤销 +# can_cancel_order() - Check whether an order can be canceled class CanCancelOrderHandler(BaseHandler): def post(self): data = json.loads(self.request.body) @@ -968,70 +985,56 @@ class CanCancelOrderHandler(BaseHandler): 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 CancelByIdHandler(BaseHandler): + """Cancel an order by its actual system order ID.""" + def post(self): + data = json.loads(self.request.body) + order_id = str(data.get('order_id', '')).strip() + account_type = data.get('account_type', 'stock') + if not order_id: + raise HTTPError(400, "order_id cannot be empty") + cancelable = safe_call(can_cancel_order, order_id, self.acc(), account_type) + if not cancelable: + self.write(json.dumps({ + "status": "failed", "order_id": order_id, + "message": "Order does not exist or cannot currently be canceled" + }, ensure_ascii=False)) + return + result = safe_call(cancel, order_id, self.acc(), account_type, self.ctx()) + self.write(json.dumps({ + "status": "success" if result is not False else "failed", + "order_id": order_id, + }, ensure_ascii=False)) + +# get_debt_contract() - Get margin financing and securities lending debt contracts 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) + result = [fixed_fields(obj) for obj in (ret or [])] self.write(json.dumps({"data": result}, ensure_ascii=False)) -# get_assure_contract() - 获取两融担保标的明细 +# get_assure_contract() - Get margin collateral instruments 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) + result = [fixed_fields(obj) for obj in (ret or [])] self.write(json.dumps({"data": result}, ensure_ascii=False)) -# get_enable_short_contract() - 获取可融券明细 +# get_enable_short_contract() - Get securities available for short selling 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) + result = [fixed_fields(obj) for obj in (ret or [])] self.write(json.dumps({"data": result}, ensure_ascii=False)) -# get_ipo_data() - 获取当日新股新债信息 +# get_ipo_data() - Get today's new stock and bond offerings class IpoDataHandler(BaseHandler): def post(self): data = json.loads(self.request.body) @@ -1039,7 +1042,7 @@ class IpoDataHandler(BaseHandler): ret = safe_call(get_ipo_data, typ) self.write(json.dumps({"data": ret or {}}, ensure_ascii=False, default=str)) -# get_new_purchase_limit() - 获取新股申购额度 +# get_new_purchase_limit() - Get the new-share subscription quota class NewPurchaseLimitHandler(BaseHandler): def post(self): data = json.loads(self.request.body) @@ -1048,8 +1051,8 @@ class NewPurchaseLimitHandler(BaseHandler): self.write(json.dumps({"data": ret or {}}, ensure_ascii=False, default=str)) -# ============= 8. 引用函数 (ext_data) ============= -# ext_data() - 获取扩展数据数值 +# ============= 8. Reference functions (ext_data) ============= +# ext_data() - Get an extended-data value class ExtDataHandler(BaseHandler): def post(self): data = json.loads(self.request.body) @@ -1059,7 +1062,7 @@ class ExtDataHandler(BaseHandler): 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() - 获取扩展数据排名 +# ext_data_rank() - Get an extended-data ranking class ExtDataRankHandler(BaseHandler): def post(self): data = json.loads(self.request.body) @@ -1069,7 +1072,7 @@ class ExtDataRankHandler(BaseHandler): 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() - 获取因子数据 +# get_factor_value() - Get a factor value class GetFactorValueHandler(BaseHandler): def post(self): data = json.loads(self.request.body) @@ -1079,7 +1082,7 @@ class GetFactorValueHandler(BaseHandler): 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() - 获取因子数据排名 +# get_factor_rank() - Get a factor ranking class GetFactorRankHandler(BaseHandler): def post(self): data = json.loads(self.request.body) @@ -1090,8 +1093,8 @@ class GetFactorRankHandler(BaseHandler): self.write(json.dumps({"factorname": factorname, "stockcode": stockcode, "rank": ret}, ensure_ascii=False)) -# ============= 9. 原有 Handler(保持兼容) ============= -# get_trade_detail_data('position') - 查询持仓列表(封装格式) +# ============= 9. Legacy handlers (compatibility) ============= +# get_trade_detail_data('position') - Query positions in the wrapped format class HoldingHandler(BaseHandler): def post(self): data = json.loads(self.request.body) @@ -1120,7 +1123,7 @@ class HoldingHandler(BaseHandler): } self.write(json.dumps(holding, ensure_ascii=False)) -# get_trade_detail_data('account') - 查询资产 +# get_trade_detail_data('account') - Query account assets class AssetsHandler(BaseHandler): def post(self): data = json.loads(self.request.body) @@ -1128,11 +1131,11 @@ class AssetsHandler(BaseHandler): _data = safe_call(get_trade_detail_data, self.acc(), account, 'account') info = _data[0] if _data else None if not info: - raise HTTPError(500, "资金数据获取失败") + raise HTTPError(500, "Failed to get account data") self.write(json.dumps({"total": round(info.m_dBalance, 2),"available": round(info.m_dAvailable, 2)}, ensure_ascii=False)) -# get_trade_detail_data('account') - 查询总资产 +# get_trade_detail_data('account') - Query total assets class TotalMoneyHandler(BaseHandler): def post(self): data = json.loads(self.request.body) @@ -1140,10 +1143,10 @@ class TotalMoneyHandler(BaseHandler): _data = safe_call(get_trade_detail_data, self.acc(), account, 'account') info = _data[0] if _data else None if not info: - raise HTTPError(500, "资金数据获取失败") + raise HTTPError(500, "Failed to get account data") self.write(json.dumps({"total_money": round(info.m_dBalance, 2)}, ensure_ascii=False)) -# get_trade_detail_data('account') - 查询可用资金 +# get_trade_detail_data('account') - Query available cash class AvailableMoneyHandler(BaseHandler): def post(self): data = json.loads(self.request.body) @@ -1151,10 +1154,10 @@ class AvailableMoneyHandler(BaseHandler): _data = safe_call(get_trade_detail_data, self.acc(), account, 'account') info = _data[0] if _data else None if not info: - raise HTTPError(500, "资金数据获取失败") + raise HTTPError(500, "Failed to get account data") self.write(json.dumps({"available_money": round(info.m_dAvailable, 2)}, ensure_ascii=False)) -# passorder(23) - 简化买入下单(封装passorder) +# passorder(23) - Simplified buy order wrapper class BuyHandler(BaseHandler): def post(self): try: @@ -1170,10 +1173,10 @@ class BuyHandler(BaseHandler): "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)}") + logger.exception("Buy order submission failed") + raise HTTPError(400, f"Order submission failed: {str(e)}") -# passorder(24) - 简化卖出下单(封装passorder) +# passorder(24) - Simplified sell order wrapper class SellHandler(BaseHandler): def post(self): try: @@ -1189,10 +1192,10 @@ class SellHandler(BaseHandler): "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)}") + logger.exception("Sell order submission failed") + raise HTTPError(400, f"Order submission failed: {str(e)}") -# get_trade_detail_data('order') - 查询委托状态列表 +# get_trade_detail_data('order') - Query order statuses class OrderStatusHandler(BaseHandler): def post(self): data = json.loads(self.request.body) @@ -1209,7 +1212,7 @@ class OrderStatusHandler(BaseHandler): }) self.write(json.dumps({"orders": rets}, ensure_ascii=False)) -# cancel() - 全部撤单 +# cancel() - Cancel all orders class CancelAllHandler(BaseHandler): def post(self): try: @@ -1228,12 +1231,12 @@ class CancelAllHandler(BaseHandler): }) self.write(json.dumps({ "status": "success", - "message": f"已发出 {len(canceled_list)} 笔撤单请求", + "message": f"Submitted {len(canceled_list)} cancellation requests", "canceled_orders": canceled_list }, ensure_ascii=False)) except Exception as e: - logger.exception("全部撤单异常") - raise HTTPError(500, f"撤单失败: {str(e)}") + logger.exception("Cancel-all operation failed") + raise HTTPError(500, f"Order cancellation failed: {str(e)}") class CancelByRuleHandler(BaseHandler): @@ -1245,7 +1248,7 @@ class CancelByRuleHandler(BaseHandler): 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") + raise HTTPError(400, "Invalid parameters: stock is required and volume must be greater than zero") orders = safe_call(get_trade_detail_data, self.acc(), account, 'order', sn) or [] target_orders = [] for order in orders: @@ -1253,7 +1256,7 @@ class CancelByRuleHandler(BaseHandler): 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)) + self.write(json.dumps({"status": "failed", "message": "No matching active orders found"}, ensure_ascii=False)) return canceled_ids = [] for t_order in target_orders: @@ -1261,15 +1264,15 @@ class CancelByRuleHandler(BaseHandler): canceled_ids.append(t_order.m_strOrderSysID) self.write(json.dumps({ "status": "success", - "message": f"匹配到 {len(target_orders)} 笔订单并发出撤单请求", + "message": f"Matched {len(target_orders)} orders and submitted cancellation requests", "canceled_sys_ids": canceled_ids }, ensure_ascii=False)) except Exception as e: - logger.exception("规则撤单异常") - raise HTTPError(500, f"撤单失败: {str(e)}") + logger.exception("Rule-based cancellation failed") + raise HTTPError(500, f"Order cancellation failed: {str(e)}") -# cancel() - 按股票+数量匹配规则撤单 -# sys: Python版本信息 +# cancel() - Cancel orders by symbol and volume matching rules +# sys: Python version information class PythonVersionHandler(BaseHandler): def get(self): import sys @@ -1285,44 +1288,33 @@ class PythonVersionHandler(BaseHandler): } self.write(json.dumps(version_info, ensure_ascii=False)) -# sys: 关闭HTTP服务 +# sys: Shut down the HTTP service class ShutdownHandler(BaseHandler): def post(self): - logger.info("收到关闭请求,服务器即将停止...") - self.write(json.dumps({"status": "success", "message": "服务器正在关闭..."}, ensure_ascii=False)) + logger.info("Shutdown request received; the server is stopping...") + self.write(json.dumps({"status": "success", "message": "Server is shutting down..."}, ensure_ascii=False)) self.finish() IOLoop.current().add_callback(IOLoop.current().stop) -# get_trade_detail_data('deal') - 查询成交明细 +# get_trade_detail_data('deal') - Query trade details class DealHandler(BaseHandler): def post(self): data = json.loads(self.request.body) account = data.get('account', 'stock') sn = data.get('sn','') deals = safe_call(get_trade_detail_data, self.acc(), account, 'deal', sn) 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) + rets = [fixed_fields(deal) for deal in deals] self.write(json.dumps({"deals": rets}, ensure_ascii=False)) -# ============= 路由注册 ============= +# ============= Route registration ============= def make_app(): return Application([ # V2 (r"/api/v2/positions", HoldingHandler), (r"/api/v2/assets", AssetsHandler), - # 原有兼容路由 + # Legacy compatibility routes (r"/api/holding", HoldingHandler), (r"/api/money/total", TotalMoneyHandler), (r"/api/money/available", AvailableMoneyHandler), @@ -1331,9 +1323,10 @@ def make_app(): (r"/api/order/status", OrderStatusHandler), (r"/api/order/cancel_all", CancelAllHandler), (r"/api/order/cancel_order", CancelByRuleHandler), + (r"/api/order/cancel_by_id", CancelByIdHandler), (r"/api/order/deal", DealHandler), - # ContextInfo 属性 + # ContextInfo properties (r"/api/context/period", ContextPeriodHandler), (r"/api/context/barpos", ContextBarposHandler), (r"/api/context/time_tick_size", ContextTimeTickSizeHandler), @@ -1345,7 +1338,7 @@ def make_app(): (r"/api/context/capital", ContextCapitalHandler), (r"/api/context/universe", ContextUniverseHandler), - # 数据查询 + # Data queries (r"/api/data/stock_name", StockNameHandler), (r"/api/data/open_date", OpenDateHandler), (r"/api/data/last_volume", LastVolumeHandler), @@ -1390,11 +1383,11 @@ def make_app(): (r"/api/data/bsm_iv", BsmIvHandler), (r"/api/data/local_data", LocalDataHandler), - # 订阅 + # Subscriptions (r"/api/data/subscribe_quote", SubscribeQuoteHandler), (r"/api/data/unsubscribe_quote", UnsubscribeQuoteHandler), - # 判定函数 + # Predicate functions (r"/api/check/is_last_bar", IsLastBarHandler), (r"/api/check/is_new_bar", IsNewBarHandler), (r"/api/check/is_suspended_stock", IsSuspendedStockHandler), @@ -1402,7 +1395,7 @@ def make_app(): (r"/api/check/is_typed_stock", IsTypedStockHandler), (r"/api/check/get_industry_name_of_stock", GetIndustryNameOfStockHandler), - # 交易 + # Trading (r"/api/trade/passorder", PassorderHandler), (r"/api/trade/algo_passorder", AlgoPassorderHandler), (r"/api/trade/smart_algo_passorder", SmartAlgoPassorderHandler), @@ -1413,7 +1406,7 @@ def make_app(): (r"/api/trade/order_target_percent", OrderTargetPercentHandler), (r"/api/trade/order_shares", OrderSharesHandler), - # 期货交易 + # Futures trading (r"/api/trade/futures/buy_open", FuturesBuyOpenHandler), (r"/api/trade/futures/buy_close_tdayfirst", FuturesBuyCloseTdayFirstHandler), (r"/api/trade/futures/buy_close_ydayfirst", FuturesBuyCloseYdayFirstHandler), @@ -1421,13 +1414,13 @@ def make_app(): (r"/api/trade/futures/sell_close_tdayfirst", FuturesSellCloseTdayFirstHandler), (r"/api/trade/futures/sell_close_ydayfirst", FuturesSellCloseYdayFirstHandler), - # 任务管理 + # Task management (r"/api/trade/cancel_task", CancelTaskHandler), (r"/api/trade/pause_task", PauseTaskHandler), (r"/api/trade/resume_task", ResumeTaskHandler), (r"/api/trade/do_order", DoOrderHandler), - # 账户/订单查询 + # Account and order queries (r"/api/trade/trade_detail_data", TradeDetailDataHandler), (r"/api/trade/value_by_order_id", ValueByOrderIdHandler), (r"/api/trade/last_order_id", LastOrderIdHandler), @@ -1438,19 +1431,19 @@ def make_app(): (r"/api/trade/ipo_data", IpoDataHandler), (r"/api/trade/new_purchase_limit", NewPurchaseLimitHandler), - # 引用函数 + # Reference functions (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), - # 系统 + # System (r"/api/sys/python_version", PythonVersionHandler), (r"/api/sys/shutdown", ShutdownHandler), ], debug=False) -# ============= Callback 注册 ============= +# ============= Callback registration ============= def json_serializer(obj): if isinstance(obj, datetime.datetime): return obj.strftime("%Y-%m-%d %H:%M:%S") @@ -1458,28 +1451,19 @@ def json_serializer(obj): return obj.strftime("%Y-%m-%d") if hasattr(obj, 'to_dict'): return obj.to_dict() - attrs = {} - for name in dir(obj): - if name.startswith('_'): - continue - try: - val = getattr(obj, name) - except Exception: - continue - if not callable(val): - attrs[name] = val + attrs = getattr(obj, '__dict__', None) if attrs: - return attrs + return {key: value for key, value in attrs.items() if not key.startswith('_')} return str(obj) def write_json(file_key, data,order_id:str=''): """ - 将数据写入 JSON 文件 + Write data to a JSON file. - 参数: - file_key (str): 文件名模板 - data: 要写入的数据 - order_id (str): 订单ID,可选 + Args: + file_key (str): File-name template. + data: Data to write. + order_id (str): Optional order ID. """ now=datetime.datetime.now() day = now.strftime("%Y%m%d") @@ -1487,54 +1471,58 @@ def write_json(file_key, data,order_id:str=''): "created_at": now, "body":data } - # 构建路径 + # Build the target path. if order_id: file_path = Path(DATA_DIR) / (file_key % (day,order_id)) else: file_path = Path(DATA_DIR) / (file_key % day) - # 创建目录 + # Create the target directory. 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) + temporary_path = file_path.with_suffix(file_path.suffix + '.tmp') + with open(temporary_path, 'w', encoding='utf-8') as f: + json.dump(body, f, ensure_ascii=False, separators=(',', ':'), default=json_serializer) + f.flush() + os.fsync(f.fileno()) + os.replace(temporary_path, file_path) -# 资金账号主推函数 +# Account update callback def account_callback(ContextInfo, accountInfo): write_json("acount_%s.json",accountInfo) -# 委托主推函数 +# Order update callback def order_callback(ContextInfo, orderInfo): write_json("order_%s_%s.json",orderInfo,orderInfo.m_strOrderSysID) -# 成交主推函数 +# Trade update callback def deal_callback(ContextInfo, dealInfo): write_json("deal_%s_%s.json",dealInfo,dealInfo.m_strOrderSysID) -# 持仓主推函数 +# Position update callback def position_callback(ContextInfo, positonInfo): write_json("position_%s.json",positonInfo) -#下单出错回调函数 +# Order error callback def orderError_callback(ContextInfo, passOrderInfo, msg): print('orderError_callback') - #输出下单信息以及错误信息 + # Log order and error details. print (passOrderInfo.orderCode) print (msg) def init(ContextInfo): if not (ACCOUNT_ID or "").strip(): - msg = "ACCOUNT_ID 为空,无法启动" + msg = "ACCOUNT_ID is empty; startup aborted" logger.error(msg) raise ValueError(msg) if not (DATA_DIR or "").strip(): - msg = "DATA_DIR 为空,无法启动" + msg = "DATA_DIR is empty; startup aborted" logger.error(msg) raise ValueError(msg) try: ContextInfo.accountID = ACCOUNT_ID ContextInfo.set_account(ACCOUNT_ID) - # 按需加载股票池 + # Load the symbol universe only when configured. pass_codes_path = Path(DATA_DIR) / "pass_codes.json" with pass_codes_path.open("r", encoding="utf-8") as stream: codes = json.load(stream) @@ -1548,8 +1536,8 @@ def init(ContextInfo): 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已加载)") + logger.info(f"Initialized symbol universe with {len(codes)} instruments") + logger.info(f"QMT HTTP Server started at http://0.0.0.0:{PORT} (all APIs loaded)") IOLoop.current().start() except Exception as e: logger.exception(f"server start failed: {e}") diff --git a/py-client/config/__init__.py b/py-client/config/__init__.py index ee290ea..5f99fd1 100644 --- a/py-client/config/__init__.py +++ b/py-client/config/__init__.py @@ -7,7 +7,7 @@ from pathlib import Path import yaml -@dataclass +@dataclass(slots=True) class SignalConfig: """单个交易信号的数据源及开仓限制配置。""" @@ -21,7 +21,7 @@ class SignalConfig: gt_last_price_is_open: bool = False -@dataclass +@dataclass(slots=True) class GlobalConfig: """所有主机共享的系统配置。""" @@ -37,7 +37,7 @@ class GlobalConfig: signals: dict[str, SignalConfig] = field(default_factory=dict) -@dataclass +@dataclass(slots=True) class AccountConfig: """当前主机所使用的账户及交易策略参数。""" diff --git a/py-client/libs/grid_take_profit.py b/py-client/libs/grid_take_profit.py index 60de290..786e9d8 100644 --- a/py-client/libs/grid_take_profit.py +++ b/py-client/libs/grid_take_profit.py @@ -20,7 +20,7 @@ class GridState(str, Enum): STEADY = "steady" # 仍处于当前峰值网格,继续持有 -@dataclass(frozen=True) +@dataclass(frozen=True, slots=True) class GridObservation: """一次网格观察的不可变结果。""" diff --git a/py-client/libs/signal.py b/py-client/libs/signal.py index aedbbe8..bfccea4 100644 --- a/py-client/libs/signal.py +++ b/py-client/libs/signal.py @@ -4,12 +4,12 @@ import secrets from .http import get_json -@dataclass +@dataclass(slots=True) class SignalItem: signal_key: str = ""; code: str = ""; name: str = ""; desc: str = ""; last_close: float = 0 tech_indicator: dict[str, float] = field(default_factory=dict) -@dataclass +@dataclass(slots=True) class SignalResult: code: str = ""; total: int = 0; updated: str = ""; data: dict[str, SignalItem] = field(default_factory=dict); message: str = "" diff --git a/py-client/main.py b/py-client/main.py index beea784..ddf0dbb 100644 --- a/py-client/main.py +++ b/py-client/main.py @@ -2,23 +2,25 @@ # -*- coding: utf-8 -*- import logging as log +from logging.handlers import TimedRotatingFileHandler import os import sys import time import config from dataclasses import dataclass import yaml +import httpx PROJECT_ROOT = os.path.dirname(os.path.abspath(__file__)) GLOBAL_CONFIG_PATH = os.path.join(PROJECT_ROOT, "etc", "_global.yaml") if PROJECT_ROOT not in sys.path: sys.path.insert(0, PROJECT_ROOT) -from sdk import Client +from sdk import APIError, Client from strategy.trend.boot import StartTrend -@dataclass(frozen=True) +@dataclass(frozen=True, slots=True) class StrategyDefinition: mutex_scope: str start_strategy: object @@ -56,24 +58,47 @@ def check_single_instance(project_root: str) -> bool: return False -def ping_api_host( - rpc_host: str, - retry_interval: float = 5.0, - connect_timeout: float = 3.0, -) -> None: +def wait_for_qmt_api(retry_interval: float = 5.0) -> None: """循环检查 API 地址,连通后才返回。""" client = Client(config.global_config.qmt_base_url, config.global_config.qmt_token, config.HTTP_TIMEOUT) - while True: + retry_event = __import__("threading").Event() + while not retry_event.is_set(): try: - assets = client.assets() + client.assets() log.info(f"API 服务已连通:{config.global_config.qmt_base_url}") + client.close() return - except: + except (APIError, httpx.RequestError) as exc: log.warning( - f"API 服务未就绪:{config.global_config.qmt_base_url},{retry_interval:g} 秒后重试" + "API 服务未就绪:%s,%g 秒后重试:%s", + config.global_config.qmt_base_url, + retry_interval, + exc, ) - time.sleep(retry_interval) + retry_event.wait(retry_interval) + + +def configure_logging(data_dir: str) -> None: + """同时输出控制台日志和按天轮转的文本日志。""" + log_dir = os.path.join(data_dir, "logs") + os.makedirs(log_dir, exist_ok=True) + root = log.getLogger() + root.setLevel(log.INFO) + formatter = log.Formatter("%(asctime)s [%(levelname)s] %(message)s") + if not root.handlers: + console = log.StreamHandler() + console.setFormatter(formatter) + root.addHandler(console) + file_handler = TimedRotatingFileHandler( + os.path.join(log_dir, "py-client.log"), + when="midnight", + interval=1, + backupCount=30, + encoding="utf-8", + ) + file_handler.setFormatter(formatter) + root.addHandler(file_handler) def wait_for_any_key() -> None: print("按任意键退出...", flush=True) @@ -96,12 +121,12 @@ def main() -> int: config.load() if config.global_config is None or config.account_config is None: raise RuntimeError("配置尚未加载,请先调用 config.load()") - - ping_api_host(config.global_config.qmt_base_url) + configure_logging(config.global_config.qmt_data_dir) + wait_for_qmt_api() STRATEGIES[config.account_config.strategy].start_strategy() return 0 - except (OSError, yaml.YAMLError, ValueError) as exc: + except (OSError, yaml.YAMLError, ValueError, RuntimeError, KeyError) as exc: print(f"启动失败: {exc}", file=sys.stderr, flush=True) wait_for_any_key() return 1 diff --git a/py-client/requirements.txt b/py-client/requirements.txt new file mode 100644 index 0000000..2179c67 --- /dev/null +++ b/py-client/requirements.txt @@ -0,0 +1,2 @@ +httpx>=0.27,<1 +PyYAML>=6.0 diff --git a/py-client/sdk/client.py b/py-client/sdk/client.py index 8d9dfcb..c37bcd5 100644 --- a/py-client/sdk/client.py +++ b/py-client/sdk/client.py @@ -1,10 +1,9 @@ from __future__ import annotations -import json from dataclasses import asdict, is_dataclass from typing import Any -from urllib.error import HTTPError -from urllib.request import Request, urlopen + +import httpx from .errors import APIError, BusinessError @@ -14,11 +13,28 @@ def csv_join(items: list[str]) -> str: class Client: + """复用连接池的同步 QMT HTTP 客户端。""" + def __init__(self, base_url: str, token: str, timeout: float = 15.0) -> None: self.base_url = base_url.rstrip("/") self.token = token self.timeout = timeout if timeout > 0 else 15.0 self.account_type = "stock" + self.http = httpx.Client( + base_url=self.base_url, + headers={"X-Token": token, "Accept": "application/json"}, + timeout=httpx.Timeout(self.timeout), + limits=httpx.Limits(max_connections=20, max_keepalive_connections=10), + ) + + def close(self) -> None: + self.http.close() + + def __enter__(self) -> "Client": + return self + + def __exit__(self, *_args: object) -> None: + self.close() def set_account_type(self, account_type: str) -> "Client": if account_type.strip(): @@ -26,28 +42,38 @@ class Client: return self def _request(self, method: str, path: str, body: Any = None) -> Any: - data = None - headers = {"X-Token": self.token, "Accept": "application/json"} - if method != "GET": - if body is None: body = {} - if is_dataclass(body): body = asdict(body) - data = json.dumps(body, ensure_ascii=False).encode() - headers["Content-Type"] = "application/json" - request = Request(self.base_url + path, data=data, headers=headers, method=method) + if is_dataclass(body): + body = asdict(body) + attempts = 2 if _is_idempotent(method, path) else 1 + response: httpx.Response | None = None + for attempt in range(attempts): + try: + response = self.http.request(method, path, json=body) + break + except (httpx.ConnectError, httpx.ReadTimeout): + if attempt + 1 == attempts: + raise + assert response is not None + if response.status_code >= 400: + try: + message = response.json().get("error", response.text) + except (ValueError, AttributeError): + message = response.text.strip() + raise APIError(response.status_code, str(message)) + if not response.content: + return None try: - with urlopen(request, timeout=self.timeout) as response: - raw = response.read() - except HTTPError as exc: - raw = exc.read() - try: message = json.loads(raw).get("error", raw.decode(errors="replace")) - except (ValueError, AttributeError): message = raw.decode(errors="replace").strip() - raise APIError(exc.code, str(message)) from exc - if not raw: return None - try: return json.loads(raw) - except ValueError as exc: raise ValueError(f"invalid JSON from {path}: {raw[:512]!r}") from exc + return response.json() + except ValueError as exc: + raise ValueError( + f"invalid JSON from {path}: {response.content[:512]!r}" + ) from exc - def _get(self, path: str) -> Any: return self._request("GET", path) - def _post(self, path: str, body: Any = None) -> Any: return self._request("POST", path, body) + def _get(self, path: str) -> Any: + return self._request("GET", path) + + def _post(self, path: str, body: Any = None) -> Any: + return self._request("POST", path, {} if body is None else body) def _get_field(self, path: str, key: str) -> Any: return self._get(path).get(key) @@ -57,3 +83,20 @@ class Client: if isinstance(result, dict) and result.get("error"): raise BusinessError(result["error"]) return result.get(key, result) if key and isinstance(result, dict) else result + + +def _is_idempotent(method: str, path: str) -> bool: + if method == "GET": + return True + prefixes = ( + "/api/v2/", + "/api/holding", + "/api/money/", + "/api/context/", + "/api/check/", + "/api/data/", + "/api/trade/trade_detail_data", + "/api/order/deal", + ) + unsafe = ("subscribe", "unsubscribe") + return path.startswith(prefixes) and not any(word in path for word in unsafe) diff --git a/py-client/sdk/models.py b/py-client/sdk/models.py index 9ae8e8b..bf75864 100644 --- a/py-client/sdk/models.py +++ b/py-client/sdk/models.py @@ -11,7 +11,7 @@ def _number(value: Any, kind: type = float) -> Any: return kind() -@dataclass +@dataclass(slots=True) class Position: stock_code: str = "" stock_name: str = "" @@ -44,20 +44,20 @@ class Position: ) -@dataclass +@dataclass(slots=True) class Assets: total: float = 0.0 available: float = 0.0 -@dataclass +@dataclass(slots=True) class Tick: last_price: float = 0.0 last_close: float = 0.0 raw: dict[str, Any] = field(default_factory=dict) -@dataclass +@dataclass(slots=True) class HistoryDataRequest: length: int = 10 period: str = "" @@ -66,7 +66,7 @@ class HistoryDataRequest: skip_paused: bool = True -@dataclass +@dataclass(slots=True) class MarketDataRequest: fields: list[str] = field(default_factory=list) stocks: list[str] = field(default_factory=list) @@ -77,7 +77,7 @@ class MarketDataRequest: count: int = 0 -@dataclass +@dataclass(slots=True) class FinancialDataRequest: tabname: str = ""; colname: str = ""; market: str = ""; code: str = "" report_type: str = ""; barpos: int = 0 @@ -85,22 +85,22 @@ class FinancialDataRequest: start_date: str = ""; end_date: str = "" -@dataclass +@dataclass(slots=True) class FactorDataRequest: field_list: list[str] = field(default_factory=list); stock_list: list[str] = field(default_factory=list) stock_code: str = ""; start_date: str = ""; end_date: str = "" -@dataclass +@dataclass(slots=True) class BSMPriceRequest: option_type: str; object_prices: Any; strike_price: float; risk_free: float; sigma: float; days: int; dividend: float -@dataclass +@dataclass(slots=True) class BSMIVRequest: option_type: str; object_prices: float; strike_price: float; option_price: float; risk_free: float; days: int; dividend: float -@dataclass +@dataclass(slots=True) class LocalDataRequest: stock_code: str; start_time: str = ""; end_time: str = ""; period: str = ""; divid_type: str = ""; count: int = 0 diff --git a/py-client/sdk/trade.py b/py-client/sdk/trade.py index 3bb1aa1..e3fb494 100644 --- a/py-client/sdk/trade.py +++ b/py-client/sdk/trade.py @@ -13,9 +13,20 @@ class TradeMixin: if value: body[key] = value return self._post("/api/trade/passorder", body) - def passorder_latest(self, side, stock, volume): return self.passorder_latest_tagged(side, stock, volume, "") - def passorder_latest_tagged(self, side, stock, volume, order_id): - return self.passorder(side, stock, volume, ORDER_TYPE_VOLUME, PR_TYPE_LATEST, -1, QUICK_TRADE_NOW, order_id) + def passorder_latest(self, side, stock, volume): return self.passorder_latest_tagged(side, stock, volume, "", "") + def passorder_latest_tagged(self, side, stock, volume, strategy_name, order_id): + body = { + "opType": side, + "orderType": ORDER_TYPE_VOLUME, + "stock": stock, + "prType": PR_TYPE_LATEST, + "price": -1, + "volume": volume, + "quickTrade": QUICK_TRADE_NOW, + "strategyName": strategy_name, + "orderId": order_id, + } + return self._post("/api/trade/passorder", body) def algo_passorder(self, **kwargs): return self._post("/api/trade/algo_passorder", kwargs) def smart_algo_passorder(self, **kwargs): return self._post("/api/trade/smart_algo_passorder", kwargs) @@ -46,6 +57,7 @@ class TradeMixin: def value_by_order_id(self, order_id, datatype): return self._post("/api/trade/value_by_order_id", {"orderId": order_id, "accountType": self.account_type, "datatype": datatype}).get("data") def last_order_id(self, datatype): return self._post("/api/trade/last_order_id", {"account": self.account_type, "datatype": datatype}).get("last_order_id") def can_cancel_order(self, order_id): return self._post("/api/trade/can_cancel_order", {"orderId": order_id, "accountType": self.account_type}).get("can_cancel") + def cancel_by_id(self, order_id): return self._post("/api/order/cancel_by_id", {"order_id": order_id, "account_type": self.account_type}) def debt_contract(self): return self._contract("debt_contract") def assure_contract(self): return self._contract("assure_contract") def enable_short_contract(self): return self._contract("enable_short_contract") diff --git a/py-client/strategy/trend/boot.py b/py-client/strategy/trend/boot.py index 15566e8..33e2350 100644 --- a/py-client/strategy/trend/boot.py +++ b/py-client/strategy/trend/boot.py @@ -12,6 +12,7 @@ from datetime import datetime import config from libs import init_signals, market_allow_open, trading_time from sdk import Client +from libs.grid_take_profit import GridTrailingTracker from .state import State from .order import OrderBook from .watch import DipWatch @@ -78,7 +79,9 @@ def StartTrend() -> None: config.account_config.strategy, config.account_config.account_id, ) - storeState.sync_positions(positions) + orders = client.trade_detail_data("order") + deals = client.deals() + storeState.reconcile(positions, orders, deals) # 获取本策略的信号开仓数据 signals = init_signals(config.global_config,["morning","tail","arbitrage"]) @@ -90,6 +93,7 @@ def StartTrend() -> None: orders=OrderBook(), open_watch=DipWatch(), add_watch=DipWatch(), + profit_tracker=GridTrailingTracker(config.account_config.grid_step_pct), ) logging.info( @@ -129,9 +133,9 @@ def RunOnce(run: Runtime, signals) -> None: except Exception: logging.exception("获取资产失败") return - if assets.available < assets.total * run.account_cfg.min_cash_ratio: + allow_open_by_cash = assets.available >= assets.total * run.account_cfg.min_cash_ratio + if not allow_open_by_cash: logging.info("资金总闸:可用金额太少,禁止开新仓") - return # 3. 获取大盘状态,只有大盘信号允许时才执行开仓。 market_ok = market_allow_open(run.global_cfg.api_host) @@ -143,11 +147,23 @@ def RunOnce(run: Runtime, signals) -> None: logging.exception("获取持仓失败") return + active_codes = set(position_codes) + removed_codes = set(run.state.codes) - active_codes + for code in removed_codes: + run.state.delete(code) + run.open_watch.forget(code) + run.add_watch.forget(code) + if removed_codes: + run.state.save() + # 5. 验证有效开仓信号:排除已有持仓,并按 signal_allow 过滤。 position_code_set = set(position_codes) - allow_open = [ - signal for signal in signals if signal.code not in position_code_set - ] + allow_open = [] + seen_codes = set(position_code_set) + for signal in signals: + if signal.code not in seen_codes: + allow_open.append(signal) + seen_codes.add(signal.code) # 6. 获取持仓和待开仓证券的实时行情 tick。 all_codes = list(position_codes) @@ -161,7 +177,7 @@ def RunOnce(run: Runtime, signals) -> None: return # 7. 执行开仓:必须同时存在有效信号且大盘允许开仓。 - if allow_open and market_ok: + if allow_open and market_ok and allow_open_by_cash: open_signal(run, ticks, allow_open) # 8. 持仓计算。当前 Go 版本的 managePositions 为空,保留扩展入口。 diff --git a/py-client/strategy/trend/open.py b/py-client/strategy/trend/open.py index 485c187..a92e3b6 100644 --- a/py-client/strategy/trend/open.py +++ b/py-client/strategy/trend/open.py @@ -41,7 +41,14 @@ def open_signal(run, ticks, open_signals) -> None: # 6. 生成本地订单号并按最新价提交开仓委托。 order_id = run.orders.new_order_id("base") - request = PlaceOrderRequest(run.client, OP_BUY, item.code, volume, order_id) + request = PlaceOrderRequest( + run.client, + OP_BUY, + item.code, + volume, + order_id, + item.signal_key, + ) if not run.orders.place(request): continue diff --git a/py-client/strategy/trend/order.py b/py-client/strategy/trend/order.py index dcfccb3..a94be70 100644 --- a/py-client/strategy/trend/order.py +++ b/py-client/strategy/trend/order.py @@ -15,7 +15,7 @@ OFFSET_FLAG = {"23": "BUY", "24": "SELL", "48": "BUY", "49": "SELL"} BUSY_STATUSES = {"48", "49", "50", "51", "52", "55"} -@dataclass(frozen=True) +@dataclass(frozen=True, slots=True) class PlaceOrderRequest: """``OrderBook.place`` 提交委托所需的全部参数。""" @@ -24,9 +24,10 @@ class PlaceOrderRequest: code: str volume: int order_id: str + strategy_name: str -@dataclass +@dataclass(slots=True) class OrderItem: """从 QMT 委托明细转换得到的本地订单记录。""" @@ -37,6 +38,7 @@ class OrderItem: status: str created_at: datetime | None volume: int + local_order_id: str = "" class OrderBook: @@ -50,8 +52,8 @@ class OrderBook: @staticmethod def new_order_id(leg: str) -> str: - """生成不超过 24 个字符的策略订单号。""" - return f"zt-{leg}-{secrets.token_hex(6)}"[:24] + """生成短订单号,为 QMT 备注中的信号键预留空间。""" + return f"zt-{leg[:1]}-{secrets.token_hex(4)}" def is_lock(self, side: str, code: str) -> bool: """判断证券在指定买卖方向上是否已经被委托锁定。""" @@ -61,8 +63,9 @@ class OrderBook: def busy(self, code: str, side: str) -> bool: """判断证券是否存在仍在处理中的同方向委托。""" with self.lock: - order = self.data.get(f"{side}-{code}") - return bool(order and order.status in BUSY_STATUSES) + key = f"{side}-{code}" + order = self.data.get(key) + return key in self.index or bool(order and order.status in BUSY_STATUSES) def refresh(self, client: Any) -> None: """从 QMT 刷新当前委托明细和方向索引。""" @@ -71,7 +74,9 @@ class OrderBook: ] with self.lock: self.data = {key: item for key, item in parsed_orders} - self.index = [key for key, _ in parsed_orders] + self.index = [ + key for key, item in parsed_orders if item.status in BUSY_STATUSES + ] def cancel_expired(self, client: Any, now: datetime | None = None) -> None: """尝试撤销超过有效期且具有委托编号的订单。""" @@ -85,20 +90,39 @@ class OrderBook: and current - order.created_at > self.timeout and order.id ): - client.can_cancel_order(order.id) + client.cancel_by_id(order.id) def place(self, request: PlaceOrderRequest) -> bool: """按最新价提交委托,并立即写入本地方向锁。""" - request.client.passorder_latest_tagged( + result = request.client.passorder_latest_tagged( request.op, request.code, request.volume, + request.strategy_name, request.order_id, ) + if not isinstance(result, dict): + return False + order_ref = str(result.get("order_ref") or "").strip().lower() + if result.get("status") != "success" or order_ref in {"", "unknown", "none"}: + return False side = OFFSET_FLAG.get(str(request.op), "") + pending = OrderItem( + id=order_ref, + code=request.code, + side=side, + remark=request.order_id, + status="48", + created_at=datetime.now(), + volume=request.volume, + local_order_id=request.order_id, + ) with self.lock: - self.index.append(f"{side}-{request.code}") + key = f"{side}-{request.code}" + self.data[key] = pending + if key not in self.index: + self.index.append(key) return True @@ -126,6 +150,7 @@ def parse_order(row: dict[str, Any]) -> tuple[str, OrderItem]: status=str(row.get("m_nOrderStatus") or ""), created_at=created_at, volume=volume, + local_order_id=_local_order_id(str(row.get("m_strRemark") or "")), ) return f"{item.side}-{item.code}", item @@ -146,3 +171,8 @@ def _parse_insert_datetime(row: dict[str, Any]) -> datetime | None: return datetime.strptime(date + clock, "%Y%m%d%H%M%S") except ValueError: return None + + +def _local_order_id(remark: str) -> str: + """兼容 ``local_order_id|signal_key`` 形式的 QMT 备注。""" + return remark.split("|", 1)[0] if remark else "" diff --git a/py-client/strategy/trend/positions.py b/py-client/strategy/trend/positions.py index d99f41a..b97d6f2 100644 --- a/py-client/strategy/trend/positions.py +++ b/py-client/strategy/trend/positions.py @@ -1,167 +1,187 @@ -"""趋势策略持仓管理逻辑,对应 Go 版本的 ``logic/positions.go``。""" +"""趋势策略持仓止盈与分级补仓。""" from __future__ import annotations -import logging -from math import floor +import logging +from dataclasses import dataclass + +from libs.calc import calc_buy_volume, calculate_min_profit_rate +from libs.grid_take_profit import GridState +from sdk import OP_BUY, OP_SELL, Position, Tick -from libs.calc import calc_buy_volume,calculate_min_profit_rate -from libs.grid_take_profit import GridState, GridTrailingTracker -from sdk import OP_BUY, OP_SELL -import config from .order import PlaceOrderRequest -from .state import STATUS_ING, STATUS_NONE, STATUS_OK from .runtime import Runtime +from .state import STATUS_ING LEG_BASE = "base" LEG_ADDED = "add" +LOSS_TIERS = (-30.0, -50.0) -# 止盈网格跟踪器延迟初始化,避免导入模块时账户配置尚未加载。 -profit_tracker = None -# 分级补仓档位(百分比) -LOSS_TIERS = [-30, -50] -# 补仓反弹确认阈值(百分比) -LOSS_REBOUND_THRESHOLD = 0.5 +@dataclass(frozen=True, slots=True) +class TradeDecision: + """一次止盈或补仓判断的统一结果。""" -def manage_positions(run:Runtime, ticks, positions, market_ok: bool,available:float) -> None: - """执行持仓计算。""" - logging.info(f"持仓:{len(positions)} 支股票,开始处理") - global profit_tracker - profit_tracker = GridTrailingTracker(step=run.account_cfg.grid_step_pct) - for idx,pos in positions: - code = pos['stock_code'] - avg_price = pos.get('avg_price', 0) - volume = pos.get('volume', 0) - can_use_volume = pos.get('can_use_volume', 0) - current_price = ticks.get(code, {}).get('lastPrice', 0) - strategy_name = pos.get('strategy_name', '') - market_value = pos.get('market_value',0) - profit = pos.get('profit_rate', 0) + submitted: bool + message: str = "" + reserved_cash: float = 0.0 - # 排除指定股票 - if code in config.account_config.excluded_codes: + +def manage_positions( + runtime: Runtime, + ticks: dict[str, Tick], + positions: list[Position], + market_ok: bool, + available: float, +) -> None: + """处理所有真实持仓,并在本轮内统一控制补仓预算。""" + active_keys = { + _position_key(runtime, position.stock_code) + for position in positions + if position.volume > 0 and position.stock_code + } + runtime.profit_tracker.retain(active_keys) + remaining_cash = max(0.0, available) + + logging.info("[持仓] 共 %d 只,开始处理", len(positions)) + for position in positions: + code = position.stock_code + tick = ticks.get(code) + if code in runtime.account_cfg.excluded_codes: + continue + if ( + not code + or position.open_price <= 0 + or position.volume <= 0 + or tick is None + or tick.last_price <= 0 + ): continue - # 过滤无效仓位 - if avg_price == 0 or can_use_volume == 0 or current_price == 0 or volume == 0: - continue + pnl_rate = round( + (tick.last_price - position.open_price) / position.open_price * 100, + 2, + ) + minimum_profit = calculate_min_profit_rate(position.open_price, 1) + profit_decision = handle_profit( + runtime=runtime, + position=position, + tick=tick, + pnl_rate=pnl_rate, + minimum_profit=minimum_profit, + ) + if profit_decision.message: + logging.info("[止盈] %s %s", code, profit_decision.message) - # 计算盈亏率(百分比) - pnl_ratio = (current_price - avg_price) / avg_price * 100 if avg_price != 0 else 0 - pnl_ratio = round(pnl_ratio, 2) + if runtime.account_cfg.enable_loss_add_position and market_ok: + loss_decision = handle_loss( + runtime=runtime, + position=position, + tick=tick, + pnl_rate=pnl_rate, + available=remaining_cash, + ) + remaining_cash -= loss_decision.reserved_cash + if loss_decision.message: + logging.info("[补仓] %s %s", code, loss_decision.message) - # 计算最小利润率:1倍 - min_profit_rate_val = calculate_min_profit_rate(avg_price, 1) - # 盈利处理 - is_closed, message = handle_profit(run,code,avg_price, pnl_ratio, min_profit_rate_val, can_use_volume, strategy_name) - if is_closed: - logging.info("profit", code, f"止盈执行 | {message}") - if message != "": - logging.info("profit", code, message) - - # 补仓处理 - if config.account_config.enable_loss_add_position and market_ok: - is_replenished, message = handle_loss(run,code,current_price,pnl_ratio,market_value,market_ok,available) - if is_replenished: - logging.info("loss", code, f"补仓执行 | {message}") - if message != "": - logging.info("loss", code, message) - -# 盈利处理 -def handle_profit(run:Runtime, code: str, pnl_rate: float, - min_profit_rate: float, vol: int) -> tuple[bool, str]: - """ - 盈利处理 - 基于网格的止盈策略 - - Args: - code: 股票代码 - open_price: 开仓价格 - pnl_rate: 当前盈亏率(百分比) - min_profit_rate: 最小利润率阈值 - vol: 可用股数 - strategy_name: str - - Returns: - tuple[bool, str]: (是否执行平仓, 操作说明) - """ - # 预检查:未达到最小利润率 - if pnl_rate < min_profit_rate: - return False, "" - - position_key = f"{run.account_cfg.account_id}:{code}" - observation = profit_tracker.observe(position_key, pnl_rate) +def handle_profit( + runtime: Runtime, + position: Position, + tick: Tick, + pnl_rate: float, + minimum_profit: float, +) -> TradeDecision: + """基于跨轮保存的最高盈利网格判断是否提交止盈。""" + if pnl_rate < minimum_profit: + return TradeDecision(False) + key = _position_key(runtime, position.stock_code) + observation = runtime.profit_tracker.observe(key, pnl_rate) if observation.state == GridState.ARMED: - msg = f"首次达到{pnl_rate}%,设置峰值网格{observation.current_grid}" - return False, msg - + return TradeDecision( + False, + f"首次达到 {pnl_rate:.2f}%,峰值网格={observation.current_grid}", + ) if observation.state == GridState.RAISED: - return False, f"上涨至{pnl_rate}%,更新峰值网格{observation.current_grid}" - - # 执行平仓 - if observation.state == GridState.RETREAT: - order_id = run.orders.new_order_id(LEG_BASE) - request = PlaceOrderRequest(run.client, OP_SELL, code, vol, order_id) - result = run.orders.place(request) - if result : - success_msg = f"✓ 委托成功 | {vol}股 订单号:{result} 等待成交" - logging.info("profit", code, success_msg) - return True, success_msg - else: - fail_msg = f"止盈委托失败: {code}" - logging.error("profit", code, "✗ 止盈委托失败") - return False, fail_msg + return TradeDecision( + False, + f"上涨至 {pnl_rate:.2f}%,峰值网格={observation.current_grid}", + ) + if observation.state in {GridState.STEADY}: + return TradeDecision(False) + if runtime.orders.busy(position.stock_code, "SELL"): + return TradeDecision(False, "卖出委托处理中") + + volume = position.can_use_volume - position.can_use_volume % 100 + if volume <= 0: + return TradeDecision(False, "无可用整手持仓") + order_id = runtime.orders.new_order_id(LEG_BASE) + request = PlaceOrderRequest( + client=runtime.client, + op=OP_SELL, + code=position.stock_code, + volume=volume, + order_id=order_id, + strategy_name=runtime.account_cfg.strategy, + ) + if not runtime.orders.place(request): + return TradeDecision(False, "止盈委托失败") + return TradeDecision(True, f"卖出 {volume} 股,订单={order_id}") -def handle_loss(run:Runtime, code: str, current_price,pnl_rate,market_value: float,market_ok: bool, available: float) -> tuple[bool, str]: - """满足条件时提交补仓委托,并返回扣减后的剩余预算。""" - state = run.state.get(code) - added_num = state.get('added_num',0) - # 预检查:未达到最低补仓阈值 - if pnl_rate > LOSS_TIERS[added_num]: - return False, "" +def handle_loss( + runtime: Runtime, + position: Position, + tick: Tick, + pnl_rate: float, + available: float, +) -> TradeDecision: + """按亏损档位、反弹确认和本轮剩余预算提交补仓。""" + try: + state = runtime.state.get(position.stock_code) + except KeyError: + return TradeDecision(False, "缺少持仓状态,跳过补仓") - # 强制条件 - if current_price>200 or market_value>=60000: - return False, f"成本价{current_price}>200,仓位价值{market_value}>=60000, 不补仓" - - # 1. 大盘必须允许开仓,且价格已从观察低点达到反弹阈值。 - if not market_ok or not run.add_watch.triggered("补仓", code, current_price): - return False + if state.added_num >= len(LOSS_TIERS): + return TradeDecision(False, "已达到最大补仓次数") + if pnl_rate > LOSS_TIERS[state.added_num]: + return TradeDecision(False) + if tick.last_price > 200 or position.market_value >= 60_000: + return TradeDecision(False, "价格或仓位市值超过补仓限制") + if not runtime.add_watch.triggered("补仓", position.stock_code, tick.last_price): + return TradeDecision(False, "等待价格反弹确认") + if runtime.orders.busy(position.stock_code, "BUY"): + return TradeDecision(False, "买入委托处理中") - # 2. 计算补仓数量和预计占用金额。 - volume = calc_buy_volume(current_price, run.account_cfg.buy_value) - amount = current_price * volume + volume = calc_buy_volume(tick.last_price, runtime.account_cfg.buy_value) + amount = tick.last_price * volume + if volume <= 0 or amount > available: + return TradeDecision(False, "本轮可用资金不足") - # 3. 检查预算。 - if amount > available: - return False, f"f{code} f{amount} 仓位资金不够补仓" + order_id = runtime.orders.new_order_id(LEG_ADDED) + request = PlaceOrderRequest( + client=runtime.client, + op=OP_BUY, + code=position.stock_code, + volume=volume, + order_id=order_id, + strategy_name=runtime.account_cfg.strategy, + ) + if not runtime.orders.place(request): + return TradeDecision(False, "补仓委托失败") - # 是否已有未完成的买入委托 - if run.orders.busy(run, code, "BUY"): - return False, f"{code}订单锁定中" - - # 4. 生成补仓订单号并提交买入委托。 - order_id = run.orders.new_order_id(LEG_ADDED) - request = PlaceOrderRequest(run.client, OP_BUY, code, volume, order_id) - result = run.orders.place(request) - if result : - state.added_num = +1 - state.added_status = run.state.STATUS_ING - state.added_order_id = order_id - run.state.set(state) - run.state.save() - run.add_watch.forget(code) - return True,f"补仓委托成功: {code} {volume}手, 等待成交确认" - else: - return False,f"补仓失败: {code}" + state.added_num += 1 + state.added_status = STATUS_ING + state.added_order_id = order_id + state.added_qty = volume + state.added_cost = tick.last_price + runtime.state.set(state) + runtime.state.save() + runtime.add_watch.forget(position.stock_code) + return TradeDecision(True, f"买入 {volume} 股,订单={order_id}", amount) -def forget(run, code: str) -> None: - """持仓退出后清理开仓、补仓观察记录和止盈峰值。""" - - - - run.peak_grids.pop(f"{code}|{LEG_ADDED}", None) +def _position_key(runtime: Runtime, code: str) -> str: + return f"{runtime.account_cfg.account_id}:{code}" diff --git a/py-client/strategy/trend/runtime.py b/py-client/strategy/trend/runtime.py index 1b7f98b..40b0a18 100644 --- a/py-client/strategy/trend/runtime.py +++ b/py-client/strategy/trend/runtime.py @@ -6,6 +6,7 @@ from dataclasses import dataclass, field from config import AccountConfig, GlobalConfig from sdk import Client +from libs.grid_take_profit import GridTrailingTracker from .order import OrderBook from .state import State @@ -27,7 +28,7 @@ class Runtime: orders: 当前活动委托和证券方向锁。 open_watch: 新开仓使用的价格反弹观察器。 add_watch: 亏损补仓使用的价格反弹观察器。 - peak_grids: ``证券代码|仓位类型`` 到最高盈利网格的映射。 + profit_tracker: 跨轮保存的账户持仓最高盈利网格跟踪器。 """ # 外部服务与账户配置。 @@ -40,4 +41,4 @@ class Runtime: orders: OrderBook open_watch: DipWatch add_watch: DipWatch - + profit_tracker: GridTrailingTracker diff --git a/py-client/strategy/trend/state.py b/py-client/strategy/trend/state.py index 263cad4..a836c75 100644 --- a/py-client/strategy/trend/state.py +++ b/py-client/strategy/trend/state.py @@ -15,6 +15,9 @@ from sdk import Position STATUS_NONE = "" STATUS_ING = "ING" STATUS_OK = "OK" +STATUS_FAILED = "FAILED" +STATUS_CANCELED = "CANCELED" +STATUS_UNKNOWN = "UNKNOWN" @dataclass(slots=True) @@ -110,6 +113,33 @@ class State: self.save() + def reconcile( + self, + positions: Iterable[Position], + orders: list[dict[str, str]], + deals: list[dict[str, str]], + ) -> None: + """用真实持仓、委托和成交恢复本地状态,不增加持久化字段。""" + position_list = list(positions) + self.sync_positions(position_list) + active_codes = { + item.stock_code for item in position_list if item.volume > 0 + } + for code in list(self.codes): + if code not in active_codes: + self.delete(code) + + for code in list(self.codes): + item = self.get(code) + item.base_status = _reconcile_leg( + item.base_order_id, item.base_status, orders, deals + ) + item.added_status = _reconcile_leg( + item.added_order_id, item.added_status, orders, deals + ) + self.set(item) + self.save() + def save(self) -> None: """将内存状态格式化写入 JSON,并原子替换正式文件。""" with self.lock: @@ -144,3 +174,40 @@ class State: } except (TypeError, ValueError) as exc: raise ValueError(f"[状态] 状态字段无效: {exc}") from exc + + +def _reconcile_leg( + local_order_id: str, + current_status: str, + orders: list[dict[str, str]], + deals: list[dict[str, str]], +) -> str: + if current_status != STATUS_ING or not local_order_id: + return current_status + if any(local_order_id in row.get("m_strRemark", "") for row in deals): + return STATUS_OK + order = next( + ( + row for row in orders + if local_order_id in row.get("m_strRemark", "") + ), + None, + ) + if order is None: + return STATUS_UNKNOWN + traded = _as_int(order.get("m_nVolumeTraded")) + status = str(order.get("m_nOrderStatus", "")) + if traded > 0 and status not in {"48", "49", "50", "51", "52", "55"}: + return STATUS_OK + if status in {"54", "56"}: + return STATUS_CANCELED + if status in {"57", "58"}: + return STATUS_FAILED + return STATUS_ING + + +def _as_int(value: object) -> int: + try: + return int(value or 0) + except (TypeError, ValueError): + return 0 diff --git a/py-client/strategy/trend/watch.py b/py-client/strategy/trend/watch.py index 95e0ed0..bab726b 100644 --- a/py-client/strategy/trend/watch.py +++ b/py-client/strategy/trend/watch.py @@ -4,7 +4,7 @@ from threading import Lock import logging -@dataclass +@dataclass(slots=True) class _Entry: last_close: float expires_at: datetime diff --git a/py-client/tests/test_trend.py b/py-client/tests/test_trend.py new file mode 100644 index 0000000..aec5ade --- /dev/null +++ b/py-client/tests/test_trend.py @@ -0,0 +1,137 @@ +from __future__ import annotations + +import unittest +from tempfile import TemporaryDirectory +from types import SimpleNamespace +from unittest.mock import patch + +from libs.grid_take_profit import GridState, GridTrailingTracker +from sdk import Assets, Position, Tick +from strategy.trend.order import OrderBook, PlaceOrderRequest +from strategy.trend.positions import LOSS_TIERS, handle_loss, manage_positions +from strategy.trend.boot import RunOnce +from strategy.trend.state import STATUS_OK, State, StateItem + + +class FakeClient: + def __init__(self): + self.orders = [] + + def passorder_latest_tagged(self, op, code, volume, strategy_name, order_id): + self.orders.append((op, code, volume, strategy_name, order_id)) + return {"status": "success", "order_ref": f"broker-{len(self.orders)}"} + + +class TrendTests(unittest.TestCase): + def test_grid_states_and_account_isolation(self): + tracker = GridTrailingTracker(1) + self.assertEqual(tracker.observe("A:code", 2.1).state, GridState.ARMED) + self.assertEqual(tracker.observe("A:code", 3.1).state, GridState.RAISED) + self.assertEqual(tracker.observe("A:code", 2.9).state, GridState.RETREAT) + self.assertEqual(tracker.observe("B:code", 2.9).state, GridState.ARMED) + tracker.retain([]) + self.assertEqual(tracker.observe("A:code", 2.9).state, GridState.ARMED) + + def test_order_book_locks_duplicate_order(self): + client = FakeClient() + book = OrderBook() + request = PlaceOrderRequest(client, 23, "000001.SZ", 100, "local", "morning") + self.assertTrue(book.place(request)) + self.assertTrue(book.busy("000001.SZ", "BUY")) + + def test_position_dataclasses_execute_without_type_error(self): + with TemporaryDirectory() as directory: + state = State.for_strategy(directory, "trend", "A") + position = Position( + stock_code="000001.SZ", volume=100, can_use_volume=100, + open_price=10, market_value=1000, + ) + state.sync_positions([position]) + runtime = SimpleNamespace( + client=FakeClient(), state=state, orders=OrderBook(), + open_watch=SimpleNamespace(forget=lambda _code: None), + add_watch=SimpleNamespace(triggered=lambda *_args: False, forget=lambda _code: None), + profit_tracker=GridTrailingTracker(1), + account_cfg=SimpleNamespace( + account_id="A", excluded_codes=[], grid_step_pct=1, + enable_loss_add_position=False, buy_value=5000, + strategy="trend", + ), + ) + manage_positions(runtime, {"000001.SZ": Tick(last_price=10.1)}, [position], True, 5000) + + def test_loss_tier_boundary_does_not_overflow(self): + self.assertEqual(len(LOSS_TIERS), 2) + with TemporaryDirectory() as directory: + state = State.for_strategy(directory, "trend", "A") + position = Position(stock_code="A", volume=100, open_price=10, market_value=1000) + state.sync_positions([position]) + item = state.get("A") + item.added_num = len(LOSS_TIERS) + state.set(item) + runtime = SimpleNamespace( + state=state, account_cfg=SimpleNamespace(buy_value=5000, strategy="trend"), + add_watch=SimpleNamespace(triggered=lambda *_args: True), orders=OrderBook(), + client=FakeClient(), + ) + decision = handle_loss(runtime, position, Tick(last_price=5), -60, 5000) + self.assertFalse(decision.submitted) + + def test_loss_tiers_zero_and_one(self): + with TemporaryDirectory() as directory: + state = State.for_strategy(directory, "trend", "A") + position = Position(stock_code="A", volume=100, open_price=10, market_value=1000) + state.sync_positions([position]) + runtime = SimpleNamespace( + state=state, account_cfg=SimpleNamespace(buy_value=5000, strategy="trend"), + add_watch=SimpleNamespace(triggered=lambda *_args: False), + orders=OrderBook(), client=FakeClient(), + ) + first = handle_loss(runtime, position, Tick(last_price=7), -30, 5000) + self.assertIn("等待", first.message) + item = state.get("A") + item.added_num = 1 + state.set(item) + before_second_tier = handle_loss(runtime, position, Tick(last_price=6), -40, 5000) + self.assertEqual(before_second_tier.message, "") + second = handle_loss(runtime, position, Tick(last_price=5), -50, 5000) + self.assertIn("等待", second.message) + + def test_reconcile_ing_order_from_deal(self): + with TemporaryDirectory() as directory: + state = State.for_strategy(directory, "trend", "A") + position = Position(stock_code="A", volume=100, open_price=10) + state.set(StateItem("A", base_order_id="local-1", base_status="ING")) + state.reconcile( + [position], + [], + [{"m_strRemark": "local-1|morning"}], + ) + self.assertEqual(state.get("A").base_status, STATUS_OK) + + def test_low_cash_still_runs_position_management(self): + client = SimpleNamespace( + assets=lambda: Assets(total=10000, available=10), + positions=lambda: (["A"], [Position(stock_code="A", volume=100, open_price=10)]), + full_tick=lambda _codes: {"A": Tick(last_price=11)}, + ) + runtime = SimpleNamespace( + client=client, + account_cfg=SimpleNamespace(min_cash_ratio=0.1), + global_cfg=SimpleNamespace(api_host="http://example"), + orders=SimpleNamespace(cancel_expired=lambda _client: None), + state=SimpleNamespace(codes=["A"]), + ) + with ( + patch("strategy.trend.boot.trading_time", return_value=True), + patch("strategy.trend.boot.market_allow_open", return_value=True), + patch("strategy.trend.boot.open_signal") as open_mock, + patch("strategy.trend.boot.manage_positions") as manage_mock, + ): + RunOnce(runtime, []) + open_mock.assert_not_called() + manage_mock.assert_called_once() + + +if __name__ == "__main__": + unittest.main()