diff --git a/README.md b/README.md index 2e021ce..0496778 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ **让 Agent 交易像移动支付一样,成为每个普通投资者触手可及的基础能力。** -guling-trader 是一个跑在 Windows 上的开源交易执行客户端。它把你登录的同花顺独立委托客户端(xiadan.exe)接入任意 AI 助手(Claude / Cursor / openclaw 等),通过 MCP(Model Context Protocol)协议暴露 9 个交易工具——市价/限价买卖、查持仓、查资金、查委托/成交、查交割单、读自选股等——让 AI 可以直接帮你研究、盯盘、下单,而整个过程中你的账号密码始终不离开同花顺官方软件。 +guling-trader 是一个跑在 Windows 上的开源交易执行客户端。它把你登录的同花顺独立委托客户端(xiadan.exe)接入任意 AI 助手(Claude / Cursor / openclaw 等),通过 MCP(Model Context Protocol)协议暴露交易工具——市价/限价买卖、查持仓、查资金、查委托/成交、查交割单、读自选股等——让 AI 可以直接帮你研究、盯盘、下单,而整个过程中你的账号密码始终不离开同花顺官方软件。 接入后,你可以对 AI 说: > "帮我对手价买入 100 股贵州茅台。" @@ -174,7 +174,7 @@ AI 助手部分(Claude、Cursor 等)在任何系统都能跑;只需确保 ## MCP 工具接口速查表 -配对成功后解锁全部 9 个交易工具。完整 Schema 见 [`docs/tools_schema.json`](docs/tools_schema.json)。 +配对成功后解锁全部交易工具。完整 Schema 见 [`docs/tools_schema.json`](docs/tools_schema.json)。 | 工具 | 用途 | 关键参数 | |------|------|---------| @@ -184,9 +184,41 @@ AI 助手部分(Claude、Cursor 等)在任何系统都能跑;只需确保 | `orders_filled` | 当日已成交记录 | — | | `settlement` | 交割单查询 | `date_range`:近一周/近一月/近三月/近一年 | | `watchlist` | 读同花顺自选股代码(新版)| —(顶部第一屏,按同花顺习惯最新在顶部)| -| `buy` | 买入(实盘) | `stock_no`, `amount`, `price`(不传=五档即成剩撤市价单/传=限价挂单), `client_order_id`(可选) | -| `sell` | 卖出(实盘) | `stock_no`, `amount`, `price`(不传=五档即成剩撤市价单/传=限价挂单), `client_order_id`(可选) | -| `cancel` | 撤销未成交单 | `entrust_no` | +| `buy` | 买入(实盘) | `stock_no`, `amount`, `order_type`(`LIMIT`/`FIVE_LEVEL_IOC`), `price`(仅 LIMIT 传正数), `client_order_id`(必填) | +| `sell` | 卖出(实盘) | `stock_no`, `amount`, `order_type`(`LIMIT`/`FIVE_LEVEL_IOC`), `price`(仅 LIMIT 传正数), `client_order_id`(必填) | +| `cancel` | 撤销未成交单;未登记订单可要求确认 | `entrust_no`, `client_order_id`(必填) | +| `confirm_external_cancel` | 确认撤销未登记/人工订单 | `confirmation_token`, 新的 `client_order_id`(必填) | + +`buy`、`sell`、`cancel`、`confirm_external_cancel` 的 `client_order_id` 必须是 +`gl-<小写 UUID v7>`,例如 `gl-0198f6a1-0001-7000-8000-000000000001`。调用方创建请求时 +生成并持久保存:每个新订单、撤单或确认撤单动作使用新 ID;网络重发同一动作必须复用原 ID。 +`confirm_external_cancel` 必须使用不同于产生令牌的 `cancel` 的新 ID。交易端只验证和防重, +不会生成或改写 ID。 + +买卖必须显式指定 `order_type`:`LIMIT` 表示限价挂单,必须传入有限且大于 0 的 `price`; +`FIVE_LEVEL_IOC` 表示五档即成剩撤,禁止传入 `price`。同一个 +`client_order_id` 不能在这两种订单语义之间切换;网络重试必须连同原参数原样复用 ID。 + +默认本地配置 `external_cancel_confirmation=two_step`(桌面端“未登记订单撤单需二次确认” +开关开启)。本系统台账已经登记的订单按 `entrust_no` 匹配,`cancel` 会直接执行;未登记的 +人工、手机端或其他外部订单,`cancel` 只会返回 `confirmation_required` 和 60 秒一次性的 +`confirmation_token`,不会点击同花顺 GUI。调用 `confirm_external_cancel` 时,交易端会消费 +该令牌、重新读取含终态的全量委托表,并逐项核验合同号、证券代码、方向、委托价、委托数量、 +已成数量和可撤状态仍与令牌生成时一致,才会执行撤单。令牌过期、已使用、连接/进程重启,或 +订单发生变化时都不会撤单。令牌绝不写入本地台账;需要重新确认时,用原 `cancel` 的 +`client_order_id` 再次调用 `cancel`,交易端会重新读取订单并换发令牌,仍不会点击 GUI。 + +关闭该本地开关即为 `external_cancel_confirmation=direct`:无论订单是否由本系统登记, +`cancel` 都按普通撤单路径直接执行,不要求 `confirm_external_cancel`。这只改变是否需要 +人工确认,不会改变幂等或核验规则。 + +买卖返回 `submitted_unconfirmed` 时会自动做一次只读 `query_order`,结果在 +`data.auto_query`;它绝不自动重发下单。撤单返回该状态时会按目标 `entrust_no` 自动读取 +一次含终态的全量委托表;只有 `data.auto_query.data.cancel_state` 为 `已撤` 或 +`部成后已撤` 才表示柜台已确认撤单。`query_order` 对实际撤单动作(直接 `cancel` 或 +`confirm_external_cancel`)都按保存的目标 `entrust_no` 精确核验,不使用买卖单的启发式匹配。 +超时或结果未知时,交易端绝不自动重发真实撤单;只能由调用方使用同一动作的 +`client_order_id` 显式取得幂等回执或调用 `query_order` 核验。 > 未配对时仅暴露 `pair_with_code` 一个工具;完整帧协议(握手、call、reply、reject、心跳)见 [`docs/PROTOCOL.md`](docs/PROTOCOL.md)。 @@ -201,8 +233,14 @@ AI 助手部分(Claude、Cursor 等)在任何系统都能跑;只需确保 # 安装依赖(包括 build 额外包) pip install -e .[build] -# 编译成单文件 exe -pyinstaller --onefile --windowed --name guling-trader -m trader +# 编译成单文件 exe(与 .github/workflows/build.yml 一致) +pyinstaller --onefile --windowed ` + --name guling-trader ` + --paths src ` + --icon src/trader/assets/icon.ico ` + --collect-submodules trader ` + --collect-data certifi ` + run_trader.py # 输出路径:dist\guling-trader.exe ``` @@ -229,7 +267,7 @@ guling-trader/ │ ├── bootstrap.py # 启动引导 │ ├── brand.py # 品牌/版本管理 │ ├── config.py # 配置加载 -│ ├── dispatcher.py # 9 个交易工具定义(FALLBACK_TOOLS_SCHEMA) +│ ├── dispatcher.py # 交易工具定义(FALLBACK_TOOLS_SCHEMA) │ ├── handshake.py # WebSocket 握手逻辑 │ ├── ws_client.py # WebSocket 客户端 │ ├── ui_dialogs.py # UI 对话框 diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index cc85058..172d96d 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -109,7 +109,7 @@ When the AI Client triggers a tool, the Gateway unwraps the tool parameter block "stock_no": "600000", "amount": 100, "price": 7.58, - "client_order_id": "custom-uuid" + "client_order_id": "gl-0198f6a1-0001-7000-8000-000000000001" } } ``` @@ -117,8 +117,8 @@ When the AI Client triggers a tool, the Gateway unwraps the tool parameter block ### 3.2. Trader-to-Gateway: `reply` Envelope(契约 v2) -reply 帧本身仍是单层 `{type,id,ok,result|error}`;**`result` 一律是契约 v2 信封**, -所有工具无例外形(含 buy/sell/cancel,含失败与 busy): +reply 帧本身仍是单层 `{type,id,ok,result|error}`;除发现接口 `tools/list` 外,业务工具的 +**`result` 一律是契约 v2 信封**,含 buy/sell/cancel/confirm_external_cancel 的失败与 busy: ```json { @@ -133,6 +133,10 @@ reply 帧本身仍是单层 `{type,id,ok,result|error}`;**`result` 一律是 `contract_version` 亦通过网关 `initialize` 的 `serverInfo.contract_version` 暴露, 消费侧无需先调业务工具即可判版。 +`tools/list` 是唯一的发现接口例外:其 `reply.result` 为裸 +`{"tools": [...]}`,方便网关直接取得 JSON Schema;它不是业务回执,消费侧不能把它按 +契约 v2 信封解析。 + #### `code` 值域(机器枚举) | code | 含义 | status | @@ -149,13 +153,14 @@ reply 帧本身仍是单层 `{type,id,ok,result|error}`;**`result` 一律是 | `invalid_params` | 参数非法 / coid 复用冲突 | failed | | `ledger_unavailable` | 下单台账不可用(**已拒单**) | failed | | `not_found` | query_order 查无此单 / 撤单找不到该委托 | failed | +| `confirmation_required` | 未登记订单撤单需要显式确认,尚未点击 GUI | failed | | `aborted` | 本笔已被超时作废(代次机制) | failed | | `unsupported_method` | 方法不在白名单 | failed | | `internal_error` | 受控端内部错误 | failed | #### ⚠️ `status: failed` **不等于**「未提交」 -`code == submitted_unconfirmed` 时委托**可能已经在柜台**。判定必须看 `code`,不能看 +`code == submitted_unconfirmed` 时交易动作**可能已经在柜台**。判定必须看 `code`,不能看 `status`。此时调用方唯一安全动作是**用同一 `client_order_id` 原样重发**(幂等,见 3.2.3),或调 `query_order` 核实;**禁止改单重下**。 @@ -163,14 +168,15 @@ reply 帧本身仍是单层 `{type,id,ok,result|error}`;**`result` 一律是 * **结构性判定**(我方控制流得出,可靠):`busy` `call_timeout` `unknown_outcome` `not_bound` `plugin_disabled` `read_failed` `table_mismatch` `invalid_params` - `ledger_unavailable` `not_found` `aborted` `internal_error` + `ledger_unavailable` `not_found` `confirmation_required` `aborted` `internal_error` * **柜台原文尽力映射**:`insufficient_funds` `price_out_of_limit` `invalid_quantity` `suspended` `no_permission` `broker_timeout`,**认不出一律 `unknown`**。 `broker_msg` 永远保留柜台原文。**`class == unknown` 与所有 unknown_outcome 一律不可自动重试**——关键词表是尽力而为的,误判「可重试」会真的重复下单。 不可自动重试集合:`unknown` `unknown_outcome` `insufficient_funds` `no_permission` -`invalid_quantity` `invalid_params` `ledger_unavailable`。 +`invalid_quantity` `invalid_params` `ledger_unavailable` `confirmation_required`。后者必须由 +用户显式确认后再调用 `confirm_external_cancel`,不能由通用重试器代为继续。 #### 3.2.1 busy 背压语义(G3) @@ -199,22 +205,79 @@ reply 帧本身仍是单层 `{type,id,ok,result|error}`;**`result` 一律是 * coid **不写入柜台**(同花顺委托无自定义字段),仅存于受控端本地台账(SQLite, 保留 ≥5 交易日)。 -* **幂等**:`buy`/`sell`/`cancel` 传 coid 后,同 id 重复提交**绝不产生第二次提交**, - 返回首次记录的回执;首次结果尚未落定时返回 `submitted_unconfirmed`—— - 这是合法态,不是 bug(最危险那一刻台账自己也不知道结果)。 +* **必填、格式与责任边界**:`buy`/`sell`/`cancel`/`confirm_external_cancel` 的 coid 必须为 + `gl-<小写 UUID v7>`,例如 `gl-0198f6a1-0001-7000-8000-000000000001`。UUID v7 + 含毫秒时间戳和随机位;调用方必须在创建业务请求时生成并持久保存它,网络重发、 + `query_order` 查询同一交易动作时原样复用。交易端只验证格式和台账幂等,不生成、 + 不改写,也不能替调用方判断两个未持久化的请求是否属于同一笔业务订单。 +* **幂等**:同 id 重复提交**绝不产生第二次提交**,返回首次记录的回执;首次结果 + 尚未落定时返回 `submitted_unconfirmed`——这是合法态,不是 bug(最危险那一刻台账 + 自己也不知道结果)。同 id 不同参数仍会被拒绝。 * 同 id **不同参数** → `invalid_params` 拒绝执行(调用方 id 复用 bug,不静默)。 * **台账不可用一律拒单**(`ledger_unavailable`),禁静默降级为无幂等下单。 -* **回显是尽力而为**:`orders_active` / `orders_filled` 按 entrust_no join 回显 coid; - 回查不到合同编号的单(超时那批)与外部/人工单为 `null`。**对账主键是 entrust_no, - coid 是增强关联**。 -* 建议 coid 全局唯一且含账户维度——受控端 `switch_account` 是盲切,对账户身份无感知。 +* **回显是尽力而为**:`orders_active` / `orders_filled` 仅按原买卖单的 entrust_no join + 回显 coid;撤单动作引用同一编号但不会覆盖原买卖单的回显。回查不到合同编号的单 + (超时那批)与外部/人工单为 `null`。**对账主键是 entrust_no,coid 是增强关联**。 +* coid 应全局唯一;调用方的持久记录必须把它与目标账户绑定。固定 UUID v7 格式不 + 携带账户文本;受控端启动后不会把任何账户视为已核验。连接完成后会发送一次只读的 + `account_event`,列出可选账户,供调用方选择;该事件丢失时调用方必须使用 +`list_accounts` 查询。每次连接后的首次 `buy`/`sell`/`cancel`/`confirm_external_cancel` 前必须 + 成功调用 `switch_account(slot)` 明确选择账户,即使目标已经是当前账户也一样。该工具会把 + 槽位对应的下拉列表文本与控件 `0x094C` 当前文本核对;目标已是当前账户时不发送热键, + 否则才发送 `Alt+N`。成功后才建立本进程基线。此后每笔交易前都会重新读取并比对;读取 + 失败或文本变化时禁止买卖和撤单,不读取订单表、不消费人工撤单令牌,也不向同花顺发送 + 交易输入。 +* `buy`/`sell` 首次或幂等重放返回 `submitted_unconfirmed` 时,受控端会自动执行**一次** + 只读 `query_order`,把结果放入 `data.auto_query`;顶层仍保持 + `submitted_unconfirmed`,不会把启发式命中伪装成精确确认,**绝不自动重发下单**。 +* `cancel` 点击确认后会短暂只读轮询 F3 委托表。只有唯一目标行明确显示 `已撤`/`部撤` 才 + 返回成功;目标消失、仍在飞、状态不明、错表或读取失败都返回 `submitted_unconfirmed`,因为 + F3 中消失也可能是订单已全部成交,不能伪装成撤单成功。其首次或幂等重放返回 + `submitted_unconfirmed` 时,受控端会用该撤单请求保存的目标 `entrust_no`,自动读取**一次** + 含终态的内部全量委托表,结果同样放入 `data.auto_query`;它不会调用买卖单的启发式 + 查询,也**绝不自动重发撤单**。只在全量表精确命中且 `cancel_state` 为 `已撤` 或 + `部成后已撤` 时,才可判定柜台已确认撤单;`已成`、`仍在飞`、`废单`、`未知`都不是撤单成功。 + 表读取失败、零命中或多命中时保守返回 `未知`,不把“查不到”当作“已撤”。 + +##### 未登记订单撤单二次确认 + +本地配置 `external_cancel_confirmation` 的默认值是 `two_step`。订单是否“已登记”只以本系统 +本地台账中保存的目标 `entrust_no` 为准,不能用 `order_event.source` 等提示字段替代。 + +* 已登记订单:`cancel(entrust_no, client_order_id)` 直接走普通撤单路径。 +* 未登记/人工/外部订单:`cancel` 会先从含终态的全量委托表唯一读取目标,再返回 + `code=confirmation_required`、`error.class=confirmation_required` 和 60 秒一次性的 + `data.confirmation_token`;此阶段 `submitted=false`,**绝不点击 GUI**。 +* 调用方展示该订单摘要后,必须以**新的** `client_order_id` 调用 + `confirm_external_cancel(confirmation_token, client_order_id)`;不能复用产生令牌的 + `cancel` ID。网络重发同一确认仍须复用该确认 ID,令牌本身只能消费一次。 +* 消费令牌后,受控端再次读取全量委托表,并按合同号唯一匹配,逐项比较合同号、证券代码、 + 方向、委托价、委托数量、已成数量及可撤状态。订单已成交、订单变化、零/多行匹配、表读取失败、 + 令牌过期或已使用时均停止,不执行撤单。 +* 令牌只保存在当前进程内,且绝不写入本地台账;进程重启或 WebSocket 连接代次切换会使它失效。 + 调用方用**原 `cancel` 的同一 client_order_id** 再次调用 `cancel`,受控端会重新读表并换发 + 令牌;旧令牌立即失效,整个刷新过程不点击 GUI。令牌的有效期固定为 60 秒。 +* 设置为 `external_cancel_confirmation=direct` 时,任何订单的 `cancel` 都按普通撤单路径 + 直接执行,不创建令牌,也不要求 `confirm_external_cancel`。 + +无论处于哪种模式,超时、未知结果或自动回查都**绝不自动重发真实撤单**。调用方只能显式地 +使用同一动作的 `client_order_id` 进行幂等重放,或调用 `query_order` 核验。 #### 3.2.4 查单(C5b) -`query_order(client_order_id)` → `state` ∈ 未报/已报/部成/已成/已撤/废单/**未知**, -并给出 `resolution`:`by_entrust_no`(精确命中)/ `heuristic`(台账无合同编号,按 -代码+数量匹配,**同参重复单存在歧义**)/ `unresolved`(零命中或多命中 → `state=未知`, -需人工)。`unknown` 态被收窄到「回查确认前」,但**不可能被消灭**。 +买卖 `query_order(client_order_id)` → `state` ∈ 未报/已报/部成/已成/已撤/废单/**未知**, +并给出 `resolution`:`by_entrust_no`(精确命中)/ `heuristic`(台账无合同编号时, +活跃委托须代码、方向、数量一致;限价单还须委托价一致;成交表须代码、方向、数量一致; +**同参重复单仍有歧义**)/ `unresolved`(零命中或多命中 → `state=未知`,需人工)。 + +对实际执行撤单的 `cancel` 或 `confirm_external_cancel` 的 `client_order_id`,`query_order` +按该撤单动作保存的目标 `entrust_no` 精确读取含终态的全量委托表,返回原委托的 `state` 与 +专用 `cancel_state`:`已撤`、`部成后已撤`、`已成`、`仍在飞`、`废单` 或 `未知`。这两类 +撤单查询都不使用买卖单的启发式匹配;只有 `resolution=by_entrust_no` 才表示精确关联。 +全量表读取失败或找不到唯一目标则 `resolution=unresolved`、`cancel_state=未知`。`unknown` +态被收窄到「回查确认前」,但**不可能被消灭**。 +为兼容升级前已写入的台账,`query_order` 可读取历史的非 UUID v7 ID;新建的 +`buy`/`sell`/`cancel` 仍只接受规范 UUID v7。 #### 3.2.5 数值与单位(C6) @@ -276,7 +339,32 @@ Rationale:2026-07-13「报错但静默成交」几乎导致重复下单。 * 收到 busy 按 `retry_after_secs` 退避,不要立即重试; * 下单类务必带 coid,超时后**重发同 id**而不是新建单。 -### 3.3. Trader-to-Gateway: `order_event` Push (Unsolicited) +### 3.3. Trader-to-Gateway: `account_event` Push (Unsolicited) + +每次 WebSocket 连接完成后,受控端会**尝试一次**只读读取同花顺账户下拉列表,并发送: + +```json +{ + "type": "account_event", + "event": "available", + "accounts": [ + {"slot": 1, "shortcut": "Alt+1", "text": "券商-王*甲"} + ], + "current_account_text": "券商 王*甲", + "partial": false, + "ts": 1782900000.0 +} +``` + +`event=unavailable` 表示连接时未能读取列表,会有空 `accounts`、`partial=true` 和可读的 +`message`。此事件不选择账户、不发送热键、不建立交易账户基线,且仅作提示:主动事件在 +断线时可能丢失,调用方必须可通过 `list_accounts` 重新查询。调用方展示列表后,必须调用 +`switch_account(slot)` 明确核验用户选择的账户,哪怕该槽位已经是当前账户。 + +与 `order_event` 一样,`account_event` 没有 `id`,由网关通用非 reply 路径广播给当前 +控制会话;无需网关改动。它不应被当作可靠状态存储或交易授权依据。 + +### 3.4. Trader-to-Gateway: `order_event` Push (Unsolicited) Unlike `reply` (which always answers a preceding `call` and carries its `id`), `order_event` is an **unsolicited push** emitted by the trader on its own @@ -297,9 +385,9 @@ client, and orders placed from the user's **mobile app** on the same account. "stock_no": "600000", "op": "买入", "order_qty": 100, - "order_price": "7.580", + "order_price": 7.58, "filled_qty": 0, - "avg_price": "", + "avg_price": null, "note": "已报", "seq": 12, "ts": 1782900000.0 @@ -308,11 +396,13 @@ client, and orders placed from the user's **mobile app** on the same account. - `event`: one of `placed` | `partially_filled` | `filled` | `canceled`. `partially_filled` may fire multiple times; `filled` is terminal. -- All business fields use the **verbatim THS column names** as source: +- All business fields use the **verbatim THS column names** as source and are + normalized before transmission: `stock_no`=证券代码, `op`=操作(买入/卖出), `order_qty`=委托数量, `order_price`=委托价格, `filled_qty`=成交数量, `avg_price`=成交均价, - `note`=备注. `order_price`/`avg_price` are strings and **may be empty** - (e.g. market orders, or before any fill). + `note`=备注. `order_price`/`avg_price` are `number | null`; `null` means the + client did not provide a usable numeric value (for example a market order, + or before any fill). #### Gateway handling `order_event` is **not** a `reply` and carries no `id`, so the gateway does @@ -323,11 +413,10 @@ bound to this connection's `agent_token` (e.g. `guling-mcp-gateway`'s required** to relay it. #### Contract notes (consumers MUST honor) -- **Account identity is carried by the connection token, not the frame.** - One WebSocket connection = one THS account; the frame body intentionally - omits any account/portfolio field. Consumers map `agent_token` → - (user, portfolio). (An optional `account` echo field MAY be added later for - defensive logging only; it must never be used for routing.) +- **网关路由身份仍由连接 token 决定,而不是券商账户文本。** 一个受控端连接可登录 + 多个同花顺账户;`account_event` 和 `switch_account` 回执中的账户文本只用于让用户 + 选择和让受控端做本地核验,绝不能作为网关路由或跨账户订单归属的依据。消费者应把 + `agent_token` 映射到用户/受控端,再根据其明确的账户选择维护自己的业务归属。 - **Delivery is best-effort and lossy.** If no live SSE control session exists for the token, if the buffer is full, or during disconnects, events are **dropped and never replayed**. Consumers MUST keep a @@ -382,7 +471,7 @@ When an AI client issues an MCP tool call: "content": [ { "type": "text", - "text": "{\"code\": 0, \"status\": \"succeed\", ...}" + "text": "{\"status\":\"succeed\",\"code\":\"ok\",\"data\":{...},\"error\":null,\"contract_version\":\"2\"}" } ], "isError": false @@ -398,14 +487,14 @@ When an AI client issues an MCP tool call: "content": [ { "type": "text", - "text": "可用资金不足" + "text": "{\"status\":\"failed\",\"code\":\"rejected\",\"data\":null,\"error\":{\"class\":\"insufficient_funds\",\"broker_msg\":\"可用资金不足\",\"message\":\"柜台拒绝本次委托\"},\"contract_version\":\"2\"}" } ], "isError": true } } ``` - *Crucial Rule: Do NOT collapse the standard HTTP or JSON-RPC transport layer with error statuses (-32xxx codes) during tool failures. Tool errors must be mapped as HTTP 200 containing `isError: true` inside standard JSON-RPC results, ensuring diagnostics are clearly read by Cursor/Claude.* + *Crucial Rule: Do NOT collapse the standard HTTP or JSON-RPC transport layer with error statuses (-32xxx codes) during tool failures. Tool errors must be mapped as HTTP 200 containing `isError: true` inside standard JSON-RPC results. The `text` field must retain the complete v2 envelope rather than flattening it to a bare error message.* --- diff --git a/docs/ths_architecture.md b/docs/ths_architecture.md index f437f5b..a8f46af 100644 --- a/docs/ths_architecture.md +++ b/docs/ths_architecture.md @@ -36,13 +36,13 @@ xiadan.exe 顶层窗口「网上股票交易系统5.0[ - 券商后缀]」 ← ## 2. 左侧树菜单布局(SysTreeView32) ``` -买入[F1] · 卖出[F2] · 撤单[F3] +买入[F1] · 卖出[F2] · 市价买入 · 市价卖出 · 对买对卖 · 撤单[F3] 自选股 └ 行情 条件单 └ 条件单监控/股价条件/止盈止损/涨停买入/涨跌幅条件/反弹买入/回落卖出/通用回购 新股申购 └ 新股申购/新股批量申购/新股配号/新股中签/新股申购额度查询 北交所交易 └ 北交所新股发行 └ 公开发行询价/申购/批量.../发行询价委托查询/... -双向委托 · 市价委托(└买入/卖出) -查询[F4] └ 资金股票·当日委托·当日成交·历史委托·历史成交·历史持仓·资金明细·对帐单·交割单·新股申购额度查询·账户分析 +市价买入 · 市价卖出 +查询[F4] └ 资金股份·当日委托·当日委托汇总·当日成交·当日成交汇总·资金明细·资金流水·历史委托·历史成交·历史持仓·对账单·交割单·... 通用回购 · 新三板交易 · 银证转帐 · 场内基金 · ETF业务 · 修改密码 · 多银行存管 批量下单 · 其它交易 · 基金盘后业务 · 隔日证券预委托 · 盘后定价委托 · 修改联系信息 · 风险提示 · ... ``` @@ -71,8 +71,11 @@ xiadan 是 32 位,`TVITEMW` 的 `hItem/pszText/lParam` 是 4 字节;64 位 P ## 5. 数据读取:剪贴板毫秒级中转 + 内存 state `read_table_text(hwnd)`:清空剪贴板 → 取 `GetClipboardSequenceNumber` 基线 → `Ctrl+C` → -**确认序列号变化(本次拷贝真落定)** → 读文本 → **立刻清空**。序列号没变 = 拷贝没落定 -(窗口没焦点/被弹窗挡),返回 `None` 让调用方重试,**绝不返回上一次遗留的陈旧表格**。 +**确认序列号变化(本次拷贝真落定)** → 读文本 → **立刻清空**。序列号没变通常表示拷贝没 +落定(窗口没焦点/被弹窗挡),返回 `None` 让调用方重试,**绝不返回上一次遗留的陈旧表格**。 +唯一例外是:已出现并由项目处理完“检测到您正在拷贝数据”验证码、验证码消失后剪贴板仍未 +写入内容。当前 xiadan 的空 `CVirtualGridCtrl` 在该情形连表头也不输出,项目将其标记为 +已核验空表并返回 `data: []`;未经过该验证码闭环的无输出仍是读取失败,绝不冒充空表。 各查询结果落 `ThsState`(线程安全内存态,`state.get(key, max_age=)` 读 last-known)。 剪贴板里几乎不留数据,不受并发/同步干扰。 @@ -125,13 +128,14 @@ xiadan 是 32 位,`TVITEMW` 的 `hItem/pszText/lParam` 是 4 字节;64 位 P → 与上次 diff → 有变化经 WS 推 `watchlist_event`(含 `added`/`codes`/`partial`)。仿 `order_watch`。 配置:`enable_watchlist_watch` / `watchlist_sync_hours`。 -## 7.6 市价委托路径(`buy`/`sell` 不传 price) +## 7.6 市价委托路径(`buy`/`sell` 显式 `order_type`) -`buy`/`sell` 有两条路径,在 `_do_buy`/`_do_sell` 内按 `price` 分派: +对外工具必须显式传 `order_type`,dispatcher 校验后才进入后端;不会再按 +`price` 是否缺失猜测订单意图。后端仍以归一化后的 `price` 分派两条路径: -- **传 `price`** → `_submit_trade("F1"/"F2", …)`:**限价挂单**(原逻辑),未成交留 `orders_active`, - 由 agent 用 `cancel` 管理。 -- **不传 `price`(`None`)** → `_submit_market_trade(op, code, amount)`:走左树 **市价委托 └ 买入/卖出** +- **`order_type=LIMIT` + 正数 `price`** → `_submit_trade("F1"/"F2", …)`:**限价挂单**(原逻辑), + 未成交留 `orders_active`,由 agent 用 `cancel` 管理。 +- **`order_type=FIVE_LEVEL_IOC`,且不传 `price`** → `_submit_market_trade(op, code, amount)`:走左树 **市价委托 └ 买入/卖出** 面板,**委托策略固定「五档即成剩撤」**——扫对手方最优五档立即成交、剩余自动撤销、**无残留挂单**。 **为什么是五档即成剩撤**:市价 5 个策略里唯一同时满足"立即成交 + 剩余自动撤 + 沪深北全市场通用"。 @@ -147,10 +151,11 @@ xiadan 是 32 位,`TVITEMW` 的 `hItem/pszText/lParam` 是 4 字节;64 位 P | 提交 | Button | `0x3EE` | | 委托策略 | ComboBox | `0x605`(标准 `ComboBox`)| -**导航**:市价买入/卖出**无 F 快捷键**,且子节点文字"买入"/"卖出"与顶层"买入[F1]"前缀相同 → -用 `_select_tree_child("市价委托", "买入"/"卖出")`:**先定位父节点、再在其直接子节点里整串精确匹配** -(深度优先的 `_select_tree_node_by_text` 会先撞顶层,不能用)。跨进程 TreeView 读写/位数/DPI -点击与 `_select_tree_node_by_text` 同构。 +**导航**:已观察到两种真实菜单结构:顶层 `市价买入` / `市价卖出`,或 +`市价委托 └ 买入/卖出`。`_select_market_tree_path` 依次精确尝试两种完整路径,绝不在根层 +搜索裸 `买入` / `卖出`,因此不会撞到普通 `买入[F1]` / `卖出[F2]`。跨进程 TreeView +读写、位数处理和 DPI 点击与 `_select_tree_node_by_text` 同构;券商或版本变更后须以 +`tools/ths_diag.py` 的真机导出为准。 **委托策略设置**:买卖下拉不同 → 用**键盘位置数字**切换(`_set_market_strategy`:`SetFocus` + `AttachThreadInput` + `WM_CHAR`,`CB_GETCURSEL` 校验,未命中回退 `CB_SETCURSEL`)。 @@ -186,6 +191,17 @@ Enter,改为 `pump()`「等待-发现-处置」循环。处置**不耦合弹 字段;全文机会性提取合同编号。安全性靠委托表/成交表回查,不靠读懂弹窗。 弹窗结构对不上时跑 `python tools/ths_dialog_dump.py`(开着弹窗)核对。 +## 7.8 本地完整诊断日志 + +`guling-trader-data/trader.log` 是完整的**本地业务诊断链路**,而不是 UI 的镜像: + +- 所有 `trader.*` 模块的 `DEBUG`、`INFO`、`WARNING`、`ERROR` 都会落盘;第三方库仍维持 `INFO`,避免掩盖交易动作; +- WebSocket 边界记录脱敏后的接收帧、RPC 开始执行、最终回执、回执本地写入结果,以及主动事件的尝试与本地写入结果; +- “本地写入成功”不等于网关已收到或业务已确认,断线、跨连接回执丢弃仍会明确记录; +- `agent_token`、各类 token / authorization、密码、验证码、确认令牌和配对码一律以 `` 写入,不能为了日志完整性记录明文。 + +主窗口中的日志是单独的业务状态队列,最多保留最近 500 条,供操作时浏览;它不是 `trader.log` 的完整副本。 + ## 8. 出新版本时怎么排查 1. 切到目标皮肤、登录 xiadan。 diff --git a/docs/tools_schema.json b/docs/tools_schema.json index 4fbd20e..7536335 100644 --- a/docs/tools_schema.json +++ b/docs/tools_schema.json @@ -68,9 +68,18 @@ "additionalProperties": false } }, + { + "name": "list_accounts", + "description": "只读列出同花顺账户下拉框中的可切换账户。仅点击当前控件快照确认的账户 ComboBox(ID 0x0912)打开下拉框,等待 0.3 秒后读取展开的 ComboLBox(ID 0x03E8)原始列表项文本;过滤“编辑账户”,其余账户按显示顺序对应 Alt+1..Alt+9。不发送 Alt+N、不选择账户,账户名称原样保留(包括 *);返回 slot、shortcut 和 text。", + "inputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": false + } + }, { "name": "buy", - "description": "下买入委托单。**会真实下单**,慎重调用。不传 price=五档即成剩撤市价单(立即成交、剩余自动撤销、无残留挂单),回执 status/filled_amount/avg_price 为实际成交;传 price=限价挂单,返回 entrust_no,未成交需自行用 orders_active+cancel 管理。", + "description": "下买入委托单。**会真实下单**,慎重调用。必须显式指定 order_type:LIMIT 为限价挂单,FIVE_LEVEL_IOC 为五档即成剩撤(立即成交、剩余自动撤销、无残留挂单)。LIMIT 必须传正数 price;FIVE_LEVEL_IOC 禁止传 price。", "inputSchema": { "type": "object", "properties": { @@ -82,25 +91,36 @@ "type": "integer", "description": "买入股数(必须为 100 股的整数倍)" }, + "order_type": { + "type": "string", + "enum": [ + "LIMIT", + "FIVE_LEVEL_IOC" + ], + "description": "订单类型。LIMIT=限价挂单(必须传正数 price);FIVE_LEVEL_IOC=五档即成剩撤(禁止传 price)。" + }, "price": { "type": "number", - "description": "限价买入价格。不传则走同花顺市价委托(五档即成剩撤)立即成交、剩余自动撤销、无残留挂单;传则限价挂单,需自行 orders_active/cancel 管理。" + "description": "LIMIT 必填的正数限价。order_type=FIVE_LEVEL_IOC 时禁止传入。" }, "client_order_id": { "type": "string", - "description": "客户端订单 ID,**幂等键**:同一 id 重复提交只会下单一次,重发返回首次回执(首次结果未知时返回 unknown_outcome,仍不会产生第二次提交)。超时后的安全动作就是用同一 id 原样重发。该 id 不写入柜台,仅存于受控端台账,orders_active/orders_filled 尽力回显(回查不到合同编号的单与外部单为 null)。建议全局唯一并含账户维度。" + "pattern": "^gl-[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$", + "description": "客户端订单 ID,**必填幂等键**。格式必须为 gl-<小写 UUID v7>,如 gl-0198f6a1-0001-7000-8000-000000000001;UUID v7 含毫秒时间戳和随机位。调用方必须在创建买卖请求时生成并持久保存该 ID:新买卖用新 ID;网络重发或 query_order 查询同一买卖单必须原样复用。交易端只验证,不生成或改写。同一 ID 重复提交只会执行一次。buy/sell 返回 submitted_unconfirmed 时,交易端会自动执行一次只读 query_order,结果位于 data.auto_query,绝不自动重发。" } }, "required": [ "stock_no", - "amount" + "amount", + "order_type", + "client_order_id" ], "additionalProperties": false } }, { "name": "sell", - "description": "下卖出委托单。**会真实下单**,慎重调用。不传 price=五档即成剩撤市价单(立即成交、剩余自动撤销、无残留挂单),回执 status/filled_amount/avg_price 为实际成交;传 price=限价挂单,返回 entrust_no,未成交需自行用 orders_active+cancel 管理。", + "description": "下卖出委托单。**会真实下单**,慎重调用。必须显式指定 order_type:LIMIT 为限价挂单,FIVE_LEVEL_IOC 为五档即成剩撤(立即成交、剩余自动撤销、无残留挂单)。LIMIT 必须传正数 price;FIVE_LEVEL_IOC 禁止传 price。", "inputSchema": { "type": "object", "properties": { @@ -112,25 +132,36 @@ "type": "integer", "description": "卖出股数" }, + "order_type": { + "type": "string", + "enum": [ + "LIMIT", + "FIVE_LEVEL_IOC" + ], + "description": "订单类型。LIMIT=限价挂单(必须传正数 price);FIVE_LEVEL_IOC=五档即成剩撤(禁止传 price)。" + }, "price": { "type": "number", - "description": "限价卖出价格。不传则走同花顺市价委托(五档即成剩撤)立即成交、剩余自动撤销、无残留挂单;传则限价挂单,需自行 orders_active/cancel 管理。" + "description": "LIMIT 必填的正数限价。order_type=FIVE_LEVEL_IOC 时禁止传入。" }, "client_order_id": { "type": "string", - "description": "客户端订单 ID,**幂等键**:同一 id 重复提交只会下单一次,重发返回首次回执(首次结果未知时返回 unknown_outcome,仍不会产生第二次提交)。超时后的安全动作就是用同一 id 原样重发。该 id 不写入柜台,仅存于受控端台账,orders_active/orders_filled 尽力回显(回查不到合同编号的单与外部单为 null)。建议全局唯一并含账户维度。" + "pattern": "^gl-[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$", + "description": "客户端订单 ID,**必填幂等键**。格式必须为 gl-<小写 UUID v7>,如 gl-0198f6a1-0001-7000-8000-000000000001;UUID v7 含毫秒时间戳和随机位。调用方必须在创建买卖请求时生成并持久保存该 ID:新买卖用新 ID;网络重发或 query_order 查询同一买卖单必须原样复用。交易端只验证,不生成或改写。同一 ID 重复提交只会执行一次。buy/sell 返回 submitted_unconfirmed 时,交易端会自动执行一次只读 query_order,结果位于 data.auto_query,绝不自动重发。" } }, "required": [ "stock_no", - "amount" + "amount", + "order_type", + "client_order_id" ], "additionalProperties": false } }, { "name": "cancel", - "description": "撤销指定委托编号的未成交订单。", + "description": "撤销指定委托编号的未成交订单。点击确认后会短暂只读轮询 F3 委托表;仅明确显示已撤/部撤时返回成功。目标消失、仍在飞、状态不明、错表或读取失败均返回 submitted_unconfirmed,并自动做一次只读全量表核验,绝不再次点击撤单。", "inputSchema": { "type": "object", "properties": { @@ -140,18 +171,43 @@ }, "client_order_id": { "type": "string", - "description": "客户端订单 ID,**幂等键**:同一 id 重复提交只会下单一次,重发返回首次回执(首次结果未知时返回 unknown_outcome,仍不会产生第二次提交)。超时后的安全动作就是用同一 id 原样重发。该 id 不写入柜台,仅存于受控端台账,orders_active/orders_filled 尽力回显(回查不到合同编号的单与外部单为 null)。建议全局唯一并含账户维度。" + "pattern": "^gl-[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$", + "description": "撤单请求 ID,**必填幂等键**。格式必须为 gl-<小写 UUID v7>,如 gl-0198f6a1-0001-7000-8000-000000000001;UUID v7 含毫秒时间戳和随机位。调用方必须在创建撤单请求时生成并持久保存该 ID:每个新撤单动作使用新 ID;网络重发同一撤单请求必须原样复用。交易端只验证,不生成或改写。同一 ID 重复提交只会执行一次。cancel 返回 submitted_unconfirmed 时,交易端会按目标 entrust_no 自动读取一次含终态的全量委托表,结果位于 data.auto_query;其中 cancel_state 为已撤/部成后已撤/已成/仍在飞/废单/未知。仅一次只读核验,绝不自动重发。" + } + }, + "required": [ + "entrust_no", + "client_order_id" + ], + "additionalProperties": false + } + }, + { + "name": "confirm_external_cancel", + "description": "确认撤销未登记订单。仅接受此前 cancel 回执给出的短时一次性 confirmation_token;确认前会再次核验合同号及证券代码、方向、委托价、委托数量仍一致且订单仍可撤。必须使用新的 client_order_id,令牌过期、已使用或订单变化时绝不执行撤单。", + "inputSchema": { + "type": "object", + "properties": { + "confirmation_token": { + "type": "string", + "description": "此前 cancel 对未登记订单返回的短时一次性确认令牌" + }, + "client_order_id": { + "type": "string", + "pattern": "^gl-[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$", + "description": "本次确认动作的新客户端订单 ID,必填幂等键,格式必须为 gl-<小写 UUID v7>;网络重发同一确认时必须原样复用,不能复用原 cancel 的 ID。" } }, "required": [ - "entrust_no" + "confirmation_token", + "client_order_id" ], "additionalProperties": false } }, { "name": "switch_account", - "description": "切换同花顺客户端当前活跃的资金账户(向 xiadan 窗口发送 Alt+N,N=账户在客户端账户下拉列表中的槽位序号)。仅在 xiadan 登录了多个账户时有意义。**盲切**:本工具不核验切换是否成功,受控端对账户身份无感知;切换后所有工具(查询/下单)都作用于新的当前账户。调用方必须紧接着用 balance/position 做指纹核对、确认账户无误后再继续操作。", + "description": "明确选择并核验同花顺当前交易账户。连接后会收到只读 account_event(可选账户列表);也可先调用 list_accounts。传入槽位后,程序先核对该槽位对应的账户文本:若已是当前账户,不发送热键,直接核验并返回资金;否则才发送 Alt+N 并确认当前账户匹配该槽位。每次连接后的首次买卖或撤单前必须成功调用一次本工具;失败时禁止后续买卖和撤单。每笔 buy/sell/cancel/confirm_external_cancel 前都会重新核对该账户文本。", "inputSchema": { "type": "object", "properties": { @@ -170,13 +226,13 @@ }, { "name": "query_order", - "description": "按 client_order_id 查单(契约 v2 C5b)。返回 state(未报/已报/部成/已成/已撤/废单/未知)+首次回执快照+分辨率 resolution:by_entrust_no=按合同编号精确命中;heuristic=台账无合同编号时按代码/数量匹配,存在同参重复单歧义;unresolved=实表中无法唯一定位,state=未知需人工。与 buy/sell/cancel 的幂等(同 id 重发不重复下单)配对使用。", + "description": "按 client_order_id 查单(契约 v2 C5b)。买卖单返回 state(未报/已报/部成/已成/已撤/废单/未知)+首次回执快照+分辨率 resolution:by_entrust_no=按合同编号精确命中;heuristic=台账无合同编号时按代码、方向、数量匹配,限价活单还须委托价一致,仍可能有同参重复单歧义;unresolved=实表中无法唯一定位,state=未知需人工。对 cancel 请求 ID,按其目标 entrust_no 精确读取含终态的全量委托表,并额外返回 cancel_state(已撤/部成后已撤/已成/仍在飞/废单/未知)。与 buy/sell/cancel 的幂等(同 id 重发不重复下单)配对使用。", "inputSchema": { "type": "object", "properties": { "client_order_id": { "type": "string", - "description": "下单时传入的 client_order_id" + "description": "买卖或撤单时传入的 client_order_id" } }, "required": [ diff --git a/src/trader/config.py b/src/trader/config.py index c9bfa7a..25c0bc8 100644 --- a/src/trader/config.py +++ b/src/trader/config.py @@ -6,6 +6,21 @@ from typing import Optional +EXTERNAL_CANCEL_CONFIRMATION_TWO_STEP = "two_step" +EXTERNAL_CANCEL_CONFIRMATION_DIRECT = "direct" +_EXTERNAL_CANCEL_CONFIRMATION_VALUES = frozenset({ + EXTERNAL_CANCEL_CONFIRMATION_TWO_STEP, + EXTERNAL_CANCEL_CONFIRMATION_DIRECT, +}) + + +def normalize_external_cancel_confirmation(value: object) -> str: + """返回安全的未登记订单撤单确认模式。""" + if value in _EXTERNAL_CANCEL_CONFIRMATION_VALUES: + return str(value) + return EXTERNAL_CANCEL_CONFIRMATION_TWO_STEP + + @dataclass class TraderConfig: device_id: str @@ -20,6 +35,7 @@ class TraderConfig: order_watch_active_secs: int = 60 # 有未完成委托时的提速周期(秒):默认 1 分钟 enable_watchlist_watch: bool = True # 是否定时同步自选股并推变化事件(新版 xiadan) watchlist_sync_hours: str = "8,12,16,20" # 自选股定时同步的整点(避开交易时段) + external_cancel_confirmation: str = EXTERNAL_CANCEL_CONFIRMATION_TWO_STEP def has_paired(self) -> bool: """检查是否已配对""" @@ -82,6 +98,9 @@ def load() -> TraderConfig: order_watch_active_secs=data.get("order_watch_active_secs", 60), enable_watchlist_watch=data.get("enable_watchlist_watch", True), watchlist_sync_hours=data.get("watchlist_sync_hours", "8,12,16,20"), + external_cancel_confirmation=normalize_external_cancel_confirmation( + data.get("external_cancel_confirmation") + ), ) except Exception: return TraderConfig(device_id="") @@ -90,6 +109,9 @@ def load() -> TraderConfig: def save(config: TraderConfig) -> None: """保存配置到本地""" config_path = _get_config_path() + config.external_cancel_confirmation = normalize_external_cancel_confirmation( + config.external_cancel_confirmation + ) data = { "device_id": config.device_id, @@ -104,8 +126,8 @@ def save(config: TraderConfig) -> None: "order_watch_active_secs": config.order_watch_active_secs, "enable_watchlist_watch": config.enable_watchlist_watch, "watchlist_sync_hours": config.watchlist_sync_hours, + "external_cancel_confirmation": config.external_cancel_confirmation, } with open(config_path, "w", encoding="utf-8") as f: json.dump(data, f, indent=2, ensure_ascii=False) - diff --git a/src/trader/contract.py b/src/trader/contract.py index c28d99b..9d63d2c 100644 --- a/src/trader/contract.py +++ b/src/trader/contract.py @@ -13,10 +13,10 @@ 两条容易踩的语义,PROTOCOL.md 同步写死: -* **status=failed 不等于「未提交」**。下单动作超时时真相不可知,此时 - status=failed + code=submitted_unconfirmed + error.class=unknown_outcome。 - 调用方的安全动作是**用同一个 client_order_id 原样重发**(幂等,见 order_ledger), - 绝不是改单重下。 +* **status=failed 不等于「未提交」**。交易动作超时时真相不可知,此时 + status=failed + code=submitted_unconfirmed + error.class=unknown_outcome。调用方可先 + 查阅 `data.auto_query`(buy/sell/cancel)或显式查询;若明确重发,唯一安全方式是**用同一个 + client_order_id 原样重发**(幂等,见 order_ledger),绝不是改单重下。 * **error.class=unknown 一律不可自动重试**。柜台原文映射是尽力而为的关键词表, 认不出来就必须认不出来——误判「可重试」会真的重复下单。 """ @@ -44,6 +44,7 @@ CODE_INVALID_PARAMS = "invalid_params" CODE_LEDGER_UNAVAILABLE = "ledger_unavailable" # 台账不可用 → 拒单,禁降级 CODE_NOT_FOUND = "not_found" # query_order 查无此单 +CODE_CONFIRMATION_REQUIRED = "confirmation_required" # 未登记订单须显式二次确认 CODE_UNSUPPORTED_METHOD = "unsupported_method" CODE_INTERNAL_ERROR = "internal_error" CODE_ABORTED = "aborted" # 本笔已被超时作废(代次机制) @@ -60,6 +61,7 @@ CLS_INVALID_PARAMS = "invalid_params" CLS_LEDGER_UNAVAILABLE = "ledger_unavailable" CLS_NOT_FOUND = "not_found" +CLS_CONFIRMATION_REQUIRED = "confirmation_required" CLS_INTERNAL_ERROR = "internal_error" CLS_ABORTED = "aborted" # 第二层:柜台原文尽力映射——认不出即 unknown,绝不猜。 @@ -87,6 +89,7 @@ NON_RETRYABLE_CLASSES = frozenset({ CLS_UNKNOWN, CLS_UNKNOWN_OUTCOME, CLS_INSUFFICIENT_FUNDS, CLS_NO_PERMISSION, CLS_INVALID_QUANTITY, CLS_INVALID_PARAMS, CLS_LEDGER_UNAVAILABLE, + CLS_CONFIRMATION_REQUIRED, }) @@ -129,7 +132,7 @@ def broker_rejected(broker_msg: str, message: Optional[str] = None, def submitted_unconfirmed(message: str, data: Any = None, broker_msg: Optional[str] = None) -> dict[str, Any]: - """已点提交但结果不可知。调用方唯一安全动作=同 client_order_id 原样重发。""" + """已点提交但结果不可知;显式重发时必须原样复用 client_order_id。""" return fail(CODE_SUBMITTED_UNCONFIRMED, CLS_UNKNOWN_OUTCOME, message, broker_msg=broker_msg, data=data) diff --git a/src/trader/dispatcher.py b/src/trader/dispatcher.py index 0dc7ffb..aba816a 100644 --- a/src/trader/dispatcher.py +++ b/src/trader/dispatcher.py @@ -6,11 +6,17 @@ import asyncio import json import logging +import math +import re +import secrets +import threading +import time from pathlib import Path from typing import Any, Optional from . import contract from .order_ledger import LedgerUnavailable +from .ths.rows import ST_CANCELED, ST_FILLED, ST_PARTIAL, ST_PENDING, ST_PLACED, ST_REJECTED from .ths.win import WinThsBackend logger = logging.getLogger(__name__) @@ -18,14 +24,181 @@ # 受控端单笔调用总预算:必须低于网关侧 30s 超时,保证网关永远等得到带 # unknown 语义的 reply,而不是自造裸错误(-32003)。 CALL_TIMEOUT_SECS = 25.0 +# ``submitted_unconfirmed`` 后的自动核验必须留在网关 30s 总预算内。买卖只读 +# orders_active/orders_filled;撤单只读内部全量委托表。超时后保留原始未知结果,绝不重发。 +AUTO_QUERY_TIMEOUT_SECS = 3.0 # win_lock 排队上限:持锁方被拖住时,排队方回 busy 而非无限饿死。 LOCK_TIMEOUT_SECS = 5.0 # 会真实改变账户状态的方法:超时/busy 回执必须带「可能已提交,先核单」语义。 -ORDER_METHODS = {"buy", "sell", "cancel"} +CANCEL_METHODS = {"cancel", "confirm_external_cancel"} +ORDER_METHODS = {"buy", "sell", *CANCEL_METHODS} # 走 client_order_id 幂等台账的方法(C5a)。 -IDEMPOTENT_METHODS = {"buy", "sell", "cancel"} +IDEMPOTENT_METHODS = {"buy", "sell", *CANCEL_METHODS} +# 买卖按成交/在飞表核单;撤单按目标 entrust_no 的全量委托表核验终态。 +AUTO_QUERY_METHODS = {"buy", "sell", *CANCEL_METHODS} # busy 是背压信号:告诉调用方等多久再来,别让它自己猜(G3)。 BUSY_BACKOFF_HINT_SECS = 3 +# 买卖的显式业务语义。入口不再根据 price 是否存在猜测订单类型。 +ORDER_TYPES = ("LIMIT", "FIVE_LEVEL_IOC") +_ORDER_TYPE_SET = frozenset(ORDER_TYPES) +# UUID v7 带毫秒时间成分与随机位,便于按时间审计且碰撞概率可忽略。格式校验只 +# 约束协议,不在受控端自行生成或重写 ID——同一业务订单必须由上游复用原 ID。 +CLIENT_ORDER_ID_PATTERN = ( + r"^gl-[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" +) +_CLIENT_ORDER_ID_RE = re.compile(CLIENT_ORDER_ID_PATTERN) + +# 未登记订单的确认令牌只留在进程内。60 秒足以让调用方展示订单摘要并发起确认, +# 又不会把一次旧读取长期变成可执行授权。 +EXTERNAL_CANCEL_CONFIRMATION_TTL_SECS = 60.0 +_external_cancel_confirmations: dict[str, dict[str, Any]] = {} +_external_cancel_confirmations_lock = threading.Lock() + + +def clear_external_cancel_confirmations() -> None: + """清空进程内的未登记订单撤单确认令牌。 + + 令牌绝不落盘;连接代次切换或进程重启后,先前读取到的订单快照不能继续 + 作为点击撤单的授权。``ws_client`` 在重连时调用此函数。 + """ + with _external_cancel_confirmations_lock: + _external_cancel_confirmations.clear() + + +def _clean_external_cancel_confirmations_locked(now: float) -> None: + """在持锁状态下删除过期令牌。""" + expired = [ + token for token, record in _external_cancel_confirmations.items() + if float(record.get("expires_at", 0.0)) <= now + ] + for token in expired: + _external_cancel_confirmations.pop(token, None) + + +def _copy_confirmation_record(record: dict[str, Any]) -> dict[str, Any]: + """返回可安全供调用方读取的令牌记录副本。""" + copied = dict(record) + binding = copied.get("binding") + if isinstance(binding, dict): + copied["binding"] = dict(binding) + summary = copied.get("summary") + if isinstance(summary, dict): + copied["summary"] = dict(summary) + return copied + + +def _issue_external_cancel_confirmation( + source_client_order_id: str, + binding: dict[str, Any], + summary: dict[str, Any], +) -> tuple[str, float]: + """生成短时确认令牌;同一提示请求只保留最新令牌。""" + now = time.monotonic() + expires_at = now + EXTERNAL_CANCEL_CONFIRMATION_TTL_SECS + with _external_cancel_confirmations_lock: + _clean_external_cancel_confirmations_locked(now) + # 同一个 cancel 的重放只是在重新展示订单、换发授权,绝不能让旧令牌 + # 与新令牌同时有效。这样连接恢复或用户停留过久后仍可用同一请求安全刷新。 + prior_tokens = [ + token for token, record in _external_cancel_confirmations.items() + if record.get("source_client_order_id") == source_client_order_id + ] + for prior_token in prior_tokens: + _external_cancel_confirmations.pop(prior_token, None) + token = secrets.token_urlsafe(32) + while token in _external_cancel_confirmations: + token = secrets.token_urlsafe(32) + _external_cancel_confirmations[token] = { + "source_client_order_id": source_client_order_id, + "entrust_no": binding["entrust_no"], + "binding": dict(binding), + "summary": dict(summary), + "expires_at": expires_at, + "used": False, + } + return token, expires_at + + +def _peek_external_cancel_confirmation( + token: Any, +) -> tuple[str, Optional[dict[str, Any]]]: + """读取令牌,不消费它。 + + 返回值第一项为 ``ok``、``used``、``expired``、``missing`` 或 ``invalid``。 + ``used`` 仍会返回记录,用于同一确认请求的幂等回放;它永远不能再执行撤单。 + """ + if not isinstance(token, str) or not token.strip(): + return "invalid", None + value = token.strip() + now = time.monotonic() + with _external_cancel_confirmations_lock: + record = _external_cancel_confirmations.get(value) + if record is None: + return "missing", None + if float(record.get("expires_at", 0.0)) <= now: + _external_cancel_confirmations.pop(value, None) + return "expired", None + if record.get("used"): + return "used", _copy_confirmation_record(record) + return "ok", _copy_confirmation_record(record) + + +def _consume_external_cancel_confirmation( + token: Any, +) -> tuple[str, Optional[dict[str, Any]]]: + """原子消费确认令牌,保证并发确认至多有一个可进入撤单前核验。""" + if not isinstance(token, str) or not token.strip(): + return "invalid", None + value = token.strip() + now = time.monotonic() + with _external_cancel_confirmations_lock: + record = _external_cancel_confirmations.get(value) + if record is None: + return "missing", None + if float(record.get("expires_at", 0.0)) <= now: + _external_cancel_confirmations.pop(value, None) + return "expired", None + if record.get("used"): + return "used", _copy_confirmation_record(record) + record["used"] = True + return "ok", _copy_confirmation_record(record) + + +def _invalidate_external_cancel_confirmation(token: Any) -> None: + """撤销未发出的确认授权(例如台账无法安全保存提示回执)。""" + if not isinstance(token, str) or not token: + return + with _external_cancel_confirmations_lock: + _external_cancel_confirmations.pop(token, None) + + +def _is_unsubmitted_external_cancel_prompt(record: Optional[dict]) -> bool: + """确认台账记录是否只是一次尚未执行的人工订单提示。 + + 这种回执没有发送真实撤单。允许相同的 cancel 幂等键再次读取订单并换发令牌, + 既解决进程/连接切换后的旧令牌问题,也不会把重试变成第二次真实操作。 + """ + receipt = (record or {}).get("receipt") + if not isinstance(receipt, dict): + return False + if receipt.get("code") != contract.CODE_CONFIRMATION_REQUIRED: + return False + data = receipt.get("data") + return isinstance(data, dict) and data.get("submitted") is False + + +def _receipt_for_ledger(result: dict) -> dict: + """生成可持久化回执副本,绝不把确认令牌写进 SQLite。""" + if result.get("code") != contract.CODE_CONFIRMATION_REQUIRED: + return result + data = result.get("data") + if not isinstance(data, dict) or "confirmation_token" not in data: + return result + stored = json.loads(json.dumps(result, ensure_ascii=False)) + stored_data = stored.get("data") + if isinstance(stored_data, dict): + stored_data.pop("confirmation_token", None) + return stored # Fallback tools schema in case the external JSON file cannot be found (e.g., in a packaged PyInstaller environment) FALLBACK_TOOLS_SCHEMA = { @@ -98,9 +271,18 @@ "additionalProperties": False } }, + { + "name": "list_accounts", + "description": "只读列出同花顺账户下拉框中的可切换账户。仅点击当前控件快照确认的账户 ComboBox(ID 0x0912)打开下拉框,等待 0.3 秒后读取展开的 ComboLBox(ID 0x03E8)原始列表项文本;过滤“编辑账户”,其余账户按显示顺序对应 Alt+1..Alt+9。不发送 Alt+N、不选择账户,账户名称原样保留(包括 *);返回 slot、shortcut 和 text。", + "inputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": False + } + }, { "name": "buy", - "description": "下买入委托单。**会真实下单**,慎重调用。不传 price=五档即成剩撤市价单(立即成交、剩余自动撤销、无残留挂单),回执 status/filled_amount/avg_price 为实际成交;传 price=限价挂单,返回 entrust_no,未成交需自行用 orders_active+cancel 管理。", + "description": "下买入委托单。**会真实下单**,慎重调用。必须显式指定 order_type:LIMIT 为限价挂单,FIVE_LEVEL_IOC 为五档即成剩撤(立即成交、剩余自动撤销、无残留挂单)。LIMIT 必须传正数 price;FIVE_LEVEL_IOC 禁止传 price。", "inputSchema": { "type": "object", "properties": { @@ -112,25 +294,36 @@ "type": "integer", "description": "买入股数(必须为 100 股的整数倍)" }, + "order_type": { + "type": "string", + "enum": [ + "LIMIT", + "FIVE_LEVEL_IOC" + ], + "description": "订单类型。LIMIT=限价挂单(必须传正数 price);FIVE_LEVEL_IOC=五档即成剩撤(禁止传 price)。" + }, "price": { "type": "number", - "description": "限价买入价格。不传则走同花顺市价委托(五档即成剩撤)立即成交、剩余自动撤销、无残留挂单;传则限价挂单,需自行 orders_active/cancel 管理。" + "description": "LIMIT 必填的正数限价。order_type=FIVE_LEVEL_IOC 时禁止传入。" }, "client_order_id": { "type": "string", - "description": "客户端订单 ID,**幂等键**:同一 id 重复提交只会下单一次,重发返回首次回执(首次结果未知时返回 unknown_outcome,仍不会产生第二次提交)。超时后的安全动作就是用同一 id 原样重发。该 id 不写入柜台,仅存于受控端台账,orders_active/orders_filled 尽力回显(回查不到合同编号的单与外部单为 null)。建议全局唯一并含账户维度。" + "pattern": "^gl-[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$", + "description": "客户端订单 ID,**必填幂等键**。格式必须为 gl-<小写 UUID v7>,如 gl-0198f6a1-0001-7000-8000-000000000001;UUID v7 含毫秒时间戳和随机位。调用方必须在创建买卖请求时生成并持久保存该 ID:新买卖用新 ID;网络重发或 query_order 查询同一买卖单必须原样复用。交易端只验证,不生成或改写。同一 ID 重复提交只会执行一次。buy/sell 返回 submitted_unconfirmed 时,交易端会自动执行一次只读 query_order,结果位于 data.auto_query,绝不自动重发。" } }, "required": [ "stock_no", - "amount" + "amount", + "order_type", + "client_order_id" ], "additionalProperties": False } }, { "name": "sell", - "description": "下卖出委托单。**会真实下单**,慎重调用。不传 price=五档即成剩撤市价单(立即成交、剩余自动撤销、无残留挂单),回执 status/filled_amount/avg_price 为实际成交;传 price=限价挂单,返回 entrust_no,未成交需自行用 orders_active+cancel 管理。", + "description": "下卖出委托单。**会真实下单**,慎重调用。必须显式指定 order_type:LIMIT 为限价挂单,FIVE_LEVEL_IOC 为五档即成剩撤(立即成交、剩余自动撤销、无残留挂单)。LIMIT 必须传正数 price;FIVE_LEVEL_IOC 禁止传 price。", "inputSchema": { "type": "object", "properties": { @@ -142,25 +335,36 @@ "type": "integer", "description": "卖出股数" }, + "order_type": { + "type": "string", + "enum": [ + "LIMIT", + "FIVE_LEVEL_IOC" + ], + "description": "订单类型。LIMIT=限价挂单(必须传正数 price);FIVE_LEVEL_IOC=五档即成剩撤(禁止传 price)。" + }, "price": { "type": "number", - "description": "限价卖出价格。不传则走同花顺市价委托(五档即成剩撤)立即成交、剩余自动撤销、无残留挂单;传则限价挂单,需自行 orders_active/cancel 管理。" + "description": "LIMIT 必填的正数限价。order_type=FIVE_LEVEL_IOC 时禁止传入。" }, "client_order_id": { "type": "string", - "description": "客户端订单 ID,**幂等键**:同一 id 重复提交只会下单一次,重发返回首次回执(首次结果未知时返回 unknown_outcome,仍不会产生第二次提交)。超时后的安全动作就是用同一 id 原样重发。该 id 不写入柜台,仅存于受控端台账,orders_active/orders_filled 尽力回显(回查不到合同编号的单与外部单为 null)。建议全局唯一并含账户维度。" + "pattern": "^gl-[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$", + "description": "客户端订单 ID,**必填幂等键**。格式必须为 gl-<小写 UUID v7>,如 gl-0198f6a1-0001-7000-8000-000000000001;UUID v7 含毫秒时间戳和随机位。调用方必须在创建买卖请求时生成并持久保存该 ID:新买卖用新 ID;网络重发或 query_order 查询同一买卖单必须原样复用。交易端只验证,不生成或改写。同一 ID 重复提交只会执行一次。buy/sell 返回 submitted_unconfirmed 时,交易端会自动执行一次只读 query_order,结果位于 data.auto_query,绝不自动重发。" } }, "required": [ "stock_no", - "amount" + "amount", + "order_type", + "client_order_id" ], "additionalProperties": False } }, { "name": "cancel", - "description": "撤销指定委托编号的未成交订单。", + "description": "撤销指定委托编号的未成交订单。点击确认后会短暂只读轮询 F3 委托表;仅明确显示已撤/部撤时返回成功。目标消失、仍在飞、状态不明、错表或读取失败均返回 submitted_unconfirmed,并自动做一次只读全量表核验,绝不再次点击撤单。", "inputSchema": { "type": "object", "properties": { @@ -170,18 +374,43 @@ }, "client_order_id": { "type": "string", - "description": "客户端订单 ID,**幂等键**:同一 id 重复提交只会下单一次,重发返回首次回执(首次结果未知时返回 unknown_outcome,仍不会产生第二次提交)。超时后的安全动作就是用同一 id 原样重发。该 id 不写入柜台,仅存于受控端台账,orders_active/orders_filled 尽力回显(回查不到合同编号的单与外部单为 null)。建议全局唯一并含账户维度。" + "pattern": "^gl-[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$", + "description": "撤单请求 ID,**必填幂等键**。格式必须为 gl-<小写 UUID v7>,如 gl-0198f6a1-0001-7000-8000-000000000001;UUID v7 含毫秒时间戳和随机位。调用方必须在创建撤单请求时生成并持久保存该 ID:每个新撤单动作使用新 ID;网络重发同一撤单请求必须原样复用。交易端只验证,不生成或改写。同一 ID 重复提交只会执行一次。cancel 返回 submitted_unconfirmed 时,交易端会按目标 entrust_no 自动读取一次含终态的全量委托表,结果位于 data.auto_query;其中 cancel_state 为已撤/部成后已撤/已成/仍在飞/废单/未知。仅一次只读核验,绝不自动重发。" } }, "required": [ - "entrust_no" + "entrust_no", + "client_order_id" + ], + "additionalProperties": False + } + }, + { + "name": "confirm_external_cancel", + "description": "确认撤销未登记订单。仅接受此前 cancel 回执给出的短时一次性 confirmation_token;确认前会再次核验合同号及证券代码、方向、委托价、委托数量仍一致且订单仍可撤。必须使用新的 client_order_id,令牌过期、已使用或订单变化时绝不执行撤单。", + "inputSchema": { + "type": "object", + "properties": { + "confirmation_token": { + "type": "string", + "description": "此前 cancel 对未登记订单返回的短时一次性确认令牌" + }, + "client_order_id": { + "type": "string", + "pattern": "^gl-[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$", + "description": "本次确认动作的新客户端订单 ID,必填幂等键,格式必须为 gl-<小写 UUID v7>;网络重发同一确认时必须原样复用,不能复用原 cancel 的 ID。" + } + }, + "required": [ + "confirmation_token", + "client_order_id" ], "additionalProperties": False } }, { "name": "switch_account", - "description": "切换同花顺客户端当前活跃的资金账户(向 xiadan 窗口发送 Alt+N,N=账户在客户端账户下拉列表中的槽位序号)。仅在 xiadan 登录了多个账户时有意义。**盲切**:本工具不核验切换是否成功,受控端对账户身份无感知;切换后所有工具(查询/下单)都作用于新的当前账户。调用方必须紧接着用 balance/position 做指纹核对、确认账户无误后再继续操作。", + "description": "明确选择并核验同花顺当前交易账户。连接后会收到只读 account_event(可选账户列表);也可先调用 list_accounts。传入槽位后,程序先核对该槽位对应的账户文本:若已是当前账户,不发送热键,直接核验并返回资金;否则才发送 Alt+N 并确认当前账户匹配该槽位。每次连接后的首次买卖或撤单前必须成功调用一次本工具;失败时禁止后续买卖和撤单。每笔 buy/sell/cancel/confirm_external_cancel 前都会重新核对该账户文本。", "inputSchema": { "type": "object", "properties": { @@ -200,13 +429,13 @@ }, { "name": "query_order", - "description": "按 client_order_id 查单(契约 v2 C5b)。返回 state(未报/已报/部成/已成/已撤/废单/未知)+首次回执快照+分辨率 resolution:by_entrust_no=按合同编号精确命中;heuristic=台账无合同编号时按代码/数量匹配,存在同参重复单歧义;unresolved=实表中无法唯一定位,state=未知需人工。与 buy/sell/cancel 的幂等(同 id 重发不重复下单)配对使用。", + "description": "按 client_order_id 查单(契约 v2 C5b)。买卖单返回 state(未报/已报/部成/已成/已撤/废单/未知)+首次回执快照+分辨率 resolution:by_entrust_no=按合同编号精确命中;heuristic=台账无合同编号时按代码、方向、数量匹配,限价活单还须委托价一致,仍可能有同参重复单歧义;unresolved=实表中无法唯一定位,state=未知需人工。对 cancel 请求 ID,按其目标 entrust_no 精确读取含终态的全量委托表,并额外返回 cancel_state(已撤/部成后已撤/已成/仍在飞/废单/未知)。与 buy/sell/cancel 的幂等(同 id 重发不重复下单)配对使用。", "inputSchema": { "type": "object", "properties": { "client_order_id": { "type": "string", - "description": "下单时传入的 client_order_id" + "description": "买卖或撤单时传入的 client_order_id" } }, "required": [ @@ -239,7 +468,11 @@ def load_tools_schema() -> dict[str, Any]: schema = json.loads(json.dumps(FALLBACK_TOOLS_SCHEMA)) if not cfg.enable_ths_plugin: - trading_names = {"balance", "position", "orders_active", "orders_filled", "settlement", "watchlist", "buy", "sell", "cancel", "switch_account"} + trading_names = { + "balance", "position", "orders_active", "orders_filled", "settlement", + "watchlist", "list_accounts", "buy", "sell", "cancel", "confirm_external_cancel", + "switch_account", "query_order", + } schema["tools"] = [t for t in schema["tools"] if t.get("name") not in trading_names] return schema @@ -252,9 +485,11 @@ def load_tools_schema() -> dict[str, Any]: "orders_filled", "settlement", "watchlist", + "list_accounts", "buy", "sell", "cancel", + "confirm_external_cancel", "switch_account", "query_order", } @@ -301,18 +536,311 @@ def _replay_receipt(coid: str, record: Optional[dict]) -> dict: "first_record": _record_brief(record)}) +def _ledger_fingerprint(record: dict) -> dict: + """读取台账请求指纹;损坏的历史值按空指纹处理。""" + try: + value = json.loads(record.get("fingerprint") or "{}") + except (TypeError, ValueError): + return {} + return value if isinstance(value, dict) else {} + + +def _cancel_target_entrust_no(record: dict) -> Optional[str]: + """撤单要核验的原委托编号,以首次请求参数为准。""" + fingerprint = _ledger_fingerprint(record) + value = fingerprint.get("entrust_no") or record.get("entrust_no") + if value is None: + return None + entrust_no = str(value).strip() + return entrust_no or None + + +def _as_int(value: Any) -> Optional[int]: + try: + return int(value) + except (TypeError, ValueError): + return None + + +def _validate_buy_sell_params(method: str, params: dict[str, Any]) -> Optional[str]: + """校验买卖的显式订单语义,返回错误原因或 ``None``。 + + 该函数只检查请求参数,不访问 backend 或台账;调用方必须在幂等预留和 + ``win_lock`` 之前调用它。这样漏字段、错类型和非法价格都不会留下台账 + 记录,也不会有机会触碰交易窗口。 + """ + order_type = params.get("order_type") + if order_type not in _ORDER_TYPE_SET: + return ( + f"{method} 必须明确指定 order_type,取值只能是 LIMIT 或 FIVE_LEVEL_IOC;" + "已拒绝执行" + ) + + has_price = "price" in params + price = params.get("price") + if order_type == "LIMIT": + if not has_price or isinstance(price, bool) or not isinstance(price, (int, float)): + return "order_type=LIMIT 必须传入正数 price,已拒绝执行" + if not math.isfinite(float(price)) or float(price) <= 0: + return "order_type=LIMIT 的 price 必须是有限且大于 0 的数值,已拒绝执行" + elif has_price: + return "order_type=FIVE_LEVEL_IOC 禁止传入 price,已拒绝执行" + return None + + +def _cancel_state_from_order_row(row: dict) -> str: + """把原委托全量行转换为撤单动作的可审计结论。""" + state = row.get("状态") or "未知" + order_qty = _as_int(row.get("委托数量")) + filled_qty = _as_int(row.get("已成数量")) + + if order_qty is not None and order_qty > 0 and filled_qty is not None and filled_qty >= order_qty: + return "已成" + if state == ST_CANCELED: + if filled_qty is not None and filled_qty > 0: + return "部成后已撤" + return "已撤" + if state == ST_FILLED: + return "已成" + if state in (ST_PENDING, ST_PLACED, ST_PARTIAL): + return "仍在飞" + if state == ST_REJECTED: + return "废单" + return "未知" + + +_EXTERNAL_CANCEL_SNAPSHOT_FIELDS = ( + "entrust_no", + "证券代码", + "方向", + "委托价", + "委托数量", + "已成数量", + "状态", +) + + +def _external_cancel_snapshot(row: dict) -> Optional[dict[str, Any]]: + """提取可用于二次确认的严格订单摘要。 + + 这里不接受缺失、无法规范化或状态未知的行。对人工订单来说,宁可要求用户 + 重新读取后确认,也不能把一张不完整的表当作可执行撤单授权。 + """ + if not isinstance(row, dict): + return None + entrust_no = str(row.get("entrust_no") or "").strip() + stock_no = str(row.get("证券代码") or "").strip() + direction = str(row.get("方向") or "").strip() + order_qty = _as_int(row.get("委托数量")) + filled_qty = _as_int(row.get("已成数量")) + state = str(row.get("状态") or "").strip() + price = row.get("委托价") + if price is None: + normalized_price = None + elif isinstance(price, bool): + return None + else: + try: + normalized_price = round(float(price), 3) + except (TypeError, ValueError): + return None + + if ( + not entrust_no + or not stock_no + or direction not in {"买入", "卖出"} + or order_qty is None + or order_qty <= 0 + or filled_qty is None + or filled_qty < 0 + or state not in {ST_PENDING, ST_PLACED, ST_PARTIAL} + ): + return None + return { + "entrust_no": entrust_no, + "证券代码": stock_no, + "方向": direction, + "委托价": normalized_price, + "委托数量": order_qty, + "已成数量": filled_qty, + "状态": state, + } + + +async def _read_external_cancel_target( + backend, + entrust_no: str, +) -> tuple[Optional[dict[str, Any]], Optional[dict[str, Any]]]: + """全量重读并严格确认目标订单仍可撤。 + + ``orders_active`` 会过滤终态,无法区分「目标不存在」和「刚刚已成/已撤」; + 二次确认必须读取内部全量表,再按合同编号唯一匹配。 + """ + read_all = getattr(backend, "orders_active_all", None) + if not callable(read_all): + return None, contract.fail( + contract.CODE_INTERNAL_ERROR, + contract.CLS_INTERNAL_ERROR, + "受控端不支持未登记订单的全量委托复核,已停止撤单", + data={"submitted": False}, + ) + result = await read_all() + if not contract.is_succeed(result): + detail = ((result.get("error") or {}).get("message")) or "委托表读取失败" + return None, contract.fail( + result.get("code") or contract.CODE_READ_FAILED, + ((result.get("error") or {}).get("class")) or contract.CLS_READ_FAILED, + f"撤单前无法读取全量委托表进行复核({detail}),已停止撤单", + data={"submitted": False, "entrust_no": entrust_no}, + ) + rows = result.get("data") + if not isinstance(rows, list): + return None, contract.fail( + contract.CODE_READ_FAILED, + contract.CLS_READ_FAILED, + "撤单前全量委托表不是行列表,已停止撤单", + data={"submitted": False, "entrust_no": entrust_no}, + ) + matched = [ + row for row in rows + if isinstance(row, dict) and str(row.get("entrust_no") or "").strip() == entrust_no + ] + if len(matched) != 1: + code = contract.CODE_NOT_FOUND if not matched else contract.CODE_TABLE_MISMATCH + cls = contract.CLS_NOT_FOUND if not matched else contract.CLS_TABLE_MISMATCH + reason = "未找到该合同编号" if not matched else "同一合同编号出现多行" + return None, contract.fail( + code, + cls, + f"撤单前复核失败:{reason},已停止撤单", + data={"submitted": False, "entrust_no": entrust_no, "matched_rows": matched}, + ) + snapshot = _external_cancel_snapshot(matched[0]) + if snapshot is None: + return None, contract.fail( + contract.CODE_NOT_FOUND, + contract.CLS_NOT_FOUND, + "撤单前复核发现订单已不可撤、状态未知或关键字段不完整,已停止撤单", + data={"submitted": False, "entrust_no": entrust_no, "order": matched[0]}, + ) + if snapshot["已成数量"] >= snapshot["委托数量"]: + return None, contract.fail( + contract.CODE_NOT_FOUND, + contract.CLS_NOT_FOUND, + "撤单前复核发现订单已全部成交,已停止撤单", + data={"submitted": False, "entrust_no": entrust_no, "order": snapshot}, + ) + return snapshot, None + + +def _confirmation_required(message: str, data: Optional[dict[str, Any]] = None) -> dict[str, Any]: + """返回未登记订单的显式确认回执;这条路径尚未点击撤单。""" + payload: dict[str, Any] = {"submitted": False} + if data: + payload.update(data) + return contract.fail( + contract.CODE_CONFIRMATION_REQUIRED, + contract.CLS_CONFIRMATION_REQUIRED, + message, + data=payload, + ) + + +def _external_cancel_confirmation_error(state: str) -> dict[str, Any]: + """令牌不再可用时,要求调用方重新从 cancel 开始,不触发 GUI。""" + messages = { + "invalid": "confirmation_token 无效,未执行撤单;请重新发起 cancel 获取确认信息", + "missing": "confirmation_token 不存在、已失效或连接已切换,未执行撤单;请重新发起 cancel", + "expired": "confirmation_token 已过期,未执行撤单;请重新发起 cancel", + "used": "confirmation_token 已使用,未执行撤单;请先用原确认请求 ID 查单或重新发起 cancel", + } + return _confirmation_required( + messages.get(state, "confirmation_token 不可用,未执行撤单;请重新发起 cancel"), + {"confirmation_state": state}, + ) + + +def _external_cancel_binding_matches( + expected: dict[str, Any], + actual: dict[str, Any], +) -> bool: + """确认前必须仍是同一笔、同一状态的可撤订单。""" + return all(expected.get(field) == actual.get(field) for field in _EXTERNAL_CANCEL_SNAPSHOT_FIELDS) + + +async def _query_cancel(backend, client_order_id: str, record: dict) -> dict: + """撤单动作的查单:按目标合同编号读内部全量委托表,绝不二次撤单。""" + entrust_no = _cancel_target_entrust_no(record) + if not entrust_no: + logger.warning("[CANCEL_VERIFY] coid=%s lacks target entrust_no", client_order_id) + return contract.ok({ + "client_order_id": client_order_id, + "state": "未知", + "cancel_state": "未知", + "resolution": "unresolved", + "entrust_no": None, + "ledger_state": record.get("state"), + "first_receipt": record.get("receipt"), + "matched_rows": [], + "tables_readable": False, + "note": "撤单请求未保存目标 entrust_no,无法核验是否已撤;需人工核实。", + }) + + read_all = getattr(backend, "orders_active_all", None) + if not callable(read_all): + return contract.fail( + contract.CODE_INTERNAL_ERROR, contract.CLS_INTERNAL_ERROR, + "受控端不支持撤单全量委托核验,无法确认是否已撤") + + all_orders = await read_all() + tables_readable = contract.is_succeed(all_orders) + rows = (all_orders.get("data") or []) if tables_readable else [] + matched = [row for row in rows if str(row.get("entrust_no") or "") == entrust_no] + resolution, state, cancel_state = "unresolved", "未知", "未知" + if len(matched) == 1: + resolution = "by_entrust_no" + state = matched[0].get("状态") or "未知" + cancel_state = _cancel_state_from_order_row(matched[0]) + + logger.info( + "[CANCEL_VERIFY] coid=%s entrust_no=%s readable=%s resolution=%s state=%s " + "cancel_state=%s matches=%d", + client_order_id, entrust_no, tables_readable, resolution, state, cancel_state, len(matched), + ) + return contract.ok({ + "client_order_id": client_order_id, + "state": state, + "cancel_state": cancel_state, + "resolution": resolution, + "entrust_no": entrust_no, + "ledger_state": record.get("state"), + "first_receipt": record.get("receipt"), + "matched_rows": matched, + "tables_readable": tables_readable, + "note": ( + "cancel_state=已撤 或 部成后已撤 才表示柜台已确认撤单;" + "已成/仍在飞/废单/未知均不表示撤单成功。" + ), + }) + + async def _query_order(backend, client_order_id: Any) -> dict: """C5b 按 client_order_id 查单:台账定位 + 实时委托/成交表核实。 分辨率分三档,回执里明说是哪一档——消费侧据此决定信不信: ``by_entrust_no``(台账有 entrust_no,实表精确命中)、 - ``heuristic``(entrust_no 未知,按代码/方向/数量/价格唯一匹配)、 + ``heuristic``(entrust_no 未知,按代码/方向/数量匹配;限价活单再比委托价)、 ``unresolved``(零命中或多命中 → 未知,需人工)。 + + 对撤单请求,`client_order_id` 标识撤单动作而非原买卖单;改按其请求中保存的 + `entrust_no` 精确读取含终态的全量委托表,才能确认已撤或部成后已撤。 + `query_order` 保留读取历史台账中旧格式 ID 的能力;UUID v7 格式仅对新建的 + buy/sell/cancel 生效,避免升级后历史未知订单反而无法核查。 """ - if not client_order_id: + if not isinstance(client_order_id, str) or not client_order_id.strip(): return contract.fail(contract.CODE_INVALID_PARAMS, contract.CLS_INVALID_PARAMS, "query_order 缺少 client_order_id") - coid = str(client_order_id) + coid = client_order_id.strip() led = _ledger_or_none(backend) if led is None: return contract.fail(contract.CODE_LEDGER_UNAVAILABLE, contract.CLS_LEDGER_UNAVAILABLE, @@ -328,6 +856,9 @@ async def _query_order(backend, client_order_id: Any) -> dict: f"台账中没有 client_order_id={coid}:" "本受控端未提交过该 id,或已超出台账保留窗口") + if record.get("method") in CANCEL_METHODS: + return await _query_cancel(backend, coid, record) + active = await backend.orders_active() filled = await backend.orders_filled() active_rows = (active.get("data") or []) if contract.is_succeed(active) else [] @@ -346,18 +877,28 @@ async def _query_order(backend, client_order_id: Any) -> dict: resolution, state = "by_entrust_no", "已成" else: # entrust_no 未知(提交超时那批):按首次请求指纹启发式匹配。 - try: - fp = json.loads(record.get("fingerprint") or "{}") - except (TypeError, ValueError): - fp = {} + fp = _ledger_fingerprint(record) stock_no, amount = str(fp.get("stock_no") or ""), fp.get("amount") + direction = {"buy": "买入", "sell": "卖出"}.get( + record.get("method") or fp.get("method")) + requested_price = fp.get("price") + + def _price_matches(value: Any) -> bool: + if requested_price is None: + return True + try: + return float(value) == float(requested_price) + except (TypeError, ValueError): + return False - def _hit(rows, qty_key): + def _hit(rows, qty_key, *, price_key: Optional[str] = None): return [r for r in rows if (r.get("证券代码") or "") == stock_no - and (amount is None or r.get(qty_key) == amount)] + and (direction is None or r.get("方向") == direction) + and (amount is None or r.get(qty_key) == amount) + and (price_key is None or _price_matches(r.get(price_key)))] - cand = _hit(active_rows, "委托数量") + cand = _hit(active_rows, "委托数量", price_key="委托价") if len(cand) == 1: resolution, state, matched = "heuristic", cand[0].get("状态") or "未知", cand elif not cand: @@ -375,10 +916,91 @@ def _hit(rows, qty_key): "matched_rows": matched, "tables_readable": tables_ok, # False ⇒ state 的可信度仅限台账 "note": ("state=未知 表示实表中无法唯一定位该单,需人工核实;" - "resolution=heuristic 表示按代码/数量匹配而非 id 关联,存在同参重复单歧义"), + "resolution=heuristic 表示按代码/方向/数量(限价活单另含委托价)匹配而非 id 关联," + "存在同参重复单歧义"), }) +def _attach_auto_query(result: dict, query_result: dict) -> None: + """把只读自动核单结果嵌入原未知回执,绝不改变原回执的未知语义。""" + data = result.get("data") + if not isinstance(data, dict): + data = {} + result["data"] = data + data["auto_query"] = query_result + + +async def _auto_query_after_unconfirmed( + backend, + method: str, + client_order_id: str, + result: dict, + *, + lock_held: bool, +) -> None: + """对买卖/撤单未知结果做一次受限的只读核验,不重发、不改变顶层结果。 + + 首次下单路径已持有 ``win_lock``;同 ID 再次调用命中台账时尚未持锁,需在这里 + 按正常窗口访问规则排队。无论锁忙、核单超时还是内部异常,均只附带核单失败信息, + 绝不让它覆盖 ``submitted_unconfirmed`` 或触发第二次下单/撤单。 + """ + if method not in AUTO_QUERY_METHODS or result.get("code") != contract.CODE_SUBMITTED_UNCONFIRMED: + return + + logger.info("[ORDER] submitted_unconfirmed 后开始一次只读核验 coid=%s method=%s", + client_order_id, method) + acquired_here = False + lock = getattr(backend, "win_lock", None) + if not lock_held: + if lock is None: + query_result = contract.fail( + contract.CODE_INTERNAL_ERROR, contract.CLS_INTERNAL_ERROR, + "自动核单未执行:受控端缺少窗口锁") + _attach_auto_query(result, query_result) + return + try: + await asyncio.wait_for(lock.acquire(), LOCK_TIMEOUT_SECS) + acquired_here = True + except asyncio.TimeoutError: + query_result = contract.busy( + "自动核验未执行:受控端正忙;原交易动作未重发,请稍后用同一 client_order_id 查单") + query_result["data"] = {"submitted": True, + "retry_after_secs": BUSY_BACKOFF_HINT_SECS} + _attach_auto_query(result, query_result) + logger.warning("[ORDER] 自动核单排队超时 coid=%s method=%s", client_order_id, method) + return + + try: + try: + query_result = await asyncio.wait_for( + _query_order(backend, client_order_id), AUTO_QUERY_TIMEOUT_SECS) + except asyncio.TimeoutError: + # 与主调用超时同理:查询线程可能仍在操作窗口,作废代次后再放锁。 + backend.degraded = True + invalidate = getattr(backend, "invalidate_inflight", None) + if invalidate: + invalidate(f"自动核单超过 {AUTO_QUERY_TIMEOUT_SECS}s 未完成") + query_result = contract.fail( + contract.CODE_CALL_TIMEOUT, contract.CLS_CALL_TIMEOUT, + "自动核验超时,原交易动作结果仍未知;未自动重发,请稍后用同一 client_order_id 查单") + logger.warning("[ORDER] 自动核单超时 coid=%s method=%s", client_order_id, method) + except Exception: + logger.exception("[ORDER] 自动核单异常 coid=%s method=%s", client_order_id, method) + query_result = contract.fail( + contract.CODE_INTERNAL_ERROR, contract.CLS_INTERNAL_ERROR, + "自动核验异常,原交易动作结果仍未知;未自动重发,请稍后用同一 client_order_id 查单") + finally: + if acquired_here: + lock.release() + + _attach_auto_query(result, query_result) + query_data = query_result.get("data") if isinstance(query_result, dict) else None + logger.info("[ORDER] 自动核验 coid=%s method=%s result=%s/%s state=%s resolution=%s", + client_order_id, method, query_result.get("status"), query_result.get("code"), + query_data.get("state") if isinstance(query_data, dict) else None, + query_data.get("resolution") if isinstance(query_data, dict) else None) + + async def handle_call( frame: dict[str, Any], backend: WinThsBackend, @@ -390,6 +1012,16 @@ async def handle_call( reply = {"type": "reply", "id": frame_id} + if not isinstance(params, dict): + msg = "params 必须是对象" + reply["ok"] = False + reply["result"] = contract.fail( + contract.CODE_INVALID_PARAMS, contract.CLS_INVALID_PARAMS, msg, + data={"submitted": False}, + ) + reply["error"] = msg + return reply + if method not in METHOD_WHITELIST: msg = f"方法 '{method}' 不支持" reply["ok"] = False @@ -414,9 +1046,11 @@ async def handle_call( "orders_filled", "settlement", "watchlist", + "list_accounts", "buy", "sell", "cancel", + "confirm_external_cancel", "switch_account", "query_order", } @@ -428,43 +1062,116 @@ async def handle_call( reply["error"] = msg return reply - # --- C5a 幂等:在**拿锁之前**查台账。重发直接返回首次回执,连排队都不用排, - # 更不会走到点提交那一步。台账不可用一律拒单(需求方拍板:禁静默降级)。 + if method == "cancel" and ( + not isinstance(params.get("entrust_no"), str) + or not params["entrust_no"].strip() + ): + msg = "cancel 缺少有效 entrust_no,未执行撤单" + reply["ok"] = False + reply["result"] = contract.fail( + contract.CODE_INVALID_PARAMS, + contract.CLS_INVALID_PARAMS, + msg, + data={"submitted": False}, + ) + reply["error"] = msg + return reply + + # 买卖契约校验必须早于台账 reserve、win_lock 和 backend 调用;尤其不能 + # 让缺失 order_type 的请求沿用旧的 price 推断路径。 + if method in ("buy", "sell"): + validation_error = _validate_buy_sell_params(method, params) + if validation_error is not None: + reply["ok"] = False + reply["result"] = contract.fail( + contract.CODE_INVALID_PARAMS, + contract.CLS_INVALID_PARAMS, + validation_error, + data={"submitted": False}, + ) + reply["error"] = validation_error + return reply + + # --- C5a 幂等:下单/撤单必须带业务级 ID,在**拿锁之前**查台账。重发直接 + # 返回首次回执,连排队都不用排,更不会走到点提交那一步。 reserved_coid: Optional[str] = None + # 当首次 cancel 只返回人工确认提示时,同 coid 重放可以安全刷新令牌。它不是 + # 新的台账预留,锁忙时不得删除原记录。 + refreshing_confirmation_prompt = False + confirmation_prompt_token: Optional[str] = None + confirmed_target_entrust_no: Optional[str] = None + real_submission_started = False if method in IDEMPOTENT_METHODS: coid = params.get("client_order_id") - if coid is not None: - coid = str(coid) - led = _ledger_or_none(backend) - if led is None: - msg = ("下单台账不可用,已拒绝下单——无台账即无法保证 client_order_id 幂等," - "重发会造成重复下单。请检查受控端数据目录后重试") - reply["ok"] = False - reply["result"] = contract.fail(contract.CODE_LEDGER_UNAVAILABLE, - contract.CLS_LEDGER_UNAVAILABLE, msg) - reply["error"] = msg - return reply - try: - verdict, record = await asyncio.to_thread(led.reserve, coid, method, params) - except LedgerUnavailable as e: - msg = f"下单台账不可用,已拒绝下单(禁降级为无幂等下单):{e}" - reply["ok"] = False - reply["result"] = contract.fail(contract.CODE_LEDGER_UNAVAILABLE, - contract.CLS_LEDGER_UNAVAILABLE, msg) - reply["error"] = msg - return reply - if verdict == "conflict": - msg = (f"client_order_id={coid} 已用于参数不同的委托,拒绝执行。" - "同 id 必须对应同一笔委托——请换新 id,或用 query_order 查原单") - reply["ok"] = False - reply["result"] = contract.fail(contract.CODE_INVALID_PARAMS, - contract.CLS_INVALID_PARAMS, msg, - data={"submitted": False, - "first_record": _record_brief(record)}) - reply["error"] = msg - return reply - if verdict == "duplicate": + if not isinstance(coid, str) or _CLIENT_ORDER_ID_RE.fullmatch(coid) is None: + logger.warning("[ORDER] 拒绝无效 client_order_id method=%s frame_id=%s coid=%r", + method, frame_id, coid) + msg = (f"{method} 的 client_order_id 格式无效,必须为 " + "gl-<小写 UUID v7>(如 gl-0198f6a1-0001-7000-8000-000000000001);" + "已拒绝执行") + reply["ok"] = False + reply["result"] = contract.fail(contract.CODE_INVALID_PARAMS, + contract.CLS_INVALID_PARAMS, msg, + data={"submitted": False}) + reply["error"] = msg + return reply + if method == "confirm_external_cancel" and ( + not isinstance(params.get("confirmation_token"), str) + or not params["confirmation_token"].strip() + ): + msg = "confirm_external_cancel 缺少 confirmation_token,未执行撤单" + reply["ok"] = False + reply["result"] = contract.fail( + contract.CODE_INVALID_PARAMS, + contract.CLS_INVALID_PARAMS, + msg, + data={"submitted": False}, + ) + reply["error"] = msg + return reply + led = _ledger_or_none(backend) + if led is None: + msg = ("下单台账不可用,已拒绝下单——无台账即无法保证 client_order_id 幂等," + "重发会造成重复下单。请检查受控端数据目录后重试") + reply["ok"] = False + reply["result"] = contract.fail(contract.CODE_LEDGER_UNAVAILABLE, + contract.CLS_LEDGER_UNAVAILABLE, msg) + reply["error"] = msg + return reply + try: + verdict, record = await asyncio.to_thread(led.reserve, coid, method, params) + except LedgerUnavailable as e: + msg = f"下单台账不可用,已拒绝下单(禁降级为无幂等下单):{e}" + reply["ok"] = False + reply["result"] = contract.fail(contract.CODE_LEDGER_UNAVAILABLE, + contract.CLS_LEDGER_UNAVAILABLE, msg) + reply["error"] = msg + return reply + if verdict == "conflict": + msg = (f"client_order_id={coid} 已用于参数不同的委托,拒绝执行。" + "同 id 必须对应同一笔委托——请换新 id,或用 query_order 查原单") + reply["ok"] = False + reply["result"] = contract.fail(contract.CODE_INVALID_PARAMS, + contract.CLS_INVALID_PARAMS, msg, + data={"submitted": False, + "first_record": _record_brief(record)}) + reply["error"] = msg + return reply + if verdict == "duplicate": + if ( + method == "cancel" + and cfg.external_cancel_confirmation + == _config.EXTERNAL_CANCEL_CONFIRMATION_TWO_STEP + and _is_unsubmitted_external_cancel_prompt(record) + ): + # confirmation_required 意味着尚未发送撤单;重新读表、换发令牌 + # 仍然是同一笔未提交动作的安全重放,不会点击 GUI。 + reserved_coid = coid + refreshing_confirmation_prompt = True + logger.info("[RPC] 刷新未登记订单撤单确认 coid=%s", coid) + else: result = _replay_receipt(coid, record) + await _auto_query_after_unconfirmed(backend, method, coid, result, lock_held=False) reply["ok"] = contract.is_succeed(result) reply["result"] = result if not reply["ok"]: @@ -472,7 +1179,7 @@ async def handle_call( logger.info("[RPC] 幂等命中 coid=%s state=%s,未产生第二次提交", coid, (record or {}).get("state")) return reply - reserved_coid = coid + reserved_coid = coid # 串行化 THS 单窗口访问:order_watch 轮询与下单/查询共用 backend.win_lock。 # 拿锁带超时:持锁方若被弹窗/慢操作拖住,排队方不能无限饿死——回 busy @@ -488,13 +1195,29 @@ async def handle_call( result = contract.busy(msg) result["data"] = {"submitted": False, "retry_after_secs": BUSY_BACKOFF_HINT_SECS} - if reserved_coid: + if reserved_coid and not refreshing_confirmation_prompt: _release_reservation(backend, reserved_coid) reply["ok"] = False reply["result"] = result reply["error"] = msg return reply try: + # 账户文本是同花顺当前交易账户的唯一已确认身份信号。每个真实交易路径 + # 均在持有 win_lock 后、读取订单表/消费人工撤单令牌/发送任何 UI 输入前 + # 重新核验;启动后必须先经 switch_account 显式建立本进程基线,之后 + # 文本变化一律阻断。 + if method in ORDER_METHODS: + account_preflight = await backend.verify_account_for_trade() + if not contract.is_succeed(account_preflight): + if reserved_coid and not refreshing_confirmation_prompt: + _release_reservation(backend, reserved_coid) + reserved_coid = None + reply["ok"] = False + reply["result"] = account_preflight + reply["error"] = ((account_preflight.get("error") or {}).get("message") + or "交易前账户核验失败") + return reply + # 上一笔调用超时(疑似弹窗阻塞)后进入 degraded:先清残留弹窗再干活。 # 清扫失败不阻断本次调用。 if getattr(backend, "degraded", False): @@ -505,6 +1228,7 @@ async def handle_call( backend.degraded = False async def _invoke() -> Any: + nonlocal confirmation_prompt_token, confirmed_target_entrust_no, real_submission_started if method == "balance": logger.info("[RPC] method=balance, frame_id=%s", frame_id) r = await backend.balance() @@ -527,15 +1251,99 @@ async def _invoke() -> Any: price = params.get("price") client_order_id = params.get("client_order_id") fn = backend.buy if method == "buy" else backend.sell + real_submission_started = True r = await fn(stock_no, amount, price, client_order_id) _eno = ((r or {}).get("data") or {}).get("entrust_no") if _eno: backend.agent_entrust_nos.add(str(_eno)) return r if method == "cancel": - return await backend.cancel(params.get("entrust_no")) + entrust_no = params["entrust_no"].strip() + if ( + cfg.external_cancel_confirmation + == _config.EXTERNAL_CANCEL_CONFIRMATION_DIRECT + ): + real_submission_started = True + return await backend.cancel(entrust_no) + + try: + is_registered = await asyncio.to_thread(led.has_entrust_no, entrust_no) + except LedgerUnavailable as e: + return contract.fail( + contract.CODE_LEDGER_UNAVAILABLE, + contract.CLS_LEDGER_UNAVAILABLE, + f"读取本机下单台账失败,已停止撤单:{e}", + data={"submitted": False, "entrust_no": entrust_no}, + ) + if is_registered: + real_submission_started = True + return await backend.cancel(entrust_no) + + snapshot, read_failure = await _read_external_cancel_target( + backend, entrust_no + ) + if read_failure is not None: + return read_failure + assert snapshot is not None + confirmation_prompt_token, _ = _issue_external_cancel_confirmation( + params["client_order_id"], snapshot, snapshot + ) + return _confirmation_required( + "该委托未由本系统登记。已读取到订单摘要,但尚未执行撤单;" + "请向用户展示摘要并调用 confirm_external_cancel 明确确认。", + { + "entrust_no": entrust_no, + "order": snapshot, + "confirmation_token": confirmation_prompt_token, + "confirmation_expires_in_secs": int( + EXTERNAL_CANCEL_CONFIRMATION_TTL_SECS + ), + }, + ) + if method == "confirm_external_cancel": + token_state, confirmation = _consume_external_cancel_confirmation( + params["confirmation_token"] + ) + if token_state != "ok" or confirmation is None: + return _external_cancel_confirmation_error(token_state) + + binding = confirmation.get("binding") + if not isinstance(binding, dict): + return _external_cancel_confirmation_error("missing") + entrust_no = str(binding.get("entrust_no") or "").strip() + if not entrust_no: + return _external_cancel_confirmation_error("missing") + confirmed_target_entrust_no = entrust_no + + current, read_failure = await _read_external_cancel_target( + backend, entrust_no + ) + if read_failure is not None: + return read_failure + assert current is not None + if not _external_cancel_binding_matches(binding, current): + return _confirmation_required( + "确认前复核发现订单已变化,未执行撤单;请重新发起 cancel 并展示最新摘要。", + { + "entrust_no": entrust_no, + "expected_order": binding, + "current_order": current, + }, + ) + + real_submission_started = True + r = await backend.cancel(entrust_no) + if isinstance(r, dict): + data = r.get("data") + if isinstance(data, dict): + data.setdefault("entrust_no", entrust_no) + elif data is None: + r["data"] = {"entrust_no": entrust_no} + return r if method == "switch_account": return await backend.switch_account(params.get("slot")) + if method == "list_accounts": + return await backend.list_accounts() if method == "query_order": return await _query_order(backend, params.get("client_order_id")) return contract.fail(contract.CODE_INTERNAL_ERROR, @@ -548,6 +1356,9 @@ async def _invoke() -> Any: result = await asyncio.wait_for(_invoke(), CALL_TIMEOUT_SECS) except asyncio.TimeoutError: backend.degraded = True + if confirmation_prompt_token: + # 令牌尚未安全写入/返回给调用方,不能留下不可审计的可执行授权。 + _invalidate_external_cancel_confirmation(confirmation_prompt_token) # wait_for 只取消了等待协程——to_thread 起的工作线程取消不掉,它还在 # 发全局按键,而下面 finally 马上要放 win_lock 让下一笔进场。作废代次, # 让那个线程在下一个检查点(翻页/抓表/弹窗/提交)自己停手, @@ -557,16 +1368,26 @@ async def _invoke() -> Any: invalidate(f"{method} 超过 {CALL_TIMEOUT_SECS}s 未完成") logger.error("[RPC] %s 超过 %ss 未完成,标记 degraded,回 unknown", method, CALL_TIMEOUT_SECS) - if method in ORDER_METHODS: + if method in ORDER_METHODS and real_submission_started: + if method in CANCEL_METHODS: + message = ( + "受控端处理超时(疑似弹窗或客户端无响应),撤单动作可能已提交。" + "安全动作=用同一 client_order_id 原样重发(幂等,不会第二次撤单)," + "或查看本次 data.auto_query / 调 query_order 核验目标委托;勿换新 id 重撤") + else: + message = ( + "受控端处理超时(疑似弹窗或客户端无响应),委托可能已提交。" + "安全动作=用同一 client_order_id 原样重发(幂等,不会重复下单)," + "或调 query_order/orders_active 核实;勿改单重下") result = contract.submitted_unconfirmed( - "受控端处理超时(疑似弹窗或客户端无响应),委托可能已提交。" - "安全动作=用同一 client_order_id 原样重发(幂等,不会重复下单)," - "或调 query_order/orders_active 核实;勿改单重下", + message, data={"submitted": True}) else: result = contract.fail( contract.CODE_CALL_TIMEOUT, contract.CLS_CALL_TIMEOUT, - "受控端查询超时(疑似弹窗或客户端无响应),请稍后重试") + "受控端处理超时,但尚未开始真实交易动作;请稍后重新发起请求", + data={"submitted": False}, + ) if not isinstance(result, dict) or "status" not in result: result = contract.fail(contract.CODE_INTERNAL_ERROR, @@ -575,27 +1396,69 @@ async def _invoke() -> Any: # 下单类:回填 client_order_id 并把首次回执落台账(幂等重发就靠它)。 if reserved_coid: + settled_coid = reserved_coid if isinstance(result.get("data"), dict): - result["data"]["client_order_id"] = reserved_coid + result["data"]["client_order_id"] = settled_coid elif result.get("data") is None: - result["data"] = {"client_order_id": reserved_coid} + result["data"] = {"client_order_id": settled_coid} entrust_no = (result.get("data") or {}).get("entrust_no") + if method in CANCEL_METHODS: + target_entrust_no = ( + str(params.get("entrust_no") or "").strip() + if method == "cancel" + else confirmed_target_entrust_no + ) + if target_entrust_no and isinstance(result.get("data"), dict): + result["data"].setdefault("entrust_no", target_entrust_no) + # cancel 的请求指纹本身保存了目标编号;confirm 的参数只含令牌, + # 必须把经过复核的目标编号写入台账,供 query_order 精确核验。 + entrust_no = None if method == "cancel" else target_entrust_no try: led = _ledger_or_none(backend) - if led is not None: - await asyncio.to_thread(led.complete, reserved_coid, result, + should_refresh_receipt = ( + result.get("code") == contract.CODE_CONFIRMATION_REQUIRED + and isinstance(result.get("data"), dict) + and "confirmation_token" in result["data"] + ) + if refreshing_confirmation_prompt and not should_refresh_receipt: + # 本次刷新没能生成新令牌(例如读表失败)。保留原提示记录, + # 以便同一个 cancel ID 之后安全再试。 + reserved_coid = None + elif led is not None: + await asyncio.to_thread(led.complete, settled_coid, + _receipt_for_ledger(result), str(entrust_no) if entrust_no else None) reserved_coid = None # 已落定,finally 不再回滚 except LedgerUnavailable: - # 单已经下出去了,台账却写不进——绝不静默:明确降级为「结果不可知」, - # 逼调用方去核单,而不是让它以为下单成功。 - logger.exception("台账回写失败 coid=%s,回执降级为 unknown_outcome", reserved_coid) - result = contract.submitted_unconfirmed( - "委托已提交,但台账回写失败——本次结果无法保证可幂等重放," - "请立即用 orders_active/orders_filled 人工核单", - data={"submitted": True, "client_order_id": reserved_coid}) + if real_submission_started: + # 单已经下出去了,台账却写不进——绝不静默:明确降级为「结果不可知」, + # 逼调用方去核单,而不是让它以为下单成功。 + logger.exception( + "台账回写失败 coid=%s,回执降级为 unknown_outcome", reserved_coid + ) + result = contract.submitted_unconfirmed( + "委托已提交,但台账回写失败——本次结果无法保证可幂等重放," + "请立即用 orders_active/orders_filled 人工核单", + data={"submitted": True, "client_order_id": settled_coid}, + ) + else: + if confirmation_prompt_token: + _invalidate_external_cancel_confirmation(confirmation_prompt_token) + logger.exception( + "台账回写失败 coid=%s,未开始真实交易,撤销确认授权", reserved_coid + ) + result = contract.fail( + contract.CODE_LEDGER_UNAVAILABLE, + contract.CLS_LEDGER_UNAVAILABLE, + "台账无法保存本次确认回执,未执行撤单;请检查后重新发起请求", + data={"submitted": False, "client_order_id": settled_coid}, + ) reserved_coid = None + # 未知时只做一次本地只读核验;不调用 buy/sell/cancel,因此不会自动重发。 + await _auto_query_after_unconfirmed(backend, method, settled_coid, result, + lock_held=True) + reply["result"] = result reply["ok"] = contract.is_succeed(result) if not reply["ok"]: @@ -603,7 +1466,7 @@ async def _invoke() -> Any: or f"{result.get('status')}/{result.get('code')}") except Exception as e: - logger.error("处理 RPC '%s' 出错:%s", method, e) + logger.exception("处理 RPC '%s' 出错:%s", method, e) reply["ok"] = False reply["result"] = contract.fail(contract.CODE_INTERNAL_ERROR, contract.CLS_INTERNAL_ERROR, str(e)) diff --git a/src/trader/handshake.py b/src/trader/handshake.py index 28ace9f..85c225c 100644 --- a/src/trader/handshake.py +++ b/src/trader/handshake.py @@ -13,6 +13,19 @@ logger = logging.getLogger(__name__) CLIENT_VERSION = "0.8.0" +_REDACTED = "" + + +def _redact_response_for_log(response: dict[str, Any]) -> dict[str, Any]: + """保留握手诊断字段,但配对码和凭证绝不能落入 trader.log。""" + safe = dict(response) + if safe.get("type") == "pair_pending" and "code" in safe: + safe["code"] = _REDACTED + for key in tuple(safe): + normalized = str(key).lower().replace("-", "_") + if any(part in normalized for part in ("token", "authorization", "password", "secret")): + safe[key] = _REDACTED + return safe @dataclass @@ -62,7 +75,7 @@ async def _pair_init( try: raw_response = await asyncio.wait_for(ws.recv(), timeout=5.0) response = json.loads(raw_response) - logger.info("收到握手应答:%s", response) + logger.info("收到握手应答:%s", _redact_response_for_log(response)) frame_type = response.get("type") @@ -118,7 +131,7 @@ async def _resume( try: raw_response = await asyncio.wait_for(ws.recv(), timeout=5.0) response = json.loads(raw_response) - logger.info("收到握手应答:%s", response) + logger.info("收到握手应答:%s", _redact_response_for_log(response)) frame_type = response.get("type") diff --git a/src/trader/main.py b/src/trader/main.py index ead3265..90c5314 100644 --- a/src/trader/main.py +++ b/src/trader/main.py @@ -89,6 +89,9 @@ def flush(self): format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", force=True, ) + # 根 logger 维持 INFO,避免 websockets 等依赖把文件日志淹没;项目自己的 + # DEBUG 是排查 UI、表格和弹窗动作所需的完整诊断信息,应写入 trader.log。 + logging.getLogger("trader").setLevel(logging.DEBUG) # 3) 顶层异常都写到日志(uncaught exception hook) def _excepthook(exc_type, exc_value, exc_tb): @@ -616,6 +619,7 @@ def run() -> None: account_name=result.config.account_name or "", agent_token=result.config.agent_token or None, enable_ths_plugin=result.config.enable_ths_plugin, + external_cancel_confirmation=result.config.external_cancel_confirmation, ) def on_open_xiadan() -> None: diff --git a/src/trader/main_window.py b/src/trader/main_window.py index 14a57e7..cc57ee6 100644 --- a/src/trader/main_window.py +++ b/src/trader/main_window.py @@ -64,6 +64,7 @@ class SharedState: ths_refreshing: bool = False # 配对码过期·正在刷新中 agent_token: Optional[str] = None # 永久凭证(仅 CONNECTED 时有用) enable_ths_plugin: bool = True # 同花顺交易插件启用状态 + external_cancel_confirmation: str = "two_step" log_messages: queue.Queue = field(default_factory=lambda: queue.Queue(maxsize=500)) _lock: threading.Lock = field(default_factory=threading.Lock) @@ -90,6 +91,7 @@ def snapshot(self) -> dict: "ths_refreshing": self.ths_refreshing, "agent_token": self.agent_token, "enable_ths_plugin": self.enable_ths_plugin, + "external_cancel_confirmation": self.external_cancel_confirmation, "self_update_info": self.self_update_info, "self_update_progress": self.self_update_progress, "self_update_status": self.self_update_status, @@ -192,6 +194,7 @@ def __init__( # 载入初始插件偏好 snap = self.state.snapshot() self.enable_ths_plugin = snap.get("enable_ths_plugin", True) + self.external_cancel_confirmation = snap.get("external_cancel_confirmation", "two_step") self._build_ui() self._schedule_poll() @@ -542,6 +545,26 @@ def _build_ui(self) -> None: highlightbackground="#d0d7de", highlightthickness=1, ) + self.external_cancel_confirmation_var = tk.BooleanVar( + master=self.root, + value=self.external_cancel_confirmation != "direct", + ) + self.external_cancel_confirmation_check = tk.Checkbutton( + self.ths_body, + text="未登记订单撤单需二次确认", + variable=self.external_cancel_confirmation_var, + command=self._toggle_external_cancel_confirmation, + bg="#ffffff", + fg="#57606a", + activebackground="#ffffff", + activeforeground="#24292f", + font=("Helvetica", 8), + anchor="w", + padx=0, + pady=2, + ) + self.external_cancel_confirmation_check.pack(fill="x", pady=(8, 0)) + # 右分栏底部:退出程序排版 right_footer = tk.Frame(right_frame, bg="#f6f8fa") right_footer.pack(fill="x", side="bottom", pady=(10, 0)) @@ -644,6 +667,27 @@ def _toggle_ths_plugin(self) -> None: self.ths_switch_btn.config(text="已禁用 ⚪", fg="#57606a", bg="#fafbfc") self.ths_body.pack_forget() + def _toggle_external_cancel_confirmation(self) -> None: + """持久化未登记订单撤单的本地确认偏好。""" + mode = "two_step" if self.external_cancel_confirmation_var.get() else "direct" + try: + from . import config as _config + + cfg = _config.load() + cfg.external_cancel_confirmation = mode + _config.save(cfg) + self.external_cancel_confirmation = mode + self.state.update(external_cancel_confirmation=mode) + self.state.log( + "[配置] 未登记订单撤单二次确认已" + + ("开启" if mode == "two_step" else "关闭") + ) + except Exception as e: + self.external_cancel_confirmation_var.set( + self.external_cancel_confirmation != "direct" + ) + self.state.log(f"⚠ 保存配置失败: {e}") + def _schedule_poll(self) -> None: """tk after-loop 周期同步 SharedState → UI""" self._sync_state() diff --git a/src/trader/order_ledger.py b/src/trader/order_ledger.py index e715a9c..460a5fd 100644 --- a/src/trader/order_ledger.py +++ b/src/trader/order_ledger.py @@ -18,6 +18,7 @@ from __future__ import annotations import json +import hashlib import logging import sqlite3 import threading @@ -54,9 +55,21 @@ class LedgerUnavailable(RuntimeError): def fingerprint(method: str, params: dict[str, Any]) -> str: - """请求指纹:同 id 不同参数要能认出来。""" - keys = ("stock_no", "amount", "price", "entrust_no") + """请求指纹:同 id 不同参数要能认出来。 + + ``order_type`` 是买卖的业务语义,而不仅是 price 的表现形式;必须写入 + 指纹,避免同一个 client_order_id 在限价和五档即成剩撤之间切换。 + """ + keys = ("stock_no", "amount", "price", "order_type", "entrust_no") payload = {k: params.get(k) for k in keys if params.get(k) is not None} + if method == "confirm_external_cancel": + # 确认令牌只应留在进程内;台账只保存摘要来区分同一 coid 被换令牌的 + # 调用,不能把可执行令牌落盘。 + token = params.get("confirmation_token") + if isinstance(token, str): + payload["confirmation_token_sha256"] = hashlib.sha256( + token.encode("utf-8") + ).hexdigest() return json.dumps({"method": method, **payload}, sort_keys=True, ensure_ascii=False) @@ -162,22 +175,45 @@ def get(self, client_order_id: str) -> Optional[dict]: raise LedgerUnavailable(f"台账读取失败:{e}") from e def coid_by_entrust(self) -> dict[str, str]: - """entrust_no → client_order_id,供 orders_active/orders_filled 回显 join。 + """买卖 entrust_no → client_order_id,供 orders_active/orders_filled 回显 join。 读失败返回空表:**回显是尽力而为的增强字段**(对账主键是 entrust_no), - 不能因为 join 不上就让查询整体失败。 + 不能因为 join 不上就让查询整体失败。撤单动作也会引用目标 entrust_no,但不能 + 覆盖原买卖单的关联;撤单 ID 的核验走其自身台账记录。 """ try: with self._connect() as conn: rows = conn.execute( "SELECT entrust_no, client_order_id FROM orders" - " WHERE entrust_no IS NOT NULL AND entrust_no != ''").fetchall() + " WHERE method IN ('buy', 'sell')" + " AND entrust_no IS NOT NULL AND entrust_no != ''").fetchall() return {str(r["entrust_no"]): str(r["client_order_id"]) for r in rows} except sqlite3.Error: logger.warning("台账 entrust_no 映射读取失败,本次不回显 client_order_id", exc_info=True) return {} + def has_entrust_no(self, entrust_no: object) -> bool: + """是否存在本机登记的买卖订单合同号。 + + 撤单记录本身只能说明曾请求撤某个编号,不能证明该原始订单由本机工具创建; + 因此这里严格只查买卖记录,供未登记订单的撤单确认闸门使用。 + """ + value = str(entrust_no or "").strip() + if not value: + return False + try: + with self._lock, self._connect() as conn: + row = conn.execute( + "SELECT 1 FROM orders" + " WHERE method IN ('buy', 'sell') AND entrust_no=?" + " LIMIT 1", + (value,), + ).fetchone() + return row is not None + except sqlite3.Error as e: + raise LedgerUnavailable(f"台账合同号映射读取失败:{e}") from e + def _row_to_dict(row: sqlite3.Row) -> dict: d = dict(row) diff --git a/src/trader/order_watch.py b/src/trader/order_watch.py index bf97d2f..947c9c5 100644 --- a/src/trader/order_watch.py +++ b/src/trader/order_watch.py @@ -7,10 +7,12 @@ from __future__ import annotations import asyncio +import inspect import logging import time +from collections import deque from datetime import datetime, time as dtime -from typing import Any, Optional +from typing import Any, Deque, Optional from . import config, contract from .ths.rows import ST_CANCELED, ST_FILLED, ST_PARTIAL, ST_REJECTED, is_in_flight @@ -19,6 +21,7 @@ IDLE_INTERVAL_DEFAULT = 300 # 空闲(无未完成委托)轮询周期:5 分钟。验证码顾虑→分钟级 ACTIVE_INTERVAL_DEFAULT = 60 # 有未完成委托挂着时提速:1 分钟(为及时抓成交) +SEND_RETRY_INTERVAL_DEFAULT = 5 # 主动事件发送失败后短间隔重试;不重新读取/操作交易窗口。 FRAME_TYPE = "order_event" # 契约 v2 规范化后的键(不再是 THS 原始表头)。 @@ -136,8 +139,62 @@ def next_interval(snapshot: dict, idle_secs: int, active_secs: int) -> int: return active_secs if any(_is_open(o) for o in snapshot.values()) else idle_secs -async def _poll_once(backend, client, prev: Optional[dict], seq: int) -> tuple[Optional[dict], int, bool]: - """单轮:取委托快照 → diff → 发帧。返回 (new_prev, new_seq, ok)。""" +async def _send_frame(client, frame: dict) -> bool: + """生产客户端必须显式返回 ``True`` 才算已写入。""" + send_result = client.send_frame(frame) + if inspect.isawaitable(send_result): + send_result = await send_result + return send_result is True + + +def _pending_events(client, pending: Optional[Deque[dict]]) -> Deque[dict]: + """取得看门狗的待发 FIFO;兼容直接调用内部薄壳的旧测试/调用方。""" + if pending is not None: + return pending + queue = getattr(client, "_order_watch_pending_events", None) + if queue is None: + queue = deque() + setattr(client, "_order_watch_pending_events", queue) + return queue + + +async def _flush_pending_events(client, pending: Deque[dict]) -> bool: + """按 FIFO 重发未确认写入的帧;成功帧只出队一次。""" + try: + while pending: + frame = pending[0] + if not await _send_frame(client, frame): + logger.warning("order_watch 待发事件未写入(下轮重试)") + return False + pending.popleft() + logger.info("order_watch 发送成功 %s entrust=%s seq=%s", + frame["event"], frame["entrust_no"], frame["seq"]) + except Exception as e: + logger.warning("order_watch 待发事件发送异常(下轮重试):%s", e) + return False + return True + + +async def _poll_once( + backend, + client, + prev: Optional[dict], + seq: int, + pending: Optional[Deque[dict]] = None, +) -> tuple[Optional[dict], int, bool]: + """单轮:先送待发帧,再取委托快照并排入新事件。 + + ``pending`` 是进程内 FIFO。每个事件分配序号后不再改变,只有成功写入当前 + WebSocket 才出队;观察基线在事件入队时推进,以免失败后重新构造已成功帧。 + 断线期间不执行任何交易 RPC,只保留通知帧。 + """ + pending = _pending_events(client, pending) + + # 先清空上轮失败留下的帧。失败时不要读取 THS 表或改变基线,避免同一事件 + # 被重新构造、已成功帧被重复提交,或在失败窗口内丢失原始观察结果。 + if pending and not await _flush_pending_events(client, pending): + return prev, seq, False + async with backend.win_lock: # 全量表(含终态):终态行正是 filled/canceled 事件的来源。 active = await backend.orders_active_all() @@ -151,17 +208,21 @@ async def _poll_once(backend, client, prev: Optional[dict], seq: int) -> tuple[O for eno in prev: if eno not in cur: logger.warning("order_watch 委托 %s 已从委托表消失,保守起见未发事件", eno) + + # 快照只能在所有 diff 事件都已排入 FIFO 后推进。这样单批的首帧已成功、 + # 后帧失败时,不会重新发送首帧;未发帧保留自己的 seq 和检测时间。 try: for ev in events: seq += 1 ev["seq"] = seq ev["ts"] = time.time() - await client.send_frame(ev) - logger.info("order_watch 推送 %s entrust=%s source=%s filled=%s", - ev["event"], ev["entrust_no"], ev["source"], ev["filled_qty"]) + pending.append(ev) + + if not await _flush_pending_events(client, pending): + return cur, seq, False except Exception as e: logger.warning("order_watch 发送事件失败(下轮重试):%s", e) - return prev, seq, False # 不推进基线,保留未发的事件供下轮重试 + return cur, seq, False return cur, seq, True @@ -176,6 +237,7 @@ async def order_watch_task(state, client) -> None: active_secs = cfg.order_watch_active_secs or ACTIVE_INTERVAL_DEFAULT prev: Optional[dict] = None seq = 0 + pending: Deque[dict] = deque() interval = idle_secs logger.info("order_watch_task 启动(空闲 %ds / 活跃 %ds)", idle_secs, active_secs) while True: @@ -184,18 +246,26 @@ async def order_watch_task(state, client) -> None: snap = state.snapshot() if snap.get("connection_state") != "CONNECTED": logger.debug("order_watch 跳过:连接状态 %s", snap.get("connection_state")) - interval = idle_secs + interval = min(active_secs, SEND_RETRY_INTERVAL_DEFAULT) if pending else idle_secs continue if not snap.get("enable_ths_plugin", True): logger.debug("order_watch 跳过:THS 插件已禁用") - interval = idle_secs + interval = min(active_secs, SEND_RETRY_INTERVAL_DEFAULT) if pending else idle_secs + continue + # 断线期间积压的事件不需要访问交易窗口,连接恢复后立即按原 seq + # 重发;这条路径只发送通知帧,绝不触发买卖或撤单 RPC。 + if pending and not await _flush_pending_events(client, pending): + interval = min(active_secs, SEND_RETRY_INTERVAL_DEFAULT) continue if not in_trading_session(datetime.now()): logger.debug("order_watch 跳过:非交易时段") interval = idle_secs continue - prev, seq, ok = await _poll_once(backend, client, prev, seq) - interval = next_interval(prev, idle_secs, active_secs) if (ok and prev) else idle_secs + prev, seq, ok = await _poll_once(backend, client, prev, seq, pending) + if pending: + interval = min(active_secs, SEND_RETRY_INTERVAL_DEFAULT) + else: + interval = next_interval(prev, idle_secs, active_secs) if (ok and prev) else idle_secs except asyncio.CancelledError: break except Exception as e: diff --git a/src/trader/ths/const.py b/src/trader/ths/const.py index 9b99833..c30e968 100644 --- a/src/trader/ths/const.py +++ b/src/trader/ths/const.py @@ -148,8 +148,13 @@ "`": 0xC0, } -# 市价委托面板控件(真机已 dump 验证;原生控件,非 CEF;新旧皮肤一致) -MARKET_TREE_PARENT = "市价委托" # 左树父节点,子节点为 买入/卖出(无 F 快捷键) +# 市价委托面板控件(原生控件,非 CEF)。不同券商版本已实测两种左树结构: +# 顶层“市价买入/市价卖出”,或“市价委托 -> 买入/卖出”。按顺序精确尝试,不能把 +# 子节点“买入/卖出”放在根层搜索,否则会误点普通买入[F1]/卖出[F2]。 +MARKET_TREE_PATHS = { + "买入": (("市价买入",), ("市价委托", "买入")), + "卖出": (("市价卖出",), ("市价委托", "卖出")), +} MARKET_CODE_ID = 0x408 # 证券代码 Edit(与 F1/F2 同 ID) MARKET_AMOUNT_ID = 0x40A # 数量 Edit(与 F1/F2 同 ID) MARKET_SUBMIT_BTN_ID = 0x3EE # 买入/卖出 提交 Button diff --git a/src/trader/ths/dialogs.py b/src/trader/ths/dialogs.py index 298483b..77df703 100644 --- a/src/trader/ths/dialogs.py +++ b/src/trader/ths/dialogs.py @@ -1,21 +1,18 @@ """交易弹窗看门人(DialogSentry):结构化发现-处置-记录 xiadan 弹窗。 设计(docs/superpowers/specs/2026-07-13-ths-dialog-handling-design.md): -**不读正文猜语义**。决策只依赖两个结构信号: +**先按既有动作处置,再完整记录未知弹窗**。决策只依赖两个结构信号: -1. 弹窗含 ``Edit`` 输入框 → 验证码/身份验证类,必须输入内容才能通过 → - 交给 backend.input_ocr(),绝不盲点按钮; -2. 否则按按钮标签的肯定优先级(是 > 确定 > 确认 > 同意 > 唯一按钮) - ``PostMessage(BM_CLICK)`` 点掉;找不到可点按钮时退而给弹窗发 Enter, - 再不行 ``WM_CLOSE``。 +1. 已知撤单确认框优先按肯定按钮确认,即使其中含有改价 ``Edit`` 输入框; +2. 其他弹窗含 ``Edit`` 输入框 → 交给既有 ``backend.input_ocr()``; +3. 否则按按钮标签的肯定优先级(是 > 确定 > 确认 > 同意 > 唯一按钮) + ``PostMessage(BM_CLICK)``;没有可点按钮时使用 Enter/WM_CLOSE 兜底。 -安全性不靠读懂弹窗,靠两条:处置窗口仅限我们自己发起的动作前后; -以及点完后照旧走成交表/委托表核实回执。每个被处置的弹窗的 -标题 + 全文 + 所采取动作都记录进返回值(进而进回执与日志)—— -调用方永远知道流程中间发生过什么。 +未知弹窗仍按上述既有动作继续处理,但会在日志、截图和回执的 +``unknown_dialogs`` 中反馈标题、正文、判定原因和实际动作。 本模块 Windows-only 部分全部惰性引用 win32 模块;纯决策函数 -(choose_button / extract_entrust_no)无平台依赖,可在任意平台单测。 +(classify_dialog / choose_button / extract_entrust_no)无平台依赖,可在任意平台单测。 """ from __future__ import annotations @@ -46,12 +43,16 @@ def normalize_button_label(raw: str) -> str: return s.replace("&", "").replace(" ", "").strip() +_DIALOG_NEGATIVE_LABELS = ("否", "取消", "不同意", "拒绝") +_KNOWN_CAPTCHA_MARKERS = ("检测到您正在拷贝数据",) +_CANCEL_CONFIRMATION_MARKERS = ("撤单确认", "是否确定以上撤销") + + def choose_button(labels: list[str]) -> Optional[str]: """从归一化按钮标签中选出要点击的肯定项。 优先级 DIALOG_AFFIRM_LABELS(是 > 确定 > 确认 > 同意);都没有但 - 只有一个按钮时点它(信息框的唯一按钮无论叫什么都等价于关闭); - 多个按钮且无肯定项 → None(交给 Enter/WM_CLOSE 兜底)。 + 只有一个按钮时点它(信息框的唯一按钮无论叫什么都等价于关闭)。 """ for want in DIALOG_AFFIRM_LABELS: if want in labels: @@ -61,6 +62,61 @@ def choose_button(labels: list[str]) -> Optional[str]: return None +def is_known_captcha(dlg: "DialogFingerprint") -> bool: + """Return whether ``dlg`` is the one captcha flow ``input_ocr`` supports. + + ``has_edit`` alone is deliberately insufficient: it also describes OTP, + trading-password, identity-verification, and other sensitive dialogs. + The marker is the exact static text used by ``WinThsBackend.get_ocr_hwnd``; + until more real-client evidence exists, no looser heuristic is allowed. + """ + if not dlg.has_edit: + return False + haystack = "\n".join((dlg.title or "", dlg.text or "")) + return any(marker in haystack for marker in _KNOWN_CAPTCHA_MARKERS) + + +def is_known_confirmation(dlg: "DialogFingerprint") -> bool: + """Return whether the button structure is an observed confirmation shape. + + A lone ``确定`` (or any other lone button) is not enough evidence: it may + be a rejection, warning, password prompt, or an unrelated application + dialog. Requiring both a whitelisted affirmative and negative control + preserves the observed ``是/否`` and ``确定/取消`` confirmation flows. + """ + labels = set(dlg.buttons) + affirmative = any(label in labels for label in DIALOG_AFFIRM_LABELS) + negative = any(label in labels for label in _DIALOG_NEGATIVE_LABELS) + return affirmative and negative + + +def is_known_cancel_confirmation(dlg: "DialogFingerprint") -> bool: + """Return whether this is the observed THS cancellation confirmation. + + The real dialog includes an optional "cancel and resubmit at a new price" + area, so it has an ``Edit`` control even for an ordinary cancellation. + Recognise it from both the fixed title/body markers before applying the + generic Edit-to-OCR rule. + """ + haystack = "\n".join((dlg.title or "", dlg.text or "")) + return all(marker in haystack for marker in _CANCEL_CONFIRMATION_MARKERS) + + +def classify_dialog(dlg: "DialogFingerprint") -> str: + """Pure safety decision used by :meth:`DialogSentry.dismiss`. + + Results are used to label feedback; unlike a fail-closed policy, an + ``unknown`` result does not prevent the legacy handling action. + """ + if is_known_cancel_confirmation(dlg): + return "known_cancel_confirmation" + if dlg.has_edit: + return "known_captcha" if is_known_captcha(dlg) else "unknown_edit" + if is_known_confirmation(dlg): + return "known_confirmation" + return "unknown" + + def extract_entrust_no(text: str) -> Optional[str]: """从弹窗全文机会性提取合同编号(拿不到不算失败,回查兜底)。""" m = re.search(DIALOG_ENTRUST_NO_RE, text or "") @@ -84,11 +140,16 @@ class PumpResult: dialogs: list[dict] = field(default_factory=list) # {title, text, action} entrust_no: Optional[str] = None + status: str = "ok" + unknown_dialogs: list[dict] = field(default_factory=list) def attach_to(self, receipt: dict) -> dict: """把弹窗存证挂到回执上(无弹窗则不加字段,保持回执干净)。""" if self.dialogs: receipt["dialogs"] = self.dialogs + if self.unknown_dialogs: + receipt["unknown_dialogs"] = self.unknown_dialogs + receipt["unknown_dialog"] = True return receipt @property @@ -164,31 +225,40 @@ def walker(h, _): def dismiss(self, dlg: DialogFingerprint) -> str: """按结构规则处置一个弹窗,返回所采取的动作(进存证)。 - 原则(2026-07-13 用户定):任何弹窗都以**肯定**方式快速消除、回到既定 - 轨道,不耦合弹窗内容。肯定优先级:点「是/确定」按钮(=精确版回车, - 不依赖焦点与默认按钮设定)→ 向弹窗投递回车(真机验证对新版自绘提示框 - 有效)→ 两次回车仍在才 WM_CLOSE 兜底。**禁止 ESC**——在下单/撤单 - 确认框上 ESC 语义是「否/取消」。 + 继续沿用原有输入、点击、Enter/WM_CLOSE 处置动作。未知判定由 + ``pump`` 额外记录并反馈,不在这里改变动作结果。 """ + if is_known_cancel_confirmation(dlg): + # A cancellation confirmation can contain an optional repricing + # Edit. It is not a captcha and must never be sent to OCR. + for label in DIALOG_AFFIRM_LABELS: + button = dlg.buttons.get(label) + if button: + win32api.PostMessage(button, win32con.BM_CLICK, 0, 0) + return f"click:{label}" + logger.warning( + "cancel confirmation has no recognizable affirmative button; " + "leaving it pending title=%r text=%r", + dlg.title, dlg.text[:200], + ) + return "pending:cancel_confirmation_no_affirmative" if dlg.has_edit: - # 验证码/身份验证:必须输入内容才能通过,回车关不掉 → - # 交给既有 OCR 流程(内部自带重试)。 + # 验证码/身份验证:沿用原逻辑交给 OCR;未知类型会在 pump 回执中标记。 self.backend.input_ocr() return "input_ocr" label = choose_button(list(dlg.buttons)) if label: win32api.PostMessage(dlg.buttons[label], win32con.BM_CLICK, 0, 0) return f"click:{label}" - # 无可用按钮标签(自绘弹窗):回车 = 默认按钮(肯定)。投递给弹窗 - # 本身而非全局按键——不依赖弹窗是否前台,也绝不会敲进别的窗口。 + # 无可用按钮标签:沿用原逻辑向弹窗本身投递 Enter,再 WM_CLOSE 兜底。 for attempt in (1, 2): win32api.PostMessage(dlg.hwnd, win32con.WM_KEYDOWN, win32con.VK_RETURN, 0) win32api.PostMessage(dlg.hwnd, win32con.WM_KEYUP, win32con.VK_RETURN, 0) time.sleep(0.2) if not (_safe_is_window(dlg.hwnd) and win32gui.IsWindowVisible(dlg.hwnd)): return "enter" if attempt == 1 else "enter*2" - # 连回车都消不掉的弹窗几乎不可能是确认框 → 关窗兜底(≈点X),大声留痕。 - logger.warning("dialog ignores Enter, WM_CLOSE fallback title=%r", dlg.title) + logger.warning("unknown dialog ignores Enter, WM_CLOSE fallback title=%r text=%r", + dlg.title, dlg.text[:200]) win32api.PostMessage(dlg.hwnd, win32con.WM_CLOSE, 0, 0) return "enter*2+wm_close" @@ -223,22 +293,39 @@ def pump(self, budget: float = 5.0, settle: float = 0.3) -> PumpResult: if time.time() - last < 0.5: continue # 刚点过,给它时间消失 self._snapshot(dlg) + decision = classify_dialog(dlg) action = self.dismiss(dlg) handled[dlg.hwnd] = time.time() logger.info("dialog handled title=%r action=%s text=%r", dlg.title, action, dlg.text[:200]) - result.dialogs.append( - {"title": dlg.title, "text": dlg.text, "action": action}) + record = {"title": dlg.title, "text": dlg.text, "action": action} + if decision not in { + "known_captcha", "known_confirmation", "known_cancel_confirmation", + }: + record["unknown"] = True + record["reason"] = decision + result.unknown_dialogs.append(dict(record)) + logger.warning( + "unknown dialog auto-handled title=%r reason=%s action=%s text=%r", + dlg.title, decision, action, dlg.text[:200], + ) + result.dialogs.append(record) if not result.entrust_no: result.entrust_no = extract_entrust_no(dlg.text) + if action.startswith("pending:"): + # A recognised cancellation prompt without a safe button + # must not be retried, Entered, closed, or reported as a + # completed cancellation. + result.status = "pending" + return result time.sleep(0.1) return result def cleanup(self) -> PumpResult: - """degraded 自愈:清掉残留弹窗(同一套「肯定+存证」规则)。 + """degraded 自愈:检查并按既有规则处理残留弹窗,同时保留未知反馈。 与 pump 的区别只有预算更短——此时上一笔已按 unknown 上报, - 调用方被要求核单,无论弹窗被肯定还是关闭,真相都以核单为准。 + 调用方被要求核单;本轮仍记录每个未知弹窗及实际动作,真相都以核单为准。 """ return self.pump(budget=2.0, settle=0.2) diff --git a/src/trader/ths/rows.py b/src/trader/ths/rows.py index d251e4f..bff9789 100644 --- a/src/trader/ths/rows.py +++ b/src/trader/ths/rows.py @@ -132,7 +132,7 @@ def normalize_active_row(row: dict[str, Any], "entrust_no": entrust_no, "证券代码": text(row.get("证券代码")), "证券名称": text(row.get("证券名称")), - "方向": direction(row.get("操作") or row.get("买卖标志")), + "方向": direction(row.get("操作") or row.get("买卖标志") or row.get("买卖")), "委托价": price(row.get("委托价格") or row.get("委托价")), "委托数量": order_qty, "已成数量": filled_qty, diff --git a/src/trader/ths/win.py b/src/trader/ths/win.py index f279e53..b8acc82 100644 --- a/src/trader/ths/win.py +++ b/src/trader/ths/win.py @@ -22,6 +22,7 @@ import ctypes from ctypes import wintypes import functools +import json import logging import os import platform @@ -30,6 +31,7 @@ import tempfile import threading import time +import unicodedata from typing import Any, Optional import pytesseract @@ -50,11 +52,13 @@ MARKET_STRATEGY, MARKET_STRATEGY_COMBO_ID, MARKET_SUBMIT_BTN_ID, - MARKET_TREE_PARENT, + MARKET_TREE_PATHS, VK_CODE, ) from .table_guard import check_table from .rows import ( + ST_CANCELED, + classify_order_state, normalize_active_row, normalize_balance, normalize_filled_row, @@ -125,6 +129,29 @@ def _key(r): refresh_sleep_time = 0.5 retry_time = 1 +# 撤单确认按钮点击后,只读轮询 F3 可撤委托表的时间预算。该轮询绝不再次 +# 双击订单行;若 F3 不能明确证明已撤,dispatcher 会再读一次含终态的全量委托表。 +CANCEL_VERIFY_TIMEOUT_SECS = 3.0 +CANCEL_VERIFY_INTERVAL_SECS = 0.3 + +# 账户列表项:每次按当前绑定主窗口重新枚举,不保存其 HWND;同花顺重启后 +# HWND 可能变化。0x0912 来自当前 Windows 控件快照:ComboBox、可见、可用。 +# 展开后,同花顺创建可见的顶层 ComboLBox(ID 0x03E8);真机确认其标准 +# LB_GETTEXT 可直接返回账户原文,末项“编辑账户”不是可切换账户。 +ACCOUNT_SELECTOR_ID = 0x094C +ACCOUNT_SELECTOR_CLASS = "Button" +ACCOUNT_VERIFY_TIMEOUT_SECS = 3.0 +ACCOUNT_TEXT_PLACEHOLDERS = frozenset({"", "NUL", "XX证券"}) +ACCOUNT_DROPDOWN_ID = 0x0912 +ACCOUNT_DROPDOWN_SETTLE_SECS = 0.3 +ACCOUNT_LISTBOX_ID = 0x03E8 +ACCOUNT_LISTBOX_CLASS = "ComboLBox" +ACCOUNT_LISTBOX_NON_ACCOUNT_ITEMS = frozenset({"编辑账户"}) +LB_GETTEXT = 0x0189 +LB_GETTEXTLEN = 0x018A +LB_GETCOUNT = 0x018B +LB_ERR = -1 + # Set by `setup()` from Config. Module-level so the existing call sites # (`win32gui.FindWindow(None, window_title)`) keep working without threading # config through every method. @@ -155,39 +182,105 @@ def setup(window_title_value: str, tesseract_cmd: str, work_dir_value: str = "") ) -def find_window_by_title_prefix(prefix: str) -> int: - """Find first visible top-level window whose title **starts with** `prefix`. +def _matching_windows_by_title_prefix(prefix: str) -> list[tuple[int, str]]: + """Return every visible top-level window whose title starts with ``prefix``. - Hexin clients typically render ` - - ` so exact-match - `FindWindow` fails. Prefix match handles the broker suffix while staying - safer than substring (avoids accidental matches in unrelated windows). + This deliberately has no ``FindWindow`` fast path. An exact title is also + a prefix match, so it must be considered together with broker-suffixed + windows rather than silently bypassing the ambiguity check. """ if not prefix: - return 0 + return [] matches: list[tuple[int, str]] = [] def cb(hwnd, _): - if not win32gui.IsWindowVisible(hwnd): + try: + if not win32gui.IsWindowVisible(hwnd): + return + text = win32gui.GetWindowText(hwnd) or "" + if text.startswith(prefix): + matches.append((hwnd, text)) + except Exception: + # Enumeration is an identity check, not a best-effort lookup. + # Ignore an unreadable candidate; the caller still fails closed + # unless exactly one readable candidate remains. return - text = win32gui.GetWindowText(hwnd) - if text.startswith(prefix): - matches.append((hwnd, text)) - win32gui.EnumWindows(cb, None) - if not matches: + try: + win32gui.EnumWindows(cb, None) + except Exception: + logger.warning("unable to enumerate windows for title prefix %r", prefix, exc_info=True) + return [] + return matches + + +def find_window_by_title_prefix(prefix: str) -> int: + """Return the sole visible top-level prefix match, or ``0`` if ambiguous. + + Hexin clients typically render `` - - ``. Prefix + matching handles that suffix, but choosing an arbitrary first match is + unsafe because subsequent F-keys and clicks are process-global. + """ + matches = _matching_windows_by_title_prefix(prefix) + if len(matches) != 1: + if matches: + logger.error( + "refusing ambiguous window binding: %d windows match prefix %r: %r", + len(matches), + prefix, + [title for _, title in matches], + ) return 0 - if len(matches) > 1: - logger.warning( - "found %d windows matching prefix %r — picking first: %r", - len(matches), - prefix, - [t for _, t in matches], - ) hwnd, full = matches[0] - logger.info("matched window prefix=%r → full_title=%r hwnd=%s", prefix, full, hwnd) + logger.info("matched unique window prefix=%r → full_title=%r hwnd=%s", prefix, full, hwnd) return hwnd +def _normalize_executable_identity(path: Any) -> str: + """Normalize a process image path only for equality comparison.""" + return os.path.normcase(os.path.normpath(str(path or "").strip())) + + +def _window_process_identity(hwnd: int) -> tuple[int, str] | None: + """Return ``(pid, executable_path)`` for a window, or ``None`` on doubt. + + ``GetWindowThreadProcessId`` alone is insufficient: Windows can recycle an + HWND after xiadan restarts. The image path gives the cached binding a second + stable identity signal. Every failure intentionally returns ``None`` so + callers stop rather than sending input to an unverified window. + """ + try: + _thread_id, pid = win32process.GetWindowThreadProcessId(hwnd) + pid = int(pid) + if pid <= 0: + return None + access = ( + getattr(win32con, "PROCESS_QUERY_LIMITED_INFORMATION", 0x1000) + | getattr(win32con, "PROCESS_QUERY_INFORMATION", 0x0400) + | getattr(win32con, "PROCESS_VM_READ", 0x0010) + ) + handle = win32api.OpenProcess(access, False, pid) + try: + executable = win32process.GetModuleFileNameEx(handle, 0) + finally: + win32api.CloseHandle(handle) + executable = _normalize_executable_identity(executable) + if not executable: + return None + return pid, executable + except Exception: + logger.warning("unable to verify process identity for hwnd=%s", hwnd, exc_info=True) + return None + + +def _foreground_window() -> int: + """Read the foreground HWND. Missing Win32 APIs are a failed check.""" + try: + return int(win32gui.GetForegroundWindow() or 0) + except Exception: + return 0 + + def get_clipboard_data(open_retries: int = 10): """读剪贴板文本。永不抛异常——失败返回 None,让调用方的重试循环继续。 @@ -216,8 +309,15 @@ def get_clipboard_data(open_retries: int = 10): return None -def hot_key(keys): +def hot_key(keys, before_dispatch=None): + """Send a hotkey after the empirical delay. + + State-changing callers can revalidate focus and their call generation + after this delay, immediately before the first physical key event. + """ time.sleep(sleep_time) + if before_dispatch: + before_dispatch() for key in keys: win32api.keybd_event(VK_CODE[key], 0, 0, 0) time.sleep(short_sleep_time) @@ -274,9 +374,80 @@ def get_text(hwnd): return buf.value +def _account_candidates_from_listbox(items: list[str]) -> list[dict[str, Any]]: + """Map verified account ListBox rows to Alt+1..Alt+9 in visible order.""" + account_texts = [ + text.strip() for text in items + if text.strip() and text.strip() not in ACCOUNT_LISTBOX_NON_ACCOUNT_ITEMS + ] + if len(account_texts) > 9: + raise ValueError(f"账户下拉列表含 {len(account_texts)} 个账户,超过 Alt+1..Alt+9 范围") + return [ + {"slot": index, "shortcut": f"Alt+{index}", "text": text} + for index, text in enumerate(account_texts, start=1) + ] + + +_ACCOUNT_IDENTITY_SEPARATORS = re.compile(r"[\s\-‐‑‒–—―]+") +_ACCOUNT_LIST_IDENTITY_RE = re.compile( + r"^(?P.+?)[\s\-‐‑‒–—―]+(?P[^\s\-‐‑‒–—―]*\*+[^\s\-‐‑‒–—―]+)$" +) + + +def _account_identity(text: Any) -> str: + """Canonical form used only to compare the selector with its ListBox row. + + The verified THS controls render the same account as e.g. ``券商 王*甲`` in + the selector and ``券商-王*甲`` in the ListBox. Only formatting separators + are ignored; the broker name and masked holder name (including ``*``) remain + part of the identity. + """ + normalized = unicodedata.normalize("NFKC", str(text or "")).strip() + return _ACCOUNT_IDENTITY_SEPARATORS.sub("", normalized) + + +def _account_selector_matches_target(selector_text: Any, target_text: Any) -> bool: + """Compare a selector value against one unique ListBox account row. + + Some THS clients insert a branch name into the selector, e.g. + ``示例券商-z示例营业部 甲*乙``, while the ListBox row only shows + ``示例券商-甲*乙``. Accept that one observed rendering difference only + when the ListBox row provides both a broker and a masked holder name. Any + unparseable text, different broker, or different holder remains a mismatch. + """ + if _account_identity(selector_text) == _account_identity(target_text): + return bool(_account_identity(target_text)) + + target = unicodedata.normalize("NFKC", str(target_text or "")).strip() + selector = unicodedata.normalize("NFKC", str(selector_text or "")).strip() + match = _ACCOUNT_LIST_IDENTITY_RE.fullmatch(target) + if not match or not selector.startswith(match.group("broker")): + return False + + remainder = selector[len(match.group("broker")):] + if not remainder or not _ACCOUNT_IDENTITY_SEPARATORS.match(remainder): + return False + parts = _ACCOUNT_IDENTITY_SEPARATORS.sub(" ", remainder).strip().split() + # At least one branch token is required. A plain broker-holder pair has + # already been handled by the strict identity comparison above. + return len(parts) >= 2 and parts[-1] == match.group("holder") + + _PHANTOM_VALUES = frozenset({"", "0", "0.0", "0.00", "0.000", "-", "--"}) +def _is_phantom_value(value: Any) -> bool: + """Return whether a copied cell is an empty-table placeholder value.""" + text = str(value).strip().strip("\x00") + if text in _PHANTOM_VALUES: + return True + # THS emits different decimal precision depending on the selected table, + # e.g. 0.0000 in the order grid. Treat only an actual numeric zero as empty. + if re.fullmatch(r"[+-]?0+(?:\.0+)?", text): + return True + return False + + def table_columns(text): """取 THS 剪贴板表格的表头列名(与 parse_table 同一套切分约定)。 @@ -311,12 +482,58 @@ def parse_table(text): continue items = raw.split("\t") info = {keys[j]: (items[j] if j < len(items) else "") for j in range(len(keys))} - if all(str(v).strip() in _PHANTOM_VALUES for v in info.values()): + if all(_is_phantom_value(v) for v in info.values()): continue result.append(info) return result +def _cancel_f3_outcome(data: Any, entrust_no: Any) -> tuple[str, str | None]: + """Classify one post-submit F3 snapshot without inferring a cancel. + + F3 is a *revocable orders* view. A row disappearing can mean either a + successful cancel or a concurrent full fill, so disappearance deliberately + remains unresolved. Only a uniquely matched row with an explicit canceled + status proves the broker accepted the cancel. + + The return value is ``(outcome, state)`` where outcome is one of + ``canceled``, ``pending``, ``unresolved``, or ``unreadable``. + """ + if data is _VERIFIED_EMPTY_GRID: + return "unresolved", None + if not isinstance(data, str) or not data: + return "unreadable", None + + columns = table_columns(data) + id_cols = tuple(name for name in ("委托编号", "合同编号") if name in columns) + state_col = next((name for name in ("委托状态", "状态", "备注") if name in columns), None) + if not id_cols or not state_col: + return "unreadable", None + + target = str(entrust_no).strip() + matches = [ + row for row in parse_table(data) + if any(str(row.get(id_col, "")).strip() == target for id_col in id_cols) + ] + if len(matches) != 1: + return "unresolved", None + + raw_state = str(matches[0].get(state_col, "")).strip() + state = classify_order_state(raw_state) + # ``classify_order_state`` intentionally groups broad wording such as + # "撤单中" into ST_CANCELED for passive order-list consumers. Here the + # bar is higher: a post-submit success reply needs a completed cancel, + # not merely evidence that cancellation is in progress. + explicit_cancel = any( + token in raw_state for token in ("已撤", "部撤", "撤单成功", "撤销成功") + ) + if state == ST_CANCELED and explicit_cancel: + return "canceled", state + if state == ST_CANCELED: + state = "未知" + return "pending", state + + # --- TreeView (SysTreeView32) 跨进程消息常量 ---------------------------------- # 用消息按"文字"定位/选中左侧查询树节点(如「交割单」),而非按像素点击 —— # 与窗口缩放 / DPI / 行高无关。 @@ -417,6 +634,15 @@ class StaleCallAborted(RuntimeError): """本线程所属的调用已被作废(dispatcher 超时后放锁),必须立刻停手。""" +class WindowSafetyError(RuntimeError): + """A global UI action was stopped before it could target an unverified window.""" + + +# Some xiadan builds write neither rows nor headers for an empty +# CVirtualGridCtrl. This is never used for ordinary clipboard failures. +_VERIFIED_EMPTY_GRID = object() + + def guarded(fn): """工作线程入口装饰器:登记调用代次,本笔被作废时在检查点中止。 @@ -433,12 +659,31 @@ def wrapper(self, *args, **kwargs): class WinThsBackend: def __init__(self): self.hwnd_main = None + # HWND values can be recycled after xiadan exits. Keep the process + # identity captured at bind time and verify it before every global UI + # action; title text by itself is not a sufficient identity proof. + self._bound_pid: int | None = None + self._bound_executable: str | None = None + # 启动后必须先由 switch_account(slot) 按账户列表核验 0x094C;之后每笔 + # 交易都核对该文本。任何读取失败或文本变化都阻断买卖和撤单。 + self._account_trading_blocked = True + self._last_account_text: str | None = None # order_watch 与 RPC 共用:串行化对 THS 单窗口的访问,避免并发拷表。 self.win_lock = asyncio.Lock() # agent 经 RPC 下单成功后登记的合同编号,供 order_watch 标记事件来源。 self.agent_entrust_nos: set[str] = set() # 内存态:查询结果的 last-known 存储(剪贴板仅作毫秒级中转)。 self.state = ThsState() + # Last raw columns observed by _grab_grid. Limit-order ownership needs + # stronger evidence than the generic query-table marker check, and an + # empty but valid table has no rows from which to recover this fact. + self._last_grid_columns: dict[str, tuple[str, ...]] = {} + # 原始剪贴板与最终订单行只保存在内存;仅基线拒绝时写入 trader.log, + # 用于定位券商空表占位行、短行或合同号异常,绝不随普通查询回执上送。 + self._last_grid_debug: dict[str, dict[str, Any]] = {} + # No-header empty grids retain their verification separately: an + # unqualified clipboard failure must not become an empty baseline. + self._last_grid_verified_empty: set[str] = set() # dispatcher 侧调用超时后置位;下一次调用进入前先跑 dialog_cleanup 自愈。 self.degraded = False # 调用代次:dispatcher 超时后 +1,作废所有在飞的工作线程(见 _abort_if_stale)。 @@ -481,6 +726,9 @@ def _run_guarded(self, fn, *args, **kwargs): except StaleCallAborted as e: logger.warning("脱缰线程已在 %s 处停手(%s)", getattr(e, "where", "?"), e) return contract.fail(contract.CODE_ABORTED, CLS_ABORTED, f"调用已作废:{e}") + except WindowSafetyError as e: + logger.warning("窗口安全检查阻止了 UI 输入:%s", e) + return contract.fail(contract.CODE_NOT_BOUND, CLS_NOT_BOUND, str(e)) finally: self._tls.gen = None @@ -500,6 +748,9 @@ def _abort_if_stale(self, where: str) -> None: def _pump_dialogs(self): """提交动作后的弹窗「发现-处置-存证」循环(见 ths/dialogs.py)。""" self._abort_if_stale("pump_dialogs") + if not self._bound_window_is_valid(): + self._clear_bound_window("cannot pump dialogs for an invalid binding") + raise WindowSafetyError("弹窗处置前交易窗口绑定已失效,已停止处理弹窗") from .dialogs import DialogSentry return DialogSentry(self).pump() @@ -507,35 +758,246 @@ def dialog_cleanup(self): """degraded 自愈入口:清掉残留弹窗并留存证(dispatcher 在超时后的 下一次调用前执行)。返回 PumpResult,内容进日志。""" self._abort_if_stale("dialog_cleanup") + if not self._bound_window_is_valid(): + self._clear_bound_window("cannot clean dialogs for an invalid binding") + raise WindowSafetyError("弹窗清理前交易窗口绑定已失效,已停止处理弹窗") from .dialogs import DialogSentry result = DialogSentry(self).cleanup() if result.dialogs: logger.warning("dialog_cleanup 清掉残留弹窗:%s", result.dialogs) return result + def _clear_bound_window(self, reason: str) -> None: + if self.hwnd_main: + logger.warning("discarding bound xiadan window hwnd=%s: %s", self.hwnd_main, reason) + self.hwnd_main = None + self._bound_pid = None + self._bound_executable = None + + def _bound_window_is_valid(self) -> bool: + """Check the cached HWND, title, PID, and executable identity. + + Failure is intentionally indistinguishable from a missing binding to + callers. A successful title match alone is not enough because a new + process can inherit a recycled HWND and render the same title. + """ + hwnd = self.hwnd_main + if not hwnd or hwnd <= 0 or not self._bound_pid or not self._bound_executable: + return False + try: + if not win32gui.IsWindow(hwnd): + return False + if not (win32gui.GetWindowText(hwnd) or "").startswith(window_title): + return False + except Exception: + return False + identity = _window_process_identity(hwnd) + return bool( + identity + and identity[0] == self._bound_pid + and identity[1] == self._bound_executable + ) + + def _activate_bound_window(self) -> bool: + """Activate the verified xiadan window and require it to become foreground.""" + if not self._bound_window_is_valid(): + self._clear_bound_window("cached HWND/title/process identity verification failed") + return False + _activate_window(self.hwnd_main) + if _foreground_window() != self.hwnd_main: + logger.warning( + "xiadan activation failed or focus was stolen: expected=%s actual=%s", + self.hwnd_main, _foreground_window(), + ) + return False + return True + + def _foreground_is_bound_process(self) -> bool: + """Whether the foreground top-level window belongs to the bound client. + + A confirmation/captcha dialog can legitimately become foreground after + a submit. It is still accepted only when its process identity matches + the bound xiadan process; another application with a copied title is + never accepted. + """ + if not self._bound_window_is_valid(): + self._clear_bound_window("bound identity changed while checking foreground") + return False + identity = _window_process_identity(_foreground_window()) + return bool( + identity + and identity[0] == self._bound_pid + and identity[1] == self._bound_executable + ) + + def _window_is_owned_by_bound_process(self, hwnd: int) -> bool: + """Whether ``hwnd`` still belongs to the exact client we bound. + + Direct WM_CHAR/BM_CLICK delivery does not need foreground focus, but + it *does* need this check: child HWND values are recyclable too. A + control ID and a title are not sufficient evidence that a message is + going to the same xiadan process. + """ + if not hwnd or not self._bound_window_is_valid(): + self._clear_bound_window("bound identity changed while checking a target control") + return False + identity = _window_process_identity(hwnd) + return bool( + identity + and identity[0] == self._bound_pid + and identity[1] == self._bound_executable + ) + + def _require_owned_window_for_input(self, hwnd: int, where: str) -> None: + """Fail closed before directly messaging a state-changing control.""" + self._abort_if_stale(where) + if not self._window_is_owned_by_bound_process(hwnd): + raise WindowSafetyError( + f"{where}: 目标控件不属于当前已绑定的 xiadan 进程,已停止发送输入" + ) + + def _activate_owned_window(self, hwnd: int) -> bool: + """Activate a bound-process popup and require that exact popup in front.""" + if not hwnd or not self._bound_window_is_valid(): + self._clear_bound_window("cannot activate popup from an invalid binding") + return False + identity = _window_process_identity(hwnd) + if not ( + identity + and identity[0] == self._bound_pid + and identity[1] == self._bound_executable + ): + logger.warning("refusing to activate foreign popup hwnd=%s", hwnd) + return False + _activate_window(hwnd) + return _foreground_window() == hwnd + + def _require_foreground_for_input(self, where: str, + allow_bound_process_popup: bool = False, + expected_popup: int | None = None) -> None: + """Fail closed before a process-global hotkey or physical mouse event.""" + self._abort_if_stale(where) + if expected_popup: + if ( + _foreground_window() == expected_popup + and self._window_is_owned_by_bound_process(expected_popup) + ): + return + if not self._activate_owned_window(expected_popup): + raise WindowSafetyError( + f"{where}: 指定 xiadan 弹窗未能保持前台或绑定已失效,已停止发送全局输入" + ) + return + if allow_bound_process_popup and self._foreground_is_bound_process(): + return + if not self._activate_bound_window(): + raise WindowSafetyError( + f"{where}: xiadan 窗口未能保持前台或绑定已失效,已停止发送全局输入" + ) + + def _send_hotkey(self, keys: list[str], where: str, + allow_bound_process_popup: bool = False, + expected_popup: int | None = None, + before_dispatch=None) -> None: + self._require_foreground_for_input( + where, allow_bound_process_popup, expected_popup + ) + + def final_check() -> None: + # hot_key waits before pressing its first key. The dispatcher may + # time out or another app may take focus during that interval. + self._require_foreground_for_input( + where, allow_bound_process_popup, expected_popup + ) + if before_dispatch: + before_dispatch() + + hot_key(keys, before_dispatch=final_check) + + def _click_screen(self, x: int, y: int, where: str) -> None: + """Perform one physical click only after foreground verification.""" + self._require_foreground_for_input(where) + win32api.SetCursorPos((x, y)) + # Moving the pointer may itself take long enough for another app to + # surface. Recheck at the last possible point before button-down. + if ( + _foreground_window() != self.hwnd_main + or not self._window_is_owned_by_bound_process(self.hwnd_main) + ): + raise WindowSafetyError( + f"{where}: 鼠标点击前前台窗口或进程身份已变化,已停止发送点击" + ) + win32api.mouse_event(win32con.MOUSEEVENTF_LEFTDOWN, 0, 0, 0, 0) + win32api.mouse_event(win32con.MOUSEEVENTF_LEFTUP, 0, 0, 0, 0) + + def _click_owned_popup(self, popup: int, x: int, y: int, where: str) -> None: + """Click an exact, bound-process popup rather than whichever window is frontmost.""" + if not self._activate_owned_window(popup): + raise WindowSafetyError( + f"{where}: 弹窗不属于当前已绑定的 xiadan 进程或未能置前,已停止点击" + ) + win32api.SetCursorPos((x, y)) + if ( + _foreground_window() != popup + or not self._window_is_owned_by_bound_process(popup) + ): + raise WindowSafetyError( + f"{where}: 鼠标点击前弹窗焦点或进程身份已变化,已停止点击" + ) + win32api.mouse_event(win32con.MOUSEEVENTF_LEFTDOWN, 0, 0, 0, 0) + win32api.mouse_event(win32con.MOUSEEVENTF_LEFTUP, 0, 0, 0, 0) + + def _set_owned_text(self, hwnd: int, value: Any, where: str, is_price: bool = False) -> None: + """Fill a form control only after both foreground and ownership checks.""" + self._require_foreground_for_input(where) + self._require_owned_window_for_input(hwnd, where) + set_text(hwnd, value, is_price) + + def _post_owned_button_click(self, hwnd: int, where: str, before_dispatch=None) -> None: + """Deliver BM_CLICK only to a control in the bound xiadan process.""" + self._require_owned_window_for_input(hwnd, where) + if before_dispatch: + before_dispatch() + win32api.PostMessage(hwnd, win32con.BM_CLICK, 0, 0) + + def _switch_to_normal_safely(self) -> None: + """Use the existing navigation primitive with its input guard enabled. + + Keeping the public ``switch_to_normal()`` default unchanged avoids + turning pure read navigation into an availability dependency on focus. + """ + self._tls.require_window_safety = True + try: + self.switch_to_normal() + finally: + self._tls.require_window_safety = False + + def _pre_submit_failure(self, message: str, code: str = contract.CODE_NOT_BOUND, + error_class: str = CLS_NOT_BOUND) -> dict[str, Any]: + """Build a never-submitted receipt for a failure before the submit action.""" + return contract.fail(code, error_class, message, data={"submitted": False}) + + def _activate_or_pre_submit_failure(self, where: str) -> dict[str, Any] | None: + try: + self._require_foreground_for_input(where) + except StaleCallAborted: + raise + except WindowSafetyError as e: + logger.warning("pre-submit foreground check failed at %s: %s", where, e) + return self._pre_submit_failure(str(e)) + return None + def _ensure_bound(self) -> dict[str, Any] | None: """检查是否已绑定;否则 lazy bind,返回错误 dict 或 None(成功)""" - # 关键:缓存句柄必须验活。xiadan 重启后旧 hwnd 数值仍 >0 但窗口已销毁, - # 不验活会拿死句柄去 SendMessage/FindWindowEx → Win32 报错 1400「无效的窗口句柄」。 - # IsWindow 判断句柄是否仍指向存活窗口;标题前缀校验顺带防 HWND 数值被系统回收复用。 if self.hwnd_main and self.hwnd_main > 0: - try: - alive = bool(win32gui.IsWindow(self.hwnd_main)) and win32gui.GetWindowText( - self.hwnd_main - ).startswith(window_title) - except Exception: - alive = False - if alive: - return None # 已绑定且句柄有效 - logger.info( - "缓存的 xiadan 句柄 %s 已失效(疑似重启/重登),重新捕获…", self.hwnd_main - ) - self.hwnd_main = None # 丢弃失效句柄,强制重绑 + if self._bound_window_is_valid(): + return None + self._clear_bound_window("cached binding validation failed") # 尝试 bind logger.info("未检测到 xiadan 窗口,尝试 lazy bind...") self.bind_client() - if self.hwnd_main and self.hwnd_main > 0: + if self.hwnd_main and self.hwnd_main > 0 and self._bound_window_is_valid(): logger.info("✓ 成功绑定到 xiadan 窗口: hwnd=%s", self.hwnd_main) return None @@ -545,29 +1007,33 @@ def _ensure_bound(self) -> dict[str, Any] | None: "未检测到 xiadan 窗口(请确保同花顺已打开并登录)") def bind_client(self): - # Try exact match first for backward compat, then prefix match. - hwnd = win32gui.FindWindow(None, window_title) + # Exact FindWindow is deliberately not used: an exact-title window and + # a broker-suffixed one both match the configured prefix, so accepting + # the exact one would bypass the ambiguity protection. + self._clear_bound_window("rebinding") + hwnd = find_window_by_title_prefix(window_title) if hwnd <= 0: - hwnd = find_window_by_title_prefix(window_title) - if hwnd > 0: - _activate_window(hwnd) - self.hwnd_main = hwnd + return + identity = _window_process_identity(hwnd) + if not identity: + logger.error("refusing to bind hwnd=%s: process identity unavailable", hwnd) + return + self.hwnd_main = hwnd + self._bound_pid, self._bound_executable = identity + if not self._activate_bound_window(): + self._clear_bound_window("window did not become foreground after bind") def kill_client(self): - self.hwnd_main = None - retry = 5 - while retry > 0: - hwnd = win32gui.FindWindow(None, window_title) - if hwnd <= 0: - hwnd = find_window_by_title_prefix(window_title) - if hwnd == 0: - time.sleep(1) - break - else: - _activate_window(hwnd) - time.sleep(sleep_time) - hot_key(["alt", "F4"]) - retry -= 1 + """Close only the uniquely bound client; never Alt+F4 a title lookup.""" + if self._ensure_bound(): + return False + try: + self._send_hotkey(["alt", "F4"], "kill_client") + except WindowSafetyError as e: + logger.warning("refusing to close client: %s", e) + return False + self._clear_bound_window("close requested") + return True def get_tree_hwnd(self): # 结构链保持不变,仅把带 MFC 版本号的类名(AfxMDIFrame140s/AfxWnd140s)换成 @@ -627,15 +1093,110 @@ def _wk(h, _): pass return hit[0] if hit else 0 + def _read_account_selector_text(self) -> Optional[str]: + """按控件 ID/class 重新定位当前账户项并读取其文本。 + + 不缓存账户控件 HWND:同花顺重启或切换页面后 HWND 会变化。控件可能处于 + 隐藏模板层,所以这里不能要求 visible=True;但目标必须仍属于已绑定的 + xiadan 进程,且占位文本不算账户身份。 + """ + ctrl = self._find_ctrl_by_id( + self.hwnd_main, ACCOUNT_SELECTOR_ID, cls=ACCOUNT_SELECTOR_CLASS + ) + if not ctrl or not self._window_is_owned_by_bound_process(ctrl): + return None + text = (get_text(ctrl) or "").strip() + if text in ACCOUNT_TEXT_PLACEHOLDERS: + return None + return text + + @property + def account_trading_blocked(self) -> bool: + """Whether account identity currently blocks every trading action.""" + return self._account_trading_blocked + + def require_explicit_account_selection(self) -> None: + """Clear a prior selection when the control connection starts a new session.""" + self._last_account_text = None + self._account_trading_blocked = True + logger.info("账户交易核验已重置,等待 switch_account 明确选择") + + @guarded + def _verify_account_for_trade(self) -> dict[str, Any]: + """建立或核对交易账户基线;失败时阻断所有交易动作。 + + 账户必须先由明确的 switch_account(slot) 与下拉列表槽位核验。此后 + buy/sell/cancel 与人工单 confirm_external_cancel 每次执行前都必须读取到 + 相同文本,避免用户手动切换同花顺账户后订单落入错误账户。 + """ + self._abort_if_stale("verify_account_for_trade") + current = self._read_account_selector_text() + expected = self._last_account_text + if not current: + self._account_trading_blocked = True + return contract.fail( + contract.CODE_READ_FAILED, CLS_READ_FAILED, + "交易前无法读取当前账户(控件 ID 0x094C),已禁止买卖和撤单", + data={ + "account_verified": False, + "expected_account_text": expected, + "account_text": None, + "submitted": False, + }, + ) + + if expected is None: + self._account_trading_blocked = True + return contract.fail( + contract.CODE_READ_FAILED, CLS_READ_FAILED, + "当前账户尚未通过 switch_account 明确核验,已禁止买卖和撤单;" + "请先调用 list_accounts,再调用 switch_account 选择账户", + data={ + "account_verified": False, + "account_text": current, + "submitted": False, + }, + ) + + if current != expected: + self._account_trading_blocked = True + logger.error("交易前账户不一致:expected=%r actual=%r", expected, current) + return contract.fail( + contract.CODE_READ_FAILED, CLS_READ_FAILED, + "交易前账户已变化(期望:%s,当前:%s),已禁止买卖和撤单;" + "请确认同花顺账户后调用 switch_account 重新核验" % (expected, current), + data={ + "account_verified": False, + "expected_account_text": expected, + "account_text": current, + "submitted": False, + }, + ) + + self._account_trading_blocked = False + return contract.ok({ + "account_verified": True, + "account_text": current, + "account_baseline_established": False, + }) + + async def verify_account_for_trade(self) -> dict[str, Any]: + """Async trading preflight; caller must already hold ``win_lock``.""" + bound_err = self._ensure_bound() + if bound_err: + self._account_trading_blocked = True + return bound_err + return await asyncio.to_thread(self._verify_account_for_trade) + def _find_grid(self, root: int) -> int: - """找面板里的表格控件(0x417)。优先【可见的】CVirtualGridCtrl —— 右区同时挂着 - 多个面板的 grid,只有当前激活面板的可见,不按可见性过滤会误读到隐藏的持仓表 - (导致 orders_active/filled 错读成 position)。逐级放宽回退,保证总能拿到一个。""" + """只找当前可见面板的表格控件(0x417)。 + + 右区会同时挂载多个页面的 grid;若页面未切换,回退读取隐藏 grid 会把持仓等旧页面 + 伪装成当前查询结果。因此找不到可见 grid 时宁可返回 0 并拒绝读取。 + """ return ( self._find_ctrl_by_id(root, 0x417, cls="CVirtualGridCtrl", visible=True) or self._find_ctrl_by_id(root, 0x417, visible=True) - or self._find_ctrl_by_id(root, 0x417, cls="CVirtualGridCtrl") - or self._find_ctrl_by_id(root, 0x417) ) @staticmethod @@ -718,24 +1279,36 @@ def _bulk_cancel(self, action: str): f"{list(self._BULK_CANCEL_BUTTONS)}", } btn_id = self._BULK_CANCEL_BUTTONS[action] - self.switch_to_normal() - hot_key(["F3"]) - self.refresh() - right = self.get_right_hwnd() try: + self._switch_to_normal_safely() + self._send_hotkey(["F3"], "bulk_cancel_panel") + self.refresh(require_window_safety=True) + right = self.get_right_hwnd() btn = self._find_ctrl_by_id(right, btn_id, cls="Button", visible=True) \ or self._find_ctrl_by_id(right, btn_id, cls="Button") + except WindowSafetyError as e: + return self._pre_submit_failure(str(e)) except Exception as e: return {"code": 1, "status": "failed", "msg": f"GetDlgItem 0x{btn_id:04X}: {e}"} if not btn: return {"code": 1, "status": "failed", "msg": f"button 0x{btn_id:04X} not present in F3 panel"} - # BM_CLICK fires the button's WM_COMMAND. Cross-process safe. - win32api.PostMessage(btn, win32con.BM_CLICK, 0, 0) + try: + # BM_CLICK is focus-independent, but must still target a control + # owned by the client we verified at bind time. + self._post_owned_button_click(btn, f"bulk_cancel_{action}") + except WindowSafetyError as e: + return self._pre_submit_failure(str(e)) time.sleep(sleep_time) # "您确定要撤销..." 确认框 / 验证码:结构化处置 + 存证(取代盲 Enter)。 - pump = self._pump_dialogs() + try: + pump = self._pump_dialogs() + except (StaleCallAborted, WindowSafetyError) as e: + return contract.submitted_unconfirmed( + f"批量撤单动作已发出,但后续弹窗核验未完成:{e}", + data={"action": action, "submitted": True}, + ) return pump.attach_to({ "code": 0, "status": "succeed", @@ -815,13 +1388,28 @@ def _grab_grid(self, kind: str, goto, label: str, normalize=None): 2026-08-03 串线事故的正面修复:翻页快捷键是全局按键,没落到 xiadan 时 grid 里还是上一次查询的表,Ctrl+C 原样抓走,过去非空即 code=0 出门。 """ + self._last_grid_columns.pop(kind, None) + self._last_grid_verified_empty.discard(kind) + self._last_grid_debug.pop(kind, None) got_columns: list[str] = [] reason = "" + navigation_failed = False for attempt in range(1, self._GRID_ATTEMPTS + 1): - goto() + navigated = goto() + if navigated is False: + navigation_failed = True + logger.warning("%s 未能导航到目标页面(第 %d/%d 次),未读取当前残留表格", + label, attempt, self._GRID_ATTEMPTS) + time.sleep(sleep_time) + continue hwnd = self.get_right_hwnd() ctrl = self._find_grid(hwnd) data = self.read_table_text(ctrl) if ctrl else None + if data is _VERIFIED_EMPTY_GRID: + logger.info("%s 已通过拷贝验证码核验为空表(客户端未输出表头)", label) + self._last_grid_verified_empty.add(kind) + self.state.update(kind, []) + return contract.ok([]) if data: # 表头取自原始文本而非解析结果:空表(今天无挂单/无成交)是合法 # 结果,它照样有表头,必须能通过校验并以 data=[] 正常返回。 @@ -830,6 +1418,11 @@ def _grab_grid(self, kind: str, goto, label: str, normalize=None): parsed = parse_table(data) if not reason: rows = normalize(parsed) if normalize else parsed + self._last_grid_columns[kind] = tuple(got_columns) + self._last_grid_debug[kind] = { + "clipboard_text": data, + "normalized_rows": rows, + } self.state.update(kind, rows) return contract.ok(rows) logger.warning("%s 抓到错表(第 %d/%d 次):%s cols=%r", @@ -841,6 +1434,10 @@ def _grab_grid(self, kind: str, goto, label: str, normalize=None): f"{label}:抓到的不是本次请求的表({reason})," f"重抓 {self._GRID_ATTEMPTS} 次仍不符,已拒绝返回错表,请稍后重试", data={"got_columns": got_columns}) + if navigation_failed: + return contract.fail( + contract.CODE_READ_FAILED, CLS_READ_FAILED, + f"{label}:未能导航到目标页面,已中止读取以避免使用残留表格,请稍后重试") return contract.fail(contract.CODE_READ_FAILED, CLS_READ_FAILED, f"{label}:读取数据失败(可能验证码弹窗或刷新超时),请稍后重试") @@ -877,6 +1474,8 @@ def get_active_orders(self): def goto(): self.switch_to_normal() _activate_window(self.hwnd_main) + # 当前 Windows 实机验证 F1 -> F8 可切到当日委托;保留表头校验和只读可见 + # grid 的约束,热键失效时会安全拒绝错表,不能把持仓表当委托表返回。 hot_key(["F1"]) hot_key(["F8"]) self.refresh() @@ -902,12 +1501,31 @@ def goto(): def _grab_active(self, goto, include_terminal: bool): coid_map = self._coid_map() + def is_empty_order_placeholder(raw: dict[str, Any]) -> bool: + """识别空委托表带时间/展示列的残留占位行。 + + 同花顺有时在没有任何委托时仍复制一行时间或市场展示值,因此通用 + ``parse_table`` 不会把它视作全空行。只有订单身份及数量/价格等核心字段 + 全部为占位值才过滤;有任一真实订单字段却没有合同号的行仍必须拒绝基线。 + """ + identity_fields = ( + "合同编号", "委托编号", "证券代码", "操作", "买卖标志", "买卖", + "委托数量", "委托价格", "委托价", "成交数量", + ) + return all( + _is_phantom_value(raw.get(field, "")) + for field in identity_fields + ) + def normalize(rows): out = [] for raw in rows: + if is_empty_order_placeholder(raw): + logger.debug("委托查询过滤空表展示占位行:keys=%r", list(raw)) + continue row = normalize_active_row(raw, coid_map) if include_terminal or is_in_flight( - row["状态"], row["委托数量"], row["已成数量"]): + row["状态"], row["委托数量"], row["已成数量"]): out.append(row) return out @@ -968,6 +1586,130 @@ def _capture_window_png(self, hwnd): except Exception: pass + def _open_account_dropdown(self) -> bool: + """点击当前快照确认的账户 ComboBox。""" + combo = self._find_ctrl_by_id( + self.hwnd_main, ACCOUNT_DROPDOWN_ID, cls="ComboBox", visible=True + ) + if (combo + and win32gui.IsWindowEnabled(combo) + and self._window_is_owned_by_bound_process(combo)): + left, top, right, bottom = win32gui.GetWindowRect(combo) + self._click_screen((left + right) // 2, (top + bottom) // 2, + "account_dropdown") + return True + raise RuntimeError( + f"未找到可见且属于当前同花顺进程的账户 ComboBox(ID=0x{ACCOUNT_DROPDOWN_ID:04X});" + "未点击、未读取账户、未切换账户" + ) + + def _find_open_account_listbox(self) -> int: + """Find the visible standard dropdown ListBox confirmed by the live snapshot.""" + matches: list[int] = [] + + def visit(hwnd, _): + try: + if ( + win32gui.IsWindowVisible(hwnd) + and win32gui.GetDlgCtrlID(hwnd) == ACCOUNT_LISTBOX_ID + and win32gui.GetClassName(hwnd) == ACCOUNT_LISTBOX_CLASS + ): + matches.append(hwnd) + except Exception: + pass + return True + + win32gui.EnumWindows(visit, None) + if len(matches) != 1: + raise RuntimeError( + "未唯一定位到账户下拉列表:期望可见 %s / ID=0x%04X,实际命中=%s" + % (ACCOUNT_LISTBOX_CLASS, ACCOUNT_LISTBOX_ID, + [f"0x{hwnd:X}" for hwnd in matches]) + ) + return matches[0] + + def _read_account_listbox_items(self, listbox: int) -> list[str]: + """Read standard ListBox rows without changing its selected item.""" + # ctypes.windll.user32 is process-global. Do not set SendMessageW.argtypes + # there: other paths intentionally use its two-argument form to read + # controls such as 0x094C, and a global four-argument signature makes + # every later account read fail with "takes 4 arguments (2 given)". + user32 = ctypes.WinDLL("user32", use_last_error=True) + send_message = user32.SendMessageW + send_message.argtypes = ( + wintypes.HWND, wintypes.UINT, wintypes.WPARAM, wintypes.LPARAM, + ) + send_message.restype = ctypes.c_ssize_t + count = send_message(listbox, LB_GETCOUNT, 0, 0) + if count == LB_ERR or count < 0: + raise RuntimeError(f"账户下拉列表 LB_GETCOUNT 失败,返回 {count}") + items: list[str] = [] + for index in range(count): + length = send_message(listbox, LB_GETTEXTLEN, index, 0) + if length == LB_ERR or length < 0: + raise RuntimeError( + f"账户下拉列表 LB_GETTEXTLEN 失败,index={index},返回 {length}" + ) + buffer = ctypes.create_unicode_buffer(length + 1) + copied = send_message( + listbox, LB_GETTEXT, index, ctypes.addressof(buffer) + ) + if copied == LB_ERR: + raise RuntimeError(f"账户下拉列表 LB_GETTEXT 失败,index={index}") + items.append(buffer.value) + return items + + @guarded + def get_account_list(self): + """展开账户下拉框并读取标准 ListBox 文本;不选择、不切换账户。""" + self._switch_to_normal_safely() + current = self._read_account_selector_text() + opened = False + try: + opened = self._open_account_dropdown() + time.sleep(ACCOUNT_DROPDOWN_SETTLE_SECS) + listbox = self._find_open_account_listbox() + candidates = _account_candidates_from_listbox( + self._read_account_listbox_items(listbox) + ) + if not candidates: + return contract.fail( + contract.CODE_READ_FAILED, CLS_READ_FAILED, + "账户下拉框已打开,但未读取到可切换账户;未切换账户", + data={"accounts": [], "current_account_text": current, + "partial": True, "submitted": False}, + ) + + accounts = [ + { + "slot": item["slot"], + "shortcut": item["shortcut"], + "text": item["text"], + } + for item in candidates + ] + return contract.ok({ + "accounts": accounts, + "current_account_text": current, + "partial": False, + "source": "listbox_text", + "msg": "已读取账户下拉列表原始文本,未选择或切换任何账户", + }) + except Exception as e: + logger.warning("account list ListBox read failed: %s", e, exc_info=True) + return contract.fail( + contract.CODE_READ_FAILED, CLS_READ_FAILED, + f"读取账户下拉列表失败:{e};未切换账户", + data={"accounts": [], "current_account_text": current, + "partial": True, "submitted": False}, + ) + finally: + if opened: + try: + self._send_hotkey(["esc"], "close_account_dropdown") + except Exception: + logger.warning("账户下拉框关闭失败,请人工确认当前界面", exc_info=True) + def _ocr_leftmost_codes(self, img) -> list[str]: """OCR 图中 6 位数字,只取【最左一簇】(x 最小)=代码列,排除右侧数字列(主力净额/ 总金额等)产生的假 6 位数。去重保序(顶部在前)。""" @@ -1160,11 +1902,13 @@ def walk(hitem: int): k32.VirtualFreeEx(int(h_proc), remote_item, 0, MEM_RELEASE) win32api.CloseHandle(h_proc) - def _select_tree_child(self, parent_text: str, child_text: str) -> bool: - """先按 parent_text 定位父节点,再在其【直接子节点】里整串精确匹配 child_text 选中。 + def _select_tree_path(self, path: tuple[str, ...], + require_window_safety: bool = False) -> bool: + """按左树完整路径精确匹配节点,再选中并点击最终节点。 - 用于市价委托 └ 买入/卖出——子节点文字'买入'与顶层'买入[F1]'前缀相同,深度优先的 - _select_tree_node_by_text 会先撞顶层,故必须限定在父节点子树内、且整串精确匹配。 + 市价入口的文字在不同券商版本中可能不同,且“买入”会与普通 ``买入[F1]`` 重名。 + 因此不做深度优先子串搜索;调用方必须提供从顶层开始的完整路径。当前真机路径为 + ``("市价买入",)`` 或 ``("市价卖出",)``。 跨进程 TreeView 读写/位数处理/DPI 点击与 _select_tree_node_by_text 同构;那套原语有 交割单/自选股导航依赖,为免在无法回归的环境重构破坏,这里独立实现,真机稳定后可再合并。 @@ -1173,6 +1917,8 @@ def _select_tree_child(self, parent_text: str, child_text: str) -> bool: if not tree: logger.warning("market: tree hwnd not found") return False + if require_window_safety: + self._require_owned_window_for_input(tree, "market_tree_navigation") _, pid = win32process.GetWindowThreadProcessId(tree) is32 = _proc_is_wow64(pid) TVITEM = _TVITEM32 if is32 else _TVITEMW @@ -1200,8 +1946,10 @@ def _select_tree_child(self, parent_text: str, child_text: str) -> bool: def _norm(s: str) -> str: return s.replace(" ", "").replace(" ", "") - parent_norm = _norm(parent_text) - child_norm = _norm(child_text) + normalized_path = tuple(_norm(item) for item in path) + if not normalized_path or any(not item for item in normalized_path): + logger.warning("market: invalid tree path %r", path) + return False def read_text(hitem: int) -> str: item = TVITEM() @@ -1216,49 +1964,34 @@ def read_text(hitem: int) -> str: k32.ReadProcessMemory(int(h_proc), remote_text, buf, bufsize, None) return buf.raw.decode("utf-16-le", "ignore").split("\x00", 1)[0] - # 1) 深度优先找父节点(parent_text 在菜单里唯一,子串匹配足够) - def find_parent(hitem: int): + def find_sibling(hitem: int, target: str) -> int: + seen: list[str] = [] while hitem: - if parent_norm in _norm(read_text(hitem)): + text = _norm(read_text(hitem)) + seen.append(text) + if text == target: return hitem - child = win32gui.SendMessage(tree, TVM_GETNEXTITEM, TVGN_CHILD, hitem) - if child: - found = find_parent(child) - if found: - return found hitem = win32gui.SendMessage(tree, TVM_GETNEXTITEM, TVGN_NEXT, hitem) + logger.warning("market: path component %r not found; siblings=%r", target, seen) return 0 - parent = find_parent(win32gui.SendMessage(tree, TVM_GETNEXTITEM, TVGN_ROOT, 0)) - if not parent: - logger.warning("market: parent tree node %r not found", parent_text) - return False - - # 2) 只在父节点的【直接子节点】里整串精确匹配 child_text(免撞顶层"买入[F1]") node = 0 - seen_children: list[str] = [] - hchild = win32gui.SendMessage(tree, TVM_GETNEXTITEM, TVGN_CHILD, parent) - while hchild: - ctext = _norm(read_text(hchild)) - seen_children.append(ctext) - if ctext == child_norm: - node = hchild - break - hchild = win32gui.SendMessage(tree, TVM_GETNEXTITEM, TVGN_NEXT, hchild) - if not node: - logger.warning("market: child %r under %r not found; children=%r", - child_text, parent_text, seen_children) - return False - - # 3) 选中 + 真实鼠标点击(触发右侧面板切换;同 _select_tree_node_by_text) + siblings = win32gui.SendMessage(tree, TVM_GETNEXTITEM, TVGN_ROOT, 0) + for component in normalized_path: + node = find_sibling(siblings, component) + if not node: + logger.warning("market: tree path %r not found", path) + return False + siblings = win32gui.SendMessage(tree, TVM_GETNEXTITEM, TVGN_CHILD, node) + + # 选中 + 真实鼠标点击(触发右侧面板切换;同 _select_tree_node_by_text) win32gui.SendMessage(tree, TVM_SELECTITEM, TVGN_CARET, node) k32.WriteProcessMemory(int(h_proc), remote_text, ctypes.byref(ctypes.c_ssize_t(node)), ctypes.sizeof(ctypes.c_ssize_t), None) got = win32gui.SendMessage(tree, TVM_GETITEMRECT, 0, remote_text) if not got: - logger.info("market: selected (no rect, 程序化) child %r/%r", - parent_text, child_text) + logger.info("market: selected (no rect, 程序化) path %r", path) return True rect = (wintypes.LONG * 4)() k32.ReadProcessMemory(int(h_proc), remote_text, ctypes.byref(rect), @@ -1276,9 +2009,12 @@ def find_parent(hitem: int): try: pt = wintypes.POINT(cx, cy) u32.ClientToScreen(tree, ctypes.byref(pt)) - win32api.SetCursorPos((pt.x, pt.y)) - win32api.mouse_event(win32con.MOUSEEVENTF_LEFTDOWN, 0, 0, 0, 0) - win32api.mouse_event(win32con.MOUSEEVENTF_LEFTUP, 0, 0, 0, 0) + if require_window_safety: + self._click_screen(pt.x, pt.y, "market_tree_navigation") + else: + win32api.SetCursorPos((pt.x, pt.y)) + win32api.mouse_event(win32con.MOUSEEVENTF_LEFTDOWN, 0, 0, 0, 0) + win32api.mouse_event(win32con.MOUSEEVENTF_LEFTUP, 0, 0, 0, 0) finally: if old_ctx is not None: try: @@ -1286,8 +2022,7 @@ def find_parent(hitem: int): except Exception: pass time.sleep(sleep_time) - logger.info("market: clicked tree child %r/%r at client(%d,%d)", - parent_text, child_text, cx, cy) + logger.info("market: clicked tree path %r at client(%d,%d)", path, cx, cy) return True finally: if remote_text: @@ -1296,6 +2031,16 @@ def find_parent(hitem: int): k32.VirtualFreeEx(int(h_proc), remote_item, 0, MEM_RELEASE) win32api.CloseHandle(h_proc) + def _select_market_tree_path(self, op_keyword: str) -> bool: + """选择当前客户端实际存在的市价买卖路径,兼容两种已验证菜单结构。""" + paths = MARKET_TREE_PATHS.get(op_keyword, ()) + for path in paths: + if self._select_tree_path(path, require_window_safety=True): + logger.info("market: selected compatible path %r for %s", path, op_keyword) + return True + logger.warning("market: no compatible path found for %s; attempted=%r", op_keyword, paths) + return False + def _real_click_hwnd(self, h: int) -> None: """对控件做一次真实鼠标点击(取窗口中心 → SetCursorPos → 按下抬起)。 @@ -1428,113 +2173,334 @@ def _do_settlement(self, date_range: str = "近一年"): return contract.fail(contract.CODE_INTERNAL_ERROR, contract.CLS_INTERNAL_ERROR, f"交割单读取异常: {e}") - def _lookup_entrust_no(self, stock_no, op_keyword, amount, price, timeout=8.0): - """After buy/sell submission, find the freshly-placed order in - orders/active by matching (code, op, qty, price). Returns entrust_no - string or None if not found within timeout. - - Replaces the upstream `ocr_rect` approach which read entrust_no by - OCR-ing a screen region (right-300:right, bottom-21:bottom). That - region is fragile to xiadan version / DPI / occlusion / dialog - timing — it reliably failed in our 100-share test even though the - order was actually placed. orders/active reads the broker's view via - clipboard which is the source of truth. + @staticmethod + def _columns_include_any(columns: tuple[str, ...] | list[str], aliases: tuple[str, ...]) -> bool: + return any(alias in str(column) for column in columns for alias in aliases) + + @classmethod + def _active_order_columns_reliable(cls, columns: tuple[str, ...] | list[str]) -> bool: + """Whether an active-order table can prove a limit-order identity. + + The generic table guard only proves that this is broadly an order table. + Ownership needs every component of the fingerprint and a stable order + ID; accepting a partial broker skin here would turn a later heuristic + into a false contract-number claim. + """ + columns = tuple(str(column) for column in columns if str(column).strip()) + required = ( + ("合同编号", "委托编号"), + ("证券代码",), + # 券商皮肤实际可见三种方向列名;必须与 normalize_active_row + # 的取值别名保持一致,否则基线通过后仍无法按完整指纹认领新合同号。 + ("操作", "买卖标志", "买卖"), + ("委托数量",), + ("委托价格", "委托价"), + ("成交数量",), + ("备注", "委托状态", "状态"), + ) + return bool(columns) and all(cls._columns_include_any(columns, aliases) for aliases in required) + + @staticmethod + def _entrust_no(value: Any) -> str: + return str(value or "").strip() + + @classmethod + def _matching_limit_entrust_nos(cls, rows: list[dict], stock_no: Any, + op_keyword: str, amount: Any, price: Any) -> list[str]: + """Return IDs whose complete normalized fingerprint exactly matches. + + This helper does not infer any ordering from contract numbers. It is + deliberately pure so the ambiguity rule can be unit-tested without + Windows APIs. + """ + try: + target_amount = int(amount) + target_price = round(float(price), 3) + except (TypeError, ValueError): + return [] + matched: list[str] = [] + for row in rows: + if not isinstance(row, dict): + continue + entrust_no = cls._entrust_no(row.get("entrust_no")) + if not entrust_no: + continue + if row.get("证券代码") != str(stock_no): + continue + if row.get("方向") != op_keyword: + continue + if row.get("委托数量") != target_amount: + continue + row_price = row.get("委托价") + if isinstance(row_price, bool) or not isinstance(row_price, (int, float)): + continue + if round(float(row_price), 3) != target_price: + continue + matched.append(entrust_no) + return matched + + @classmethod + def _unique_new_limit_entrust_no(cls, rows: list[dict], baseline_entrust_nos: set[str], + stock_no: Any, op_keyword: str, amount: Any, + price: Any) -> tuple[str | None, int]: + """Claim an ID only when exactly one full-fingerprint candidate is new.""" + candidates = [ + entrust_no + for entrust_no in cls._matching_limit_entrust_nos( + rows, stock_no, op_keyword, amount, price) + if entrust_no not in baseline_entrust_nos + ] + return (candidates[0], 1) if len(candidates) == 1 else (None, len(candidates)) + + def _read_limit_order_baseline(self) -> tuple[set[str] | None, dict[str, Any] | None]: + """Read a strict, pre-submit full order-table baseline for limit orders.""" + result = self.get_active_orders_all() + if not contract.is_succeed(result): + detail = ((result.get("error") or {}).get("message")) or "委托表读取失败" + return None, contract.fail( + result.get("code") or contract.CODE_READ_FAILED, + ((result.get("error") or {}).get("class")) or CLS_READ_FAILED, + f"限价委托提交前无法建立可靠委托表基线({detail}),已中止未提交", + data={"submitted": False}, + ) + columns = self._last_grid_columns.get("active_orders", ()) + debug_snapshot = self._last_grid_debug.get("active_orders", {}) + verified_empty = "active_orders" in self._last_grid_verified_empty + if not verified_empty and not self._active_order_columns_reliable(columns): + self._log_limit_baseline_debug( + "unreliable_columns", columns, debug_snapshot, + ) + return None, contract.fail( + contract.CODE_TABLE_MISMATCH, CLS_TABLE_MISMATCH, + "限价委托提交前的委托表缺少合同号、代码、方向、数量、价格或状态列," + "无法可靠归属新订单,已中止未提交", + data={"submitted": False, "got_columns": list(columns)}, + ) + rows = result.get("data") + if not isinstance(rows, list): + self._log_limit_baseline_debug( + "non_list_rows", columns, debug_snapshot, + ) + return None, contract.fail( + contract.CODE_READ_FAILED, CLS_READ_FAILED, + "限价委托提交前的委托表结果不是行列表,无法建立可靠基线,已中止未提交", + data={"submitted": False}, + ) + if verified_empty: + if rows: + return None, contract.fail( + contract.CODE_READ_FAILED, CLS_READ_FAILED, + "委托表空表核验与返回行数不一致,无法建立可靠基线,已中止未提交", + data={"submitted": False}, + ) + return set(), None + ids = [self._entrust_no(row.get("entrust_no")) for row in rows if isinstance(row, dict)] + if len(ids) != len(rows) or any(not entrust_no for entrust_no in ids) or len(set(ids)) != len(ids): + missing_indexes = [ + index for index, row in enumerate(rows) + if not isinstance(row, dict) or not self._entrust_no(row.get("entrust_no")) + ] + id_indexes: dict[str, list[int]] = {} + for index, row in enumerate(rows): + if isinstance(row, dict): + entrust_no = self._entrust_no(row.get("entrust_no")) + if entrust_no: + id_indexes.setdefault(entrust_no, []).append(index) + duplicates = { + entrust_no: indexes for entrust_no, indexes in id_indexes.items() + if len(indexes) > 1 + } + self._log_limit_baseline_debug( + "missing_or_duplicate_contract", columns, debug_snapshot, + missing_contract_row_indexes=missing_indexes, + duplicate_contract_ids=duplicates, + ) + return None, contract.fail( + contract.CODE_TABLE_MISMATCH, CLS_TABLE_MISMATCH, + "限价委托提交前的委托表存在缺失或重复合同号,无法证明新旧订单边界,已中止未提交", + data={"submitted": False, "got_columns": list(columns)}, + ) + return set(ids), None + + @staticmethod + def _log_limit_baseline_debug(reason: str, columns: tuple[str, ...] | list[str], + snapshot: dict[str, Any], **details: Any) -> None: + """将委托表基线拒绝时的剪贴板和解析中间态写入本地诊断日志。""" + payload = { + "reason": reason, + "columns": list(columns), + "clipboard_text": snapshot.get("clipboard_text"), + "normalized_rows": snapshot.get("normalized_rows"), + **details, + } + logger.error( + "[LIMIT_BASELINE_DEBUG] %s", + json.dumps(payload, ensure_ascii=False, sort_keys=True, default=str), + ) + + def _lookup_entrust_no(self, stock_no, op_keyword, amount, price, + baseline_entrust_nos: set[str] | None, timeout=8.0): + """Find one uniquely new full-fingerprint order after a limit submit. + + The previous implementation sorted same-parameter candidates by the + largest contract number. That assumption is not evidence of ownership: + an old/manual/external order can have a larger number. Only a contract + absent from the pre-submit baseline is eligible, and multiple new rows + are explicitly unresolved. """ - target_price = f"{float(price):.3f}" if price is not None else None - target_amount = str(int(amount)) + if baseline_entrust_nos is None: + logger.warning("lookup_entrust_no refused without a pre-submit baseline") + return None deadline = time.time() + timeout last_seen_rows = 0 while time.time() < deadline: - result = self.get_active_orders() + result = self.get_active_orders_all() if contract.is_succeed(result): - # 契约 v2:行已规范化(数值为 number、方向/状态为枚举、id 键为 entrust_no)。 + columns = self._last_grid_columns.get("active_orders", ()) + verified_empty = "active_orders" in self._last_grid_verified_empty + if not verified_empty and not self._active_order_columns_reliable(columns): + logger.warning("lookup_entrust_no refused unreliable columns=%r", columns) + return None rows = result.get("data") or [] + if not isinstance(rows, list): + logger.warning("lookup_entrust_no got non-list order rows") + return None + if verified_empty and rows: + logger.warning("lookup_entrust_no got rows with verified-empty marker") + return None last_seen_rows = len(rows) - candidates = [] - for r in rows: - if (r.get("证券代码") or "") != str(stock_no): - continue - if op_keyword not in (r.get("方向") or ""): - continue - if r.get("委托数量") != int(target_amount): - continue - if target_price is not None and r.get("委托价") != float(target_price): - continue - candidates.append(r) - if candidates: - candidates.sort( - key=lambda r: int(r.get("entrust_no") or 0), - reverse=True, + entrust_no, candidate_count = self._unique_new_limit_entrust_no( + rows, baseline_entrust_nos, stock_no, op_keyword, amount, price) + if entrust_no: + logger.info( + "lookup_entrust_no uniquely matched new order stock=%s op=%s qty=%s price=%s -> %s", + stock_no, op_keyword, amount, price, entrust_no, ) - eno = (candidates[0].get("entrust_no") or "").strip() - if eno: - logger.info( - "lookup_entrust_no matched stock=%s op=%s qty=%s price=%s -> %s", - stock_no, op_keyword, target_amount, target_price, eno, - ) - return eno + return entrust_no + if candidate_count > 1: + logger.warning( + "lookup_entrust_no ambiguous: %d newly appeared full matches " + "stock=%s op=%s qty=%s price=%s", + candidate_count, stock_no, op_keyword, amount, price, + ) + return None time.sleep(0.3) logger.warning( "lookup_entrust_no timeout stock=%s op=%s qty=%s price=%s rows_last=%d", - stock_no, op_keyword, target_amount, target_price, last_seen_rows, + stock_no, op_keyword, amount, price, last_seen_rows, ) return None def _submit_trade(self, panel_key, op_keyword, stock_no, amount, price): - """Shared form-fill + submit + lookup pipeline for buy/sell. + """Shared conservative limit-order form-fill, submit, and ownership path.""" + submitted = False - panel_key: 'F1' (buy) or 'F2' (sell). - op_keyword: '买入' or '卖出' — substring matched against orders/active 操作 column. - """ - self.switch_to_normal() - _activate_window(self.hwnd_main) - hot_key([panel_key]) - time.sleep(sleep_time) - hwnd = self.get_right_hwnd() - ctrl = self._find_input(hwnd, 0x408) - set_text(ctrl, stock_no) - time.sleep(sleep_time) - price_str = None - if price is not None: - price_str = "%.3f" % price - ctrl = self._find_input(hwnd, 0x409) - set_text(ctrl, price_str, True) - time.sleep(short_sleep_time) - ctrl = self._find_input(hwnd, 0x40A) - set_text(ctrl, str(amount)) - time.sleep(sleep_time) - # Submit form(Enter 提交表单本身)→ 之后可能出现的确认框/验证码/结果框 - # 交给 DialogSentry 结构化处置(发现弹窗→点肯定按钮→存证;含 Edit 的 - # 验证码框走 input_ocr)。取代旧的三连盲 Enter:不再依赖焦点与时序, - # 弹窗标题/全文/所点按钮全部带回回执,绝不静默。 - # 提交前最后一次对代次:填单到这里已过去约 1s,若本笔已被超时作废, - # 绝不能在下一笔正在操作同一窗口时又敲一次提交。 - self._abort_if_stale("submit_trade") - hot_key(["enter"]) # submit form → 可能弹「委托确认」 - pump = self._pump_dialogs() - time.sleep(sleep_time) - entrust_no = pump.entrust_no or self._lookup_entrust_no( - stock_no, op_keyword, amount, price) - if entrust_no: - return contract.ok(pump.attach_to({ - "entrust_no": entrust_no, - "stock_no": str(stock_no), - "方向": op_keyword, - "委托数量": int(amount), - "委托价": float(price) if price is not None else None, - "submitted": True, - })) - if pump.texts: - # 回查无此单 + 有弹窗文本 ⇒ 大概率被拒/废单:原文原样带回 broker_msg, - # class 由关键词表尽力映射(认不出即 unknown = 不可自动重试)。 - return contract.broker_rejected( - ";".join(pump.texts), - message="委托未进入委托列表,客户端有提示", - data=pump.attach_to({"stock_no": str(stock_no), "submitted": True})) - return contract.submitted_unconfirmed( - "已提交但未能在委托表中匹配到对应订单,真相不可知。" - "安全动作=用同一 client_order_id 原样重发(幂等),或调 query_order 核实", - data=pump.attach_to({"stock_no": str(stock_no), "submitted": True})) + def mark_submitted() -> None: + nonlocal submitted + submitted = True + + try: + baseline_entrust_nos, baseline_error = self._read_limit_order_baseline() + if baseline_error: + return baseline_error + if baseline_entrust_nos is None: + return self._pre_submit_failure("限价委托提交前未能建立委托表基线") + + self._switch_to_normal_safely() + self._send_hotkey([panel_key], "limit_order_panel") + time.sleep(sleep_time) + hwnd = self.get_right_hwnd() + if not hwnd: + return self._pre_submit_failure( + "限价委托提交前未找到右侧下单面板", + contract.CODE_READ_FAILED, CLS_READ_FAILED, + ) + code_ctrl = self._find_input(hwnd, 0x408) + amount_ctrl = self._find_input(hwnd, 0x40A) + if not code_ctrl or not amount_ctrl: + return self._pre_submit_failure( + "限价委托提交前未找到证券代码或数量输入框", + contract.CODE_READ_FAILED, CLS_READ_FAILED, + ) + self._set_owned_text(code_ctrl, stock_no, "limit_order_code") + time.sleep(sleep_time) + if price is not None: + price_ctrl = self._find_input(hwnd, 0x409) + if not price_ctrl: + return self._pre_submit_failure( + "限价委托提交前未找到价格输入框", + contract.CODE_READ_FAILED, CLS_READ_FAILED, + ) + self._set_owned_text(price_ctrl, "%.3f" % price, "limit_order_price", True) + time.sleep(short_sleep_time) + self._set_owned_text(amount_ctrl, str(amount), "limit_order_amount") + time.sleep(sleep_time) + + # The last foreground check occurs immediately before Enter. Mark + # the outcome unknown before invoking the global submit key: an + # exception during the key event cannot prove that no submit began. + self._send_hotkey( + ["enter"], "limit_submit", before_dispatch=mark_submitted + ) + except StaleCallAborted: + raise + except WindowSafetyError as e: + if not submitted: + return self._pre_submit_failure(str(e)) + return contract.submitted_unconfirmed( + f"限价委托已进入提交动作,但前台窗口校验随后失败:{e}", + data={"stock_no": str(stock_no), "submitted": True}, + ) + except Exception as e: + logger.exception("limit order failed before/while submit stock=%s", stock_no) + if not submitted: + return self._pre_submit_failure( + f"限价委托提交前发生异常:{e}", + contract.CODE_READ_FAILED, CLS_READ_FAILED, + ) + return contract.submitted_unconfirmed( + f"限价委托已进入提交动作,但客户端未能确认结果:{e}", + data={"stock_no": str(stock_no), "submitted": True}, + ) + + try: + pump = self._pump_dialogs() + time.sleep(sleep_time) + entrust_no = self._entrust_no(getattr(pump, "entrust_no", None)) + if not entrust_no: + entrust_no = self._lookup_entrust_no( + stock_no, op_keyword, amount, price, baseline_entrust_nos) + if entrust_no: + return contract.ok(pump.attach_to({ + "entrust_no": entrust_no, + "stock_no": str(stock_no), + "方向": op_keyword, + "委托数量": int(amount), + "委托价": float(price) if price is not None else None, + "submitted": True, + })) + if pump.texts: + return contract.broker_rejected( + ";".join(pump.texts), + message="委托已进入提交动作但未能在委托表唯一确认,客户端有提示", + data=pump.attach_to({"stock_no": str(stock_no), "submitted": True}), + ) + return contract.submitted_unconfirmed( + "限价委托已提交,但委托表中没有唯一新增的完整匹配订单;未认领合同号。" + "请用同一 client_order_id 查询或人工核对,勿改单重下", + data=pump.attach_to({"stock_no": str(stock_no), "submitted": True}), + ) + except StaleCallAborted as e: + return contract.submitted_unconfirmed( + f"限价委托已提交,但后续核验被作废:{e}", + data={"stock_no": str(stock_no), "submitted": True}, + ) + except Exception as e: + logger.exception("limit order post-submit verification failed stock=%s", stock_no) + return contract.submitted_unconfirmed( + f"限价委托已提交,但后续核验失败:{e}", + data={"stock_no": str(stock_no), "submitted": True}, + ) @guarded def _do_sell(self, stock_no, amount, price): @@ -1556,6 +2522,8 @@ def _set_market_strategy(self, combo, key, expected_index): 键盘法(真机验证优先):标准 ComboBox 收到数字字符 → 增量匹配"以该数字开头"的项 (买入'1'→'1-...'、卖出'4'→'4-五档即成剩撤'),且能触发同花顺的策略变更处理。 跨进程发键需 AttachThreadInput + SetFocus,否则 WM_CHAR 落不到目标控件。""" + self._require_foreground_for_input("market_strategy") + self._require_owned_window_for_input(combo, "market_strategy") CB_GETCURSEL, CB_SETCURSEL = 0x0147, 0x014E user32 = ctypes.windll.user32 kernel32 = ctypes.windll.kernel32 @@ -1592,59 +2560,100 @@ def _submit_market_trade(self, op_keyword, stock_no, amount): return contract.fail(contract.CODE_INVALID_PARAMS, contract.CLS_INVALID_PARAMS, f"未知方向 {op_keyword!r}") - self.switch_to_normal() - _activate_window(self.hwnd_main) - # 下单前快照成交表作 before 基线(差分辨"本次新增成交" vs 历史成交;~1-2s, - # 换回执真实性,值得——市价单可能部分成交,必须拿准实际成交量/均价)。 - pre = self.get_filled_orders() - if pre.get("code") != 0: - # 基线拿不到就**不下单**。空基线会把当日同股同向的历史成交算成本次成交 - # (回执差分认「after 里 before 没有的行」),直接污染真钱 sizing 的输入; - # 而市价单发出去就没法回收。宁可不下单让调用方重试,也不带着空基线提交。 - reason = ((pre.get("error") or {}).get("message")) or "" - return contract.fail( - contract.CODE_READ_FAILED, contract.CLS_READ_FAILED, - f"下单前无法读取成交表作回执基线({reason}),已中止未提交——" - "空基线会把历史成交误算成本次成交。请稍后重试", - data={"submitted": False}) - before = pre.get("data") or [] + submitted = False - if not self._select_tree_child(MARKET_TREE_PARENT, op_keyword): - return contract.fail(contract.CODE_READ_FAILED, contract.CLS_READ_FAILED, - "未能导航到市价委托面板", data={"submitted": False}) - time.sleep(sleep_time) - hwnd = self.get_right_hwnd() + def mark_submitted() -> None: + nonlocal submitted + submitted = True - # 填 证券代码 + 数量(市价面板无价格框) - set_text(self._find_input(hwnd, MARKET_CODE_ID), stock_no) - time.sleep(sleep_time) - set_text(self._find_input(hwnd, MARKET_AMOUNT_ID), str(amount)) - time.sleep(short_sleep_time) + try: + self._switch_to_normal_safely() + # 下单前快照成交表作 before 基线(差分辨"本次新增成交" vs 历史成交;~1-2s, + # 换回执真实性,值得——市价单可能部分成交,必须拿准实际成交量/均价)。 + pre = self.get_filled_orders() + # ``get_filled_orders`` returns the v2 contract envelope, whose + # successful code is the string ``"ok"`` (not legacy integer 0). + # Checking ``code != 0`` rejected every successful read, + # including the normal no-history case ``data=[]``. + if not contract.is_succeed(pre): + reason = ((pre.get("error") or {}).get("message")) or "" + return contract.fail( + contract.CODE_READ_FAILED, contract.CLS_READ_FAILED, + f"下单前无法读取成交表作回执基线({reason}),已中止未提交——" + "空基线会把历史成交误算成本次成交。请稍后重试", + data={"submitted": False}) + before = pre.get("data") + if not isinstance(before, list): + return contract.fail( + contract.CODE_READ_FAILED, contract.CLS_READ_FAILED, + "下单前成交表回执基线格式异常,已中止未提交", + data={"submitted": False}) - # 委托策略 = 五档即成剩撤(卖出默认是即成剩撤=深市专有、沪市会拒 → 必须显式设) - combo = self._find_ctrl_by_id(hwnd, MARKET_STRATEGY_COMBO_ID, cls="ComboBox", visible=True) \ - or self._find_ctrl_by_id(hwnd, MARKET_STRATEGY_COMBO_ID) - if not combo: - return contract.fail(contract.CODE_READ_FAILED, contract.CLS_READ_FAILED, - "未找到委托策略下拉框", data={"submitted": False}) - if not self._set_market_strategy(combo, strat["key"], strat["index"]): - logger.warning("market strategy not set to 五档即成剩撤 op=%s, abort", op_keyword) - return contract.fail(contract.CODE_INVALID_PARAMS, contract.CLS_INVALID_PARAMS, - "委托策略未能设为五档即成剩撤,已中止(避免下错单)", - data={"submitted": False}) - - # 提交:点提交按钮(焦点无关,避开 combo 焦点吞 Enter)。 - # 必须 PostMessage:SendMessage 是同步跨进程调用,按钮 handler 弹出模态 - # 「委托确认」框时不返回 → 线程死锁(2026-07-13 事故根因),后续弹窗 - # 处理代码永远执行不到。 - self._abort_if_stale("submit_market_trade") - submit_btn = self._find_ctrl_by_id(hwnd, MARKET_SUBMIT_BTN_ID, cls="Button", visible=True) \ - or self._find_ctrl_by_id(hwnd, MARKET_SUBMIT_BTN_ID) - if submit_btn: - win32api.PostMessage(submit_btn, win32con.BM_CLICK, 0, 0) - else: - hot_key(["enter"]) - pump = self._pump_dialogs() # 确认框/验证码/结果框:结构化处置 + 存证 + if not self._select_market_tree_path(op_keyword): + return contract.fail(contract.CODE_READ_FAILED, contract.CLS_READ_FAILED, + "未能导航到市价委托面板", data={"submitted": False}) + time.sleep(sleep_time) + hwnd = self.get_right_hwnd() + code_ctrl = self._find_input(hwnd, MARKET_CODE_ID) + amount_ctrl = self._find_input(hwnd, MARKET_AMOUNT_ID) + if not code_ctrl or not amount_ctrl: + return self._pre_submit_failure( + "市价委托提交前未找到证券代码或数量输入框", + contract.CODE_READ_FAILED, CLS_READ_FAILED, + ) + + # 填证券代码 + 数量(市价面板无价格框)。 + self._set_owned_text(code_ctrl, stock_no, "market_order_code") + time.sleep(sleep_time) + self._set_owned_text(amount_ctrl, str(amount), "market_order_amount") + time.sleep(short_sleep_time) + + # 委托策略 = 五档即成剩撤(卖出默认是即成剩撤=深市专有、沪市会拒 → 必须显式设) + combo = self._find_ctrl_by_id(hwnd, MARKET_STRATEGY_COMBO_ID, cls="ComboBox", visible=True) \ + or self._find_ctrl_by_id(hwnd, MARKET_STRATEGY_COMBO_ID) + if not combo: + return self._pre_submit_failure( + "未找到委托策略下拉框", contract.CODE_READ_FAILED, CLS_READ_FAILED) + if not self._set_market_strategy(combo, strat["key"], strat["index"]): + logger.warning("market strategy not set to 五档即成剩撤 op=%s, abort", op_keyword) + return contract.fail(contract.CODE_INVALID_PARAMS, contract.CLS_INVALID_PARAMS, + "委托策略未能设为五档即成剩撤,已中止(避免下错单)", + data={"submitted": False}) + + # 提交:优先定向投递 BM_CLICK;没有按钮才使用受前台校验的 Enter。 + submit_btn = self._find_ctrl_by_id(hwnd, MARKET_SUBMIT_BTN_ID, cls="Button", visible=True) \ + or self._find_ctrl_by_id(hwnd, MARKET_SUBMIT_BTN_ID) + if submit_btn: + self._post_owned_button_click( + submit_btn, "market_submit", before_dispatch=mark_submitted + ) + else: + self._send_hotkey(["enter"], "market_submit", before_dispatch=mark_submitted) + except StaleCallAborted: + raise + except WindowSafetyError as e: + if not submitted: + return self._pre_submit_failure(str(e)) + return contract.submitted_unconfirmed( + f"市价委托已进入提交动作,但窗口安全校验随后失败:{e}", + data={"stock_no": str(stock_no), "submitted": True}, + ) + except Exception as e: + logger.exception("market order failed before/while submit stock=%s", stock_no) + if not submitted: + return self._pre_submit_failure( + f"市价委托提交前发生异常:{e}", contract.CODE_READ_FAILED, CLS_READ_FAILED) + return contract.submitted_unconfirmed( + f"市价委托已进入提交动作,但客户端未能确认结果:{e}", + data={"stock_no": str(stock_no), "submitted": True}, + ) + try: + pump = self._pump_dialogs() # 确认框/验证码/结果框:结构化处置 + 存证 + except (StaleCallAborted, WindowSafetyError) as e: + return contract.submitted_unconfirmed( + f"市价委托已进入提交动作,但后续弹窗核验未完成:{e}", + data={"stock_no": str(stock_no), "submitted": True}, + ) time.sleep(sleep_time) # 回执:轮询成交表拿本次新增成交(五档即成剩撤成交极快,给足 8s) @@ -1678,15 +2687,68 @@ def _submit_market_trade(self, op_keyword, stock_no, amount): def _do_cancel(self, entrust_no): try: return self._cancel_inner(entrust_no) + except StaleCallAborted: + raise + except WindowSafetyError as e: + logger.warning("cancel(%s) stopped by window safety: %s", entrust_no, e) + return self._pre_submit_failure(str(e)) except Exception as e: logger.exception("cancel(%s) unhandled exception", entrust_no) return contract.fail(contract.CODE_INTERNAL_ERROR, contract.CLS_INTERNAL_ERROR, f"cancel error: {e}") + def _verify_cancel_after_submit(self, entrust_no: str) -> dict[str, Any]: + """Read F3 after a confirmed cancel click; never submit a second cancel. + + A successful click only proves that the client accepted our input. The + broker result is confirmed only when the same F3 row explicitly says + ``已撤``. F3 excludes non-revocable orders, therefore a missing row is + intentionally not a success signal: it can also be a full fill. + """ + deadline = time.time() + CANCEL_VERIFY_TIMEOUT_SECS + last_outcome = "unreadable" + last_state: str | None = None + last_columns: list[str] = [] + + while time.time() < deadline: + self.refresh(require_window_safety=True) + hwnd = self.get_right_hwnd() + ctrl = self._find_grid(hwnd) if hwnd else None + data = self.read_table_text(ctrl) if ctrl else None + if isinstance(data, str): + last_columns = table_columns(data) + outcome, state = _cancel_f3_outcome(data, entrust_no) + last_outcome, last_state = outcome, state + if outcome == "canceled": + logger.info("撤单柜台状态已由 F3 确认 entrust_no=%s", entrust_no) + return contract.ok({ + "entrust_no": str(entrust_no), + "submitted": True, + "cancel_state": ST_CANCELED, + "cancel_verified": True, + }) + time.sleep(CANCEL_VERIFY_INTERVAL_SECS) + + logger.warning( + "撤单确认后 F3 未能确认柜台已撤 entrust_no=%s outcome=%s state=%s columns=%r", + entrust_no, last_outcome, last_state, last_columns, + ) + return contract.submitted_unconfirmed( + "撤单确认已点击,但未在 F3 委托表确认柜台已撤;本次未再次点击撤单。" + "请用原 client_order_id 查询订单状态", + data={ + "entrust_no": str(entrust_no), + "submitted": True, + "cancel_verified": False, + "cancel_state": last_state or "未知", + "f3_verification": last_outcome, + }, + ) + def _cancel_inner(self, entrust_no): - self.switch_to_normal() - hot_key(["F3"]) - self.refresh() + self._switch_to_normal_safely() + self._send_hotkey(["F3"], "cancel_panel") + self.refresh(require_window_safety=True) hwnd = self.get_right_hwnd() if not hwnd: return contract.fail(contract.CODE_READ_FAILED, contract.CLS_READ_FAILED, @@ -1724,23 +2786,55 @@ def _cancel_inner(self, entrust_no): if find is None: return contract.fail(contract.CODE_NOT_FOUND, contract.CLS_NOT_FOUND, f"撤单:委托表中没找到指定订单 {entrust_no}(可能已成/已撤)") - # 撤单是按行号算坐标的盲点击:本笔若已被作废,页面早被下一笔切走, - # 这两下点击会落到未知控件上 —— 提交类动作前必须对代次。 - self._abort_if_stale("cancel_click") + # 撤单是按行号算坐标的盲点击。两次点击分别校验前台,避免第二下在 + # 已失焦、HWND 被复用或调用作废后落到其他应用/其他控件。 + self._require_owned_window_for_input(ctrl, "cancel_click") left, top, right, bottom = win32gui.GetWindowRect(ctrl) x = 50 + left y = 30 + 16 * find + top - win32api.SetCursorPos((x, y)) - win32api.mouse_event(win32con.MOUSEEVENTF_LEFTDOWN, 0, 0, 0, 0) - win32api.mouse_event(win32con.MOUSEEVENTF_LEFTUP, 0, 0, 0, 0) + self._click_screen(x, y, "cancel_select_row") time.sleep(sleep_time) - win32api.mouse_event(win32con.MOUSEEVENTF_LEFTDOWN, 0, 0, 0, 0) - win32api.mouse_event(win32con.MOUSEEVENTF_LEFTUP, 0, 0, 0, 0) + self._click_screen(x, y, "cancel_confirm_row") time.sleep(sleep_time) # 双击委托行后可能弹「撤单确认」——结构化处置(取代两次盲 Enter), # 弹窗内容带回回执。 - pump = self._pump_dialogs() - return contract.ok(pump.attach_to({"entrust_no": str(entrust_no), "submitted": True})) + try: + pump = self._pump_dialogs() + except (StaleCallAborted, WindowSafetyError) as e: + return contract.submitted_unconfirmed( + f"撤单双击已发出,但后续弹窗核验未完成:{e}", + data={"entrust_no": str(entrust_no), "submitted": True}, + ) + if pump.status == "pending": + return contract.fail( + contract.CODE_READ_FAILED, + contract.CLS_READ_FAILED, + "撤单确认弹窗未找到可识别的肯定按钮,未自动确认撤单;请人工核对订单状态", + data=pump.attach_to({"entrust_no": str(entrust_no), "submitted": False}), + ) + try: + result = self._verify_cancel_after_submit(str(entrust_no)) + except StaleCallAborted: + raise + except Exception as e: + # The confirmation click has already been accepted. A later read, + # focus, or UI error cannot truthfully turn this into "not + # submitted"; preserve the unknown outcome for dispatcher query. + logger.exception("撤单确认后 F3 核验异常 entrust_no=%s", entrust_no) + result = contract.submitted_unconfirmed( + "撤单确认已点击,但 F3 委托表核验异常;本次未再次点击撤单。" + "请用原 client_order_id 查询订单状态", + data={ + "entrust_no": str(entrust_no), + "submitted": True, + "cancel_verified": False, + "cancel_state": "未知", + "f3_verification": "error", + }, + ) + if isinstance(result.get("data"), dict): + result["data"] = pump.attach_to(result["data"]) + return result def get_result(self, cid=0x3EC): tid, pid = win32process.GetWindowThreadProcessId(self.hwnd_main) @@ -1781,9 +2875,12 @@ def handler(hwnd, results): else: return {"code": 1, "status": "failed", "msg": text} - def refresh(self): + def refresh(self, require_window_safety: bool = False): self._abort_if_stale("refresh") - hot_key(["F5"]) + if require_window_safety: + self._send_hotkey(["F5"], "refresh") + else: + hot_key(["F5"]) time.sleep(refresh_sleep_time) def active_mian_window(self): @@ -1791,17 +2888,25 @@ def active_mian_window(self): ctypes.windll.user32.SwitchToThisWindow(self.hwnd_main, True) time.sleep(sleep_time) - def switch_to_normal(self): + def switch_to_normal(self, require_window_safety: bool = False): # 翻页/抓表链路的第一个动作 —— 代次检查放这里,脱缰线程在发出任何 # 全局按键之前就退出。 + require_window_safety = require_window_safety or bool( + getattr(self._tls, "require_window_safety", False) + ) self._abort_if_stale("switch_to_normal") tabs = self.get_left_bottom_tabs() + if require_window_safety: + self._require_owned_window_for_input(tabs, "switch_to_normal") left, top, right, bottom = win32gui.GetWindowRect(tabs) x = left + 10 y = top + 5 - win32api.SetCursorPos((x, y)) - win32api.mouse_event(win32con.MOUSEEVENTF_LEFTDOWN, 0, 0, 0, 0) - win32api.mouse_event(win32con.MOUSEEVENTF_LEFTUP, 0, 0, 0, 0) + if require_window_safety: + self._click_screen(x, y, "switch_to_normal") + else: + win32api.SetCursorPos((x, y)) + win32api.mouse_event(win32con.MOUSEEVENTF_LEFTDOWN, 0, 0, 0, 0) + win32api.mouse_event(win32con.MOUSEEVENTF_LEFTUP, 0, 0, 0, 0) time.sleep(sleep_time) _activate_window(self.hwnd_main) @@ -1840,7 +2945,12 @@ def read_table_text(self, hwnd, timeout: float = 2.0): seq0 = user32.GetClipboardSequenceNumber() # 清空后取基线,之后变化=本次拷贝 _activate_window(hwnd) hot_key(["ctrl", "c"]) + # Some empty grids produce no text at all. Only accept that as empty + # after the known copy-data captcha was present and cleared; any + # no-popup copy failure remains None and is retried/failed by callers. + captcha_seen = bool(self.get_ocr_hwnd()) self.input_ocr() # 处理"检测到您正在拷贝数据"验证码(无弹窗立即返回) + captcha_cleared = captcha_seen and not self.get_ocr_hwnd() deadline = time.time() + timeout data = None while time.time() < deadline: @@ -1850,6 +2960,9 @@ def read_table_text(self, hwnd, timeout: float = 2.0): break time.sleep(0.02) self._empty_clipboard() # 读完立刻清空——剪贴板只当毫秒级中转点 + if data is None and captcha_cleared: + logger.info("grid copy produced no text after known captcha cleared; verified empty grid") + return _VERIFIED_EMPTY_GRID return data def _preprocess_captcha(self, image): @@ -1874,16 +2987,15 @@ def _preprocess_captcha(self, image): pil = Image.fromarray(binary) return pil.filter(ImageFilter.SHARPEN) - def _refresh_captcha(self, captcha_static): + def _refresh_captcha(self, captcha_static, dialog): """Click the captcha image to trigger image regeneration. xiadan does NOT auto-refresh on wrong submission — without this each retry OCRs the same image and gets the same wrong answer.""" rect = win32gui.GetWindowRect(captcha_static) cx = (rect[0] + rect[2]) // 2 cy = (rect[1] + rect[3]) // 2 - win32api.SetCursorPos((cx, cy)) - win32api.mouse_event(win32con.MOUSEEVENTF_LEFTDOWN, 0, 0, 0, 0) - win32api.mouse_event(win32con.MOUSEEVENTF_LEFTUP, 0, 0, 0, 0) + self._require_owned_window_for_input(captcha_static, "captcha_refresh") + self._click_owned_popup(dialog, cx, cy, "captcha_refresh") time.sleep(short_sleep_time) def input_ocr(self): @@ -1931,6 +3043,10 @@ def input_ocr(self): logger.warning("ocr attempt=%d cannot resolve dialog from %s", attempt, hex(captcha_static)) return + if not self._window_is_owned_by_bound_process(dialog): + raise WindowSafetyError( + "captcha_dialog: 验证码弹窗不属于当前已绑定的 xiadan 进程,已停止输入" + ) edit_hwnd = 0 ok_btn = 0 @@ -1948,11 +3064,14 @@ def walker(h, _): logger.warning("ocr attempt=%d no Edit in dialog %s", attempt, hex(dialog)) return + self._require_owned_window_for_input(edit_hwnd, "captcha_edit") + if ok_btn: + self._require_owned_window_for_input(ok_btn, "captcha_confirm") # On retries (attempt > 1), force a fresh captcha image first — # xiadan doesn't auto-rotate on wrong submission, so without this # every retry OCRs the same image and gets the same wrong answer. if attempt > 1: - self._refresh_captcha(captcha_static) + self._refresh_captcha(captcha_static, dialog) ocr_png = os.path.join(work_dir, "ocr.png") ocr_proc_png = os.path.join(work_dir, "ocr_proc.png") self.capture_window(captcha_static, ocr_png) @@ -1970,9 +3089,9 @@ def walker(h, _): text = "" code = text.strip() logger.info( - "ocr attempt=%d edit=%s ok_btn=%s raw=%r code=%r", + "ocr attempt=%d edit=%s ok_btn=%s recognized=%s length=%d", attempt, hex(edit_hwnd), - hex(ok_btn) if ok_btn else None, text, code, + hex(ok_btn) if ok_btn else None, bool(code), len(code), ) if not code: time.sleep(short_sleep_time) @@ -1982,15 +3101,12 @@ def walker(h, _): # silently dropped). So: bring popup to foreground, click the Edit # center to grant focus, attach thread input, type via WM_CHAR. user32 = ctypes.windll.user32 - _activate_window(dialog) - time.sleep(short_sleep_time) er = win32gui.GetWindowRect(edit_hwnd) cx = (er[0] + er[2]) // 2 cy = (er[1] + er[3]) // 2 - win32api.SetCursorPos((cx, cy)) - win32api.mouse_event(win32con.MOUSEEVENTF_LEFTDOWN, 0, 0, 0, 0) - win32api.mouse_event(win32con.MOUSEEVENTF_LEFTUP, 0, 0, 0, 0) + self._click_owned_popup(dialog, cx, cy, "captcha_focus_edit") time.sleep(short_sleep_time) + self._require_owned_window_for_input(edit_hwnd, "captcha_type") my_tid = ctypes.windll.kernel32.GetCurrentThreadId() target_tid, _ = win32process.GetWindowThreadProcessId(edit_hwnd) attached = False @@ -2005,6 +3121,7 @@ def walker(h, _): # WM_CHAR per char — real-typing semantics, bypasses IME and # the SetText anti-bot subclass. for ch in code: + self._require_owned_window_for_input(edit_hwnd, "captcha_type") user32.SendMessageW(edit_hwnd, win32con.WM_CHAR, ord(ch), 0) time.sleep(0.02) finally: @@ -2018,21 +3135,23 @@ def walker(h, _): ) actual = buf.value logger.info( - "ocr attempt=%d wrote=%r read_back=%r match=%s", - attempt, code, actual, actual == code, + "ocr attempt=%d wrote_length=%d read_back_length=%d match=%s", + attempt, len(code), len(actual), actual == code, ) time.sleep(short_sleep_time) if ok_btn: # PostMessage:确定按钮的 handler 若再弹模态框(如"验证码错误"), # SendMessage 会同步卡死本线程(同 2026-07-13 事故根因)。 - win32api.PostMessage(ok_btn, win32con.BM_CLICK, 0, 0) + self._post_owned_button_click(ok_btn, "captcha_confirm") else: - hot_key(["enter"]) + self._send_hotkey( + ["enter"], "captcha_confirm", expected_popup=dialog + ) time.sleep(sleep_time) if not self.get_ocr_hwnd(): - logger.info("ocr accepted attempt=%d code=%r", attempt, code) + logger.info("ocr accepted attempt=%d", attempt) return - logger.info("ocr rejected attempt=%d code=%r", attempt, code) + logger.info("ocr rejected attempt=%d", attempt) logger.warning("ocr gave up after %d attempts", max_retries) def capture_window(self, hwnd, file_name): @@ -2097,6 +3216,12 @@ async def balance(self) -> dict[str, Any]: return bound_err return await asyncio.to_thread(self.get_balance) + async def list_accounts(self) -> dict[str, Any]: + bound_err = self._ensure_bound() + if bound_err: + return bound_err + return await asyncio.to_thread(self.get_account_list) + async def position(self) -> dict[str, Any]: bound_err = self._ensure_bound() if bound_err: @@ -2186,19 +3311,122 @@ async def switch_account(self, slot: Any) -> dict[str, Any]: def do_switch_account(self, slot: int): """向 xiadan 发送 Alt+N,切换多账户登录下的当前活跃资金账户。 - 盲切:新版 xiadan 的账户下拉框给每个已登录账户注册了 Alt+1..Alt+9 - 加速键(与下拉列表顺序一致)。这里只负责把窗口拉到前台并发按键, - 不核验切换结果——受控端对账户身份保持无感知,切换后由调用方用 - balance/position 做指纹核对再继续操作。""" - _activate_window(self.hwnd_main) - hot_key(["alt", str(slot)]) - # 切换会触发资金/持仓面板重载,稍等再放行后续操作。 - time.sleep(sleep_time * 2) + 每次先读取下拉列表,以槽位对应的账户文本作为目标。若目标已经是当前 + 账户,直接核验并返回资金,绝不因文本未变化把交易锁死;否则才发送 + Alt+N,并轮询到当前账户与目标账户匹配。 + """ + self._account_trading_blocked = True + listed = self.get_account_list() + listed_data = listed.get("data") if isinstance(listed, dict) else None + if not contract.is_succeed(listed) or not isinstance(listed_data, dict): + return contract.fail( + contract.CODE_READ_FAILED, CLS_READ_FAILED, + "切换前无法读取可选账户列表,未发送切换按键,已禁止买卖和撤单", + data={ + "slot": slot, + "account_verified": False, + "accounts": (listed_data or {}).get("accounts", []), + "submitted": False, + }, + ) + + accounts = listed_data.get("accounts") + target = next( + (item for item in accounts if isinstance(item, dict) and item.get("slot") == slot), + None, + ) if isinstance(accounts, list) else None + if not target or not isinstance(target.get("text"), str): + return contract.fail( + contract.CODE_INVALID_PARAMS, contract.CLS_INVALID_PARAMS, + f"slot={slot} 不在当前可切换账户列表中,未发送切换按键", + data={"slot": slot, "accounts": accounts or [], "submitted": False}, + ) + + target_text = target["text"] + target_identity = _account_identity(target_text) + same_identity_count = sum( + _account_identity(item.get("text")) == target_identity + for item in accounts if isinstance(item, dict) + ) + if not target_identity or same_identity_count != 1: + return contract.fail( + contract.CODE_READ_FAILED, CLS_READ_FAILED, + "账户列表中存在无法唯一核验的目标账户,未发送切换按键,已禁止买卖和撤单", + data={ + "slot": slot, + "target_account_text": target_text, + "accounts": accounts, + "submitted": False, + }, + ) + + previous = self._read_account_selector_text() + if not previous: + return contract.fail( + contract.CODE_READ_FAILED, CLS_READ_FAILED, + "切换前无法读取当前账户(控件 ID 0x094C),未发送切换按键,已禁止买卖和撤单", + data={"slot": slot, "account_verified": False, "submitted": False}, + ) + + if _account_selector_matches_target(previous, target_text): + current = previous + already_active = True + else: + already_active = False + self._send_hotkey(["alt", str(slot)], "switch_account") + + # 切换会触发账户列表和资金/持仓面板重载。账户控件可能先保留旧文本, + # 因此轮询至与列表中目标槽位匹配;不使用固定 HWND,确保同花顺重启后仍可定位。 + current = None + deadline = time.monotonic() + ACCOUNT_VERIFY_TIMEOUT_SECS + while time.monotonic() < deadline: + self._abort_if_stale("switch_account_verify") + current = self._read_account_selector_text() + if current and _account_selector_matches_target(current, target_text): + break + time.sleep(sleep_time) + + if not current or not _account_selector_matches_target(current, target_text): + return contract.fail( + contract.CODE_READ_FAILED, CLS_READ_FAILED, + "Alt+%s 已发送,但当前账户未匹配目标账户 %s,已禁止买卖和撤单;" + "请确认目标账户后重试" % (slot, target_text), + data={ + "slot": slot, + "account_verified": False, + "target_account_text": target_text, + "previous_account_text": previous, + "account_text": current, + "submitted": False, + }, + ) + + balance = self.get_balance() + if not contract.is_succeed(balance): + return contract.fail( + contract.CODE_READ_FAILED, CLS_READ_FAILED, + "已核验当前账户为 %s,但资金信息读取失败,已禁止买卖和撤单;请稍后重试" + % current, + data={ + "slot": slot, + "target_account_text": target_text, + "account_verified": False, + "account_text": current, + "balance": None, + "balance_error": balance.get("error") if isinstance(balance, dict) else None, + "submitted": False, + }, + ) + + self._last_account_text = current + self._account_trading_blocked = False + message = f"当前已是:{current}" if already_active else f"已切换到:{current}" return contract.ok({ "slot": slot, - "msg": ( - f"已向同花顺窗口发送 Alt+{slot}(盲切,未核验结果)。" - "后续所有查询/下单都作用于切换后的当前账户," - "请先用 balance/position 核对账户身份再继续。" - ), + "target_account_text": target_text, + "account_verified": True, + "account_text": current, + "already_active": already_active, + "balance": balance.get("data"), + "msg": message, }) diff --git a/src/trader/watchlist_watch.py b/src/trader/watchlist_watch.py index f50f794..f608e7a 100644 --- a/src/trader/watchlist_watch.py +++ b/src/trader/watchlist_watch.py @@ -10,16 +10,19 @@ from __future__ import annotations import asyncio +import inspect import logging import time +from collections import deque from datetime import datetime, timedelta -from typing import Optional +from typing import Deque, Optional from . import config logger = logging.getLogger(__name__) FRAME_TYPE = "watchlist_event" +SEND_RETRY_INTERVAL_DEFAULT = 5 # 发送失败后短间隔重试;不重复读取或操作 THS。 def _parse_hours(spec: str) -> list[int]: @@ -41,6 +44,31 @@ def next_fire(now: datetime, hours: list[int]) -> datetime: hour=hours[0], minute=0, second=0, microsecond=0) +async def _send_frame(client, frame: dict) -> bool: + """生产客户端必须显式返回 ``True`` 才算已写入当前 WebSocket。""" + send_result = client.send_frame(frame) + if inspect.isawaitable(send_result): + send_result = await send_result + return send_result is True + + +async def _flush_pending_events(client, pending: Deque[dict]) -> bool: + """按 FIFO 重发未确认写入的自选股事件,成功后才出队。""" + try: + while pending: + frame = pending[0] + if not await _send_frame(client, frame): + logger.warning("watchlist_watch 待发事件未写入(下次重试)") + return False + pending.popleft() + logger.info("watchlist_watch 推送变化:新增 %s(顶部 %d 只,seq=%s)", + frame["added"], len(frame["codes"]), frame["seq"]) + except Exception as e: + logger.warning("watchlist_watch 待发事件发送异常(下次重试):%s", e) + return False + return True + + async def watchlist_watch_task(state, client) -> None: """定点同步自选股顶部 → 变化则推 watchlist_event。exception-safe。""" backend = client.backend @@ -51,11 +79,16 @@ async def watchlist_watch_task(state, client) -> None: hours = _parse_hours(getattr(cfg, "watchlist_sync_hours", "8,12,16,20")) prev: Optional[list[str]] = None seq = 0 + pending: Deque[dict] = deque() logger.info("watchlist_watch_task 启动,定点同步整点 %s", hours) first = True while True: try: - if first: + if pending: + # 已经读取到的变化必须先按原帧/原序号送达;不在失败窗口反复 OCR, + # 也不让下一次整点读数覆盖该变化。 + await asyncio.sleep(SEND_RETRY_INTERVAL_DEFAULT) + elif first: # 启动后先等 30s(等连接/登录稳定)建立基线,也便于验证读取正常 await asyncio.sleep(30) first = False @@ -73,6 +106,10 @@ async def watchlist_watch_task(state, client) -> None: if not snap.get("enable_ths_plugin", True): logger.debug("watchlist_watch 跳过:THS 插件已禁用") continue + if pending: + # 这里只重放已排队的通知帧,不读取 THS、更不会执行交易 RPC。 + await _flush_pending_events(client, pending) + continue async with backend.win_lock: res = await backend.watchlist() @@ -100,12 +137,11 @@ async def watchlist_watch_task(state, client) -> None: "seq": seq, "ts": time.time(), } - try: - await client.send_frame(frame) - logger.info("watchlist_watch 推送变化:新增 %s(顶部 %d 只)", added, len(cur)) - prev = cur - except Exception as e: - logger.warning("watchlist_watch 发送失败(下次重试):%s", e) # 不推进基线 + # 读取成功后立即推进观察基线并排队。写入失败时帧仍保留,后续变化 + # 不会覆盖它;队列只承载通知事件,绝不触发真实交易动作。 + prev = cur + pending.append(frame) + await _flush_pending_events(client, pending) except asyncio.CancelledError: break except Exception as e: diff --git a/src/trader/ws_client.py b/src/trader/ws_client.py index 6bd1b8f..0e56f2e 100644 --- a/src/trader/ws_client.py +++ b/src/trader/ws_client.py @@ -3,6 +3,7 @@ import json import logging import ssl +import time from dataclasses import dataclass from enum import Enum from typing import Any, Callable, Optional, TYPE_CHECKING @@ -31,6 +32,42 @@ WS_ENDPOINT = "wss://mcp.guling.pro/api/trader-tunnel" WS_ENDPOINT_DEV = "ws://localhost:8000/api/trader-tunnel" +_AUDIT_REDACTED = "" +_AUDIT_SECRET_KEY_PARTS = ( + "token", "authorization", "password", "passwd", "secret", "captcha", + "verification_code", "confirmation_token", "pairing_code", "credential", + "api_key", "access_key", "private_key", "otp", "pin", +) + + +def _redact_for_audit(value: Any, key: str = "") -> Any: + """返回可写入本地诊断日志的副本,绝不保留认证或验证码明文。""" + normalized_key = str(key).lower().replace("-", "_") + if any(part in normalized_key for part in _AUDIT_SECRET_KEY_PARTS): + return _AUDIT_REDACTED + if isinstance(value, dict): + is_pair_pending = value.get("type") == "pair_pending" + redacted = {} + for child_key, child in value.items(): + child_key = str(child_key) + if is_pair_pending and child_key == "code": + redacted[child_key] = _AUDIT_REDACTED + else: + redacted[child_key] = _redact_for_audit(child, child_key) + return redacted + if isinstance(value, list): + return [_redact_for_audit(child) for child in value] + if isinstance(value, tuple): + return [_redact_for_audit(child) for child in value] + return value + + +def _audit_json(value: Any) -> str: + """稳定的单行审计表示,既能检索也不会因未知对象中断业务。""" + return json.dumps( + _redact_for_audit(value), ensure_ascii=False, sort_keys=True, default=str, + ) + def _normalize_endpoint(value: Optional[str]) -> Optional[str]: """把用户填的"域名 / IP"补全成完整 WS 连接地址。 @@ -156,6 +193,12 @@ def __init__( ) self.state = ConnectionState.UNPAIRED self.ws: Optional[Any] = None + # 每次成功建立连接递增。主动事件写入期间若连接已经切换,旧连接上的 + # send 即使返回也不能视为当前会话投递成功,交给看门狗保留原帧重放。 + self._connection_generation = 0 + # 每个 WS 连接代次只尝试推送一次账户列表。推送失败不在本地重放: + # list_accounts 是可随时调用的权威只读查询,避免断线时反复开关账户下拉框。 + self._account_event_generation = 0 self.pending_rpcs: dict[str, PendingRPC] = {} self.reconnect_delay = 1.0 self.max_reconnect_delay = 60.0 @@ -173,6 +216,59 @@ def _set_state(self, new_state: ConnectionState) -> None: if self.on_state_change: self.on_state_change(new_state) + async def _publish_accounts_after_connected(self) -> None: + """连接成功后将可选账户作为只读事件发给上游选择。 + + 账户列表事件只是连接时的提示,丢失或读取失败时上游仍须调用 + ``list_accounts``。它不会建立交易账户基线,也不会发送任何切换热键。 + """ + generation = self._connection_generation + if (self.state != ConnectionState.CONNECTED + or self._account_event_generation == generation): + return + self._account_event_generation = generation + + try: + async with self.backend.win_lock: + result = await self.backend.list_accounts() + except Exception as e: + logger.warning("连接后读取账户列表异常:%s", e, exc_info=True) + result = None + + if self.state != ConnectionState.CONNECTED or generation != self._connection_generation: + return + + data = result.get("data") if isinstance(result, dict) else {} + succeeded = isinstance(result, dict) and result.get("status") == "succeed" + frame = { + "type": "account_event", + "event": "available" if succeeded else "unavailable", + "accounts": data.get("accounts", []) if isinstance(data, dict) else [], + "current_account_text": ( + data.get("current_account_text") if isinstance(data, dict) else None + ), + "partial": bool(data.get("partial", True)) if isinstance(data, dict) else True, + "ts": time.time(), + } + if not succeeded and isinstance(result, dict): + error = result.get("error") + if isinstance(error, dict): + frame["message"] = error.get("message") + sent = await self.send_frame(frame) + if sent: + logger.info("连接后账户列表事件已发送 event=%s accounts=%d", + frame["event"], len(frame["accounts"])) + else: + logger.warning("连接后账户列表事件未写入;上游可调用 list_accounts 重试") + + def _begin_connected_account_selection(self) -> None: + """A new control connection must not inherit the prior session's choice.""" + reset = getattr(self.backend, "require_explicit_account_selection", None) + if callable(reset): + reset() + else: + logger.warning("后端不支持连接后账户核验重置;交易请求将由后端自身闸门决定") + async def run(self) -> None: """主循环:连接 → 握手 → 消息处理 → 重连""" while True: @@ -194,41 +290,65 @@ async def run(self) -> None: ssl=_SSL_CONTEXT if self.endpoint.startswith("wss://") else None, ) as ws: # type: ignore self.ws = ws - self.reconnect_delay = 1.0 - logger.info("已连接到服务器") - - # 记录握手前是否已配对——决定握手成功后的状态 - was_paired = cfg.has_paired() - result = await handshake.perform_handshake(ws, cfg) - - if not result.success: - logger.error("握手失败:%s", result.error) - self._set_state(ConnectionState.UNPAIRED) - if result.should_clear_config: - latest_cfg = config.load() - latest_cfg.agent_token = None - latest_cfg.account_name = None - latest_cfg.paired_at = None - config.save(latest_cfg) - # 握手失败(如网络、服务不可用)退避 5 秒重试 - await asyncio.sleep(5) - continue - - # pair_init 成功 → 收到 pair_pending,等 bind_ok 才 CONNECTED - # resume 成功 → 收到 welcome,直接 CONNECTED - if was_paired: - self._set_state(ConnectionState.CONNECTED) - else: - self._set_state(ConnectionState.AWAITING_BIND) - # 把 pair_pending 的 code/expires_at 推给上层(main_window) - if result.pair_pending and self.on_pair_pending: - self.on_pair_pending( - result.pair_pending.get("code"), - result.pair_pending.get("expires_at"), - ) - logger.info("握手成功,状态:%s", self.state) - - await self._main_loop(ws) + self._connection_generation += 1 + try: + # 确认令牌只对生成它的连接代次有效,重连后不能继续授权旧快照。 + dispatcher.clear_external_cancel_confirmations() + self.reconnect_delay = 1.0 + logger.info("已连接到服务器") + + # 记录握手前是否已配对——决定握手成功后的状态 + was_paired = cfg.has_paired() + result = await handshake.perform_handshake(ws, cfg) + + if not result.success: + logger.error("握手失败:%s", result.error) + self._set_state(ConnectionState.UNPAIRED) + if result.should_clear_config: + latest_cfg = config.load() + latest_cfg.agent_token = None + latest_cfg.account_name = None + latest_cfg.paired_at = None + config.save(latest_cfg) + # 握手失败(如网络、服务不可用)退避 5 秒重试 + await asyncio.sleep(5) + continue + + # pair_init 成功 → 收到 pair_pending,等 bind_ok 才 CONNECTED + # resume 成功 → 收到 welcome,直接 CONNECTED + if was_paired: + self._begin_connected_account_selection() + self._set_state(ConnectionState.CONNECTED) + await self._publish_accounts_after_connected() + else: + self._set_state(ConnectionState.AWAITING_BIND) + # 把 pair_pending 的 code/expires_at 推给上层(main_window) + if result.pair_pending and self.on_pair_pending: + self.on_pair_pending( + result.pair_pending.get("code"), + result.pair_pending.get("expires_at"), + ) + logger.info("握手成功,状态:%s", self.state) + + await self._main_loop(ws) + finally: + # 不能留下已经退出上下文的 socket:否则看门狗会把它误当作可写。 + if self.ws is ws: + self.ws = None + if self.state in ( + ConnectionState.DIALING, + ConnectionState.AWAITING_BIND, + ConnectionState.CONNECTED, + ): + self._set_state(ConnectionState.DISCONNECTED) + + # 远端正常关闭不会抛异常;仍按退避重连,避免紧循环占满 CPU。 + if self.state == ConnectionState.DISCONNECTED: + logger.info("WebSocket 已关闭,%d 秒后重连...", self.reconnect_delay) + await asyncio.sleep(self.reconnect_delay) + self.reconnect_delay = min( + self.reconnect_delay * 2, self.max_reconnect_delay + ) except asyncio.CancelledError: logger.info("WS 客户端已取消") @@ -264,7 +384,8 @@ async def _main_loop(self, ws: "ClientConnection") -> None: # type: ignore except SessionRejectedException: raise except Exception as e: - logger.error("处理帧出错:%s,原始数据:%s", e, raw_msg) + # 原始文本可能含 bind_ok 的 agent_token;解析失败时只保留长度。 + logger.exception("处理帧出错:%s(原始帧长度=%d)", e, len(raw_msg)) except asyncio.CancelledError: raise except SessionRejectedException: @@ -275,12 +396,13 @@ async def _main_loop(self, ws: "ClientConnection") -> None: # type: ignore async def _handle_frame(self, frame: dict[str, Any], origin_ws: Any = None) -> None: """处理接收到的帧。origin_ws=收到该帧的那条连接(用于回执归属校验)。""" frame_type = frame.get("type") + logger.info("[WS<-] received=%s", _audit_json(frame)) if frame_type == "pair_pending": self._set_state(ConnectionState.AWAITING_BIND) code = frame.get("code") expires_at = frame.get("expires_at") - logger.info("配对码已生成:%s", code) + logger.info("配对码已生成(已脱敏) expires_at=%s", expires_at) # 新码到来时,通知上层更新 pairing_code + expires_at + 清除 refreshing if self.on_pair_pending: self.on_pair_pending(code, expires_at) @@ -306,10 +428,12 @@ async def _handle_frame(self, frame: dict[str, Any], origin_ws: Any = None) -> N except Exception as e: logger.exception("⚠ 保存 agent_token 到 config 失败:%s", e) + self._begin_connected_account_selection() self._set_state(ConnectionState.CONNECTED) + await self._publish_accounts_after_connected() elif frame_type == "welcome": - logger.info("欢迎消息:%s", frame) + logger.info("欢迎消息已接收") self._set_state(ConnectionState.CONNECTED) elif frame_type == "reject": @@ -347,7 +471,7 @@ async def _process_call(self, frame: dict[str, Any], origin_ws: Any = None) -> N rpc_id = frame.get("id") method = frame.get("method") params = frame.get("params", {}) - logger.info("收到 RPC call:id=%s, method=%s", rpc_id, method) + logger.info("[RPC] dispatch_begin id=%s method=%s", rpc_id, method) try: # dispatcher.handle_call 已返回完整 reply 帧(type/id/ok/result|error)。 # 直接转发,不要再包一层 {ok:true, result:...}——否则外层永远 ok:true, @@ -363,26 +487,69 @@ async def _process_call(self, frame: dict[str, Any], origin_ws: Any = None) -> N _format_rpc_log(method, params, error=reply.get("error")) ) except Exception as e: + logger.exception("[RPC] dispatch_exception id=%s method=%s", rpc_id, method) reply = {"type": "reply", "id": rpc_id, "ok": False, "error": str(e)} if self.on_rpc_log: self.on_rpc_log(_format_rpc_log(method, params, error=str(e))) + logger.info( + "[WS->] reply_ready id=%s method=%s reply=%s", + rpc_id, method, _audit_json(reply), + ) if origin_ws is not None and self.ws is not origin_ws: logger.warning( "丢弃跨连接回执:id=%s method=%s(执行期间已重连,回执归属无法保证)", rpc_id, method) return - if self.ws: - await self.ws.send(json.dumps(reply, ensure_ascii=False)) - - async def send_frame(self, frame: dict[str, Any]) -> None: - """发送帧""" if not self.ws: - logger.warning("WebSocket 未连接,无法发送帧") + logger.warning("[WS->] reply_not_written id=%s method=%s(连接不存在)", rpc_id, method) return try: - await self.ws.send(json.dumps(frame, ensure_ascii=False)) + await self.ws.send(json.dumps(reply, ensure_ascii=False)) + except Exception: + logger.exception("[WS->] reply_write_failed id=%s method=%s", rpc_id, method) + raise + logger.info("[WS->] reply_written id=%s method=%s(仅本地写入成功)", rpc_id, method) + + @staticmethod + def _socket_is_closing(ws: Any) -> bool: + """兼容 websockets 版本与测试替身,判断 socket 是否已不可写。""" + state = getattr(ws, "state", None) + state_name = str(getattr(state, "name", state)).upper() + return bool(getattr(ws, "closed", False)) or state_name in ("CLOSING", "CLOSED") + + async def send_frame(self, frame: dict[str, Any]) -> bool: + """发送主动事件帧;仅当前连接完整写入时返回 ``True``。 + + 此返回值表示本地 WebSocket 写入结果,不是网关业务确认。网络在写入后 + 中断时无法证明对端是否收到,因此调用方会按同一事件/序号保守重放。 + """ + ws = self.ws + generation = self._connection_generation + logger.info("[WS->] active_frame_attempt=%s", _audit_json(frame)) + if ws is None: + logger.warning("WebSocket 未连接,无法发送帧 type=%s", frame.get("type")) + return False + + if self._socket_is_closing(ws): + logger.warning("WebSocket 已关闭,无法发送帧 type=%s", frame.get("type")) + return False + try: + await ws.send(json.dumps(frame, ensure_ascii=False)) except Exception as e: - logger.error("发送帧出错:%s", e) + logger.error("发送帧出错 type=%s:%s", frame.get("type"), e) + return False + + logger.info("[WS->] active_frame_written type=%s(仅本地写入成功)", frame.get("type")) + + # send() 可在 await 时让出控制权;若此期间 run() 已断开并换了连接, + # 旧会话上的写入不能推进本地事件基线。 + if self.ws is not ws or self._connection_generation != generation: + logger.warning("发送帧期间连接已切换,保留事件重试 type=%s", frame.get("type")) + return False + if self._socket_is_closing(ws): + logger.warning("发送帧后连接已关闭,保留事件重试 type=%s", frame.get("type")) + return False + return True def is_connected(self) -> bool: """是否已连接""" diff --git a/tests/test_app_data_dir.py b/tests/test_app_data_dir.py index 9d14203..e372125 100644 --- a/tests/test_app_data_dir.py +++ b/tests/test_app_data_dir.py @@ -40,6 +40,27 @@ def test_non_frozen_falls_back(monkeypatch, tmp_path): assert base.is_dir() +def test_external_cancel_confirmation_defaults_safely_and_persists(monkeypatch, tmp_path): + """桌面端的直接撤单偏好必须落盘,未知旧值始终回退到二次确认。""" + config = _reload_config() + config_path = tmp_path / "config.json" + monkeypatch.setattr(config, "_get_config_path", lambda: config_path) + + assert config.load().external_cancel_confirmation == "two_step" + + config.save(config.TraderConfig( + device_id="test-device", + external_cancel_confirmation="direct", + )) + assert config.load().external_cancel_confirmation == "direct" + + config_path.write_text(json.dumps({ + "device_id": "test-device", + "external_cancel_confirmation": "unexpected", + }), encoding="utf-8") + assert config.load().external_cancel_confirmation == "two_step" + + diff --git a/tests/test_cancel_confirmation.py b/tests/test_cancel_confirmation.py new file mode 100644 index 0000000..119ddc1 --- /dev/null +++ b/tests/test_cancel_confirmation.py @@ -0,0 +1,101 @@ +"""撤单点击后的 F3 回执判定,不触碰 Win32。""" + +import pytest + +from trader import contract +from trader.ths import win as w +from trader.ths.win import WinThsBackend, _VERIFIED_EMPTY_GRID, _cancel_f3_outcome + + +HEADER = "委托编号\t证券代码\t委托状态\t委托数量\t\r\n" + + +def _table(*rows): + return HEADER + "".join("\t".join(row) + "\t\r\n" for row in rows) + + +@pytest.mark.parametrize("status", ["已撤", "部撤", "撤单成功"]) +def test_f3_explicit_cancel_is_confirmed(status): + assert _cancel_f3_outcome(_table(("A-1", "600000", status, "100")), "A-1") == ( + "canceled", "已撤" + ) + + +@pytest.mark.parametrize("status,expected", [ + ("已报", "已报"), ("部成", "部成"), ("已成", "已成"), + ("废单", "废单"), ("柜台处理中", "未知"), ("撤单中", "未知"), +]) +def test_f3_non_cancel_state_is_never_confirmed(status, expected): + assert _cancel_f3_outcome(_table(("A-1", "600000", status, "100")), "A-1") == ( + "pending", expected + ) + + +def test_f3_missing_target_is_unresolved_not_cancelled(): + assert _cancel_f3_outcome(_table(("B-2", "600000", "已报", "100")), "A-1") == ( + "unresolved", None + ) + + +def test_f3_empty_or_verified_empty_grid_is_unresolved_not_cancelled(): + empty = HEADER + "\t\t\t\t\r\n" + assert _cancel_f3_outcome(empty, "A-1") == ("unresolved", None) + assert _cancel_f3_outcome(_VERIFIED_EMPTY_GRID, "A-1") == ("unresolved", None) + + +@pytest.mark.parametrize("data", [ + None, + "证券代码\t委托状态\t\r\n600000\t已撤\t\r\n", + "委托编号\t证券代码\t\r\nA-1\t600000\t\r\n", +]) +def test_f3_unreadable_or_wrong_table_is_not_cancelled(data): + assert _cancel_f3_outcome(data, "A-1") == ("unreadable", None) + + +def test_f3_accepts_contract_number_and_remark_aliases(): + data = "合同编号\t证券代码\t备注\t\r\nA-1\t600000\t已撤\t\r\n" + assert _cancel_f3_outcome(data, "A-1") == ("canceled", "已撤") + + +def test_f3_matches_either_id_alias_when_both_columns_exist(): + data = "委托编号\t合同编号\t委托状态\t\r\nE-1\tC-1\t已撤\t\r\n" + assert _cancel_f3_outcome(data, "C-1") == ("canceled", "已撤") + + +def test_post_submit_f3_confirmation_returns_success_without_second_cancel(monkeypatch): + backend = WinThsBackend() + calls = [] + monkeypatch.setattr(backend, "refresh", lambda **kwargs: calls.append("refresh")) + monkeypatch.setattr(backend, "get_right_hwnd", lambda: 10) + monkeypatch.setattr(backend, "_find_grid", lambda hwnd: 20) + monkeypatch.setattr( + backend, "read_table_text", lambda ctrl: _table(("A-1", "600000", "已撤", "100")), + ) + + result = backend._verify_cancel_after_submit("A-1") + + assert contract.is_succeed(result) + assert result["data"] == { + "entrust_no": "A-1", + "submitted": True, + "cancel_state": "已撤", + "cancel_verified": True, + } + assert calls == ["refresh"] + + +def test_post_submit_missing_target_returns_unconfirmed(monkeypatch): + backend = WinThsBackend() + monkeypatch.setattr(backend, "refresh", lambda **kwargs: None) + monkeypatch.setattr(backend, "get_right_hwnd", lambda: 10) + monkeypatch.setattr(backend, "_find_grid", lambda hwnd: 20) + monkeypatch.setattr(backend, "read_table_text", lambda ctrl: HEADER) + monkeypatch.setattr(w, "CANCEL_VERIFY_TIMEOUT_SECS", 0.001) + monkeypatch.setattr(w.time, "sleep", lambda _: None) + + result = backend._verify_cancel_after_submit("A-1") + + assert result["code"] == "submitted_unconfirmed" + assert result["data"]["submitted"] is True + assert result["data"]["cancel_verified"] is False + assert result["data"]["f3_verification"] == "unresolved" diff --git a/tests/test_contract_docs_sync.py b/tests/test_contract_docs_sync.py index 610e9f1..0bc64e8 100644 --- a/tests/test_contract_docs_sync.py +++ b/tests/test_contract_docs_sync.py @@ -8,7 +8,7 @@ import pytest from trader import contract -from trader.dispatcher import FALLBACK_TOOLS_SCHEMA, METHOD_WHITELIST +from trader.dispatcher import CLIENT_ORDER_ID_PATTERN, FALLBACK_TOOLS_SCHEMA, METHOD_WHITELIST from trader.ths import rows ROOT = Path(__file__).resolve().parents[1] @@ -58,11 +58,21 @@ def test_query_order_is_exposed(): assert "query_order" in {t["name"] for t in SCHEMA["tools"]} -@pytest.mark.parametrize("name", ["buy", "sell", "cancel"]) +@pytest.mark.parametrize("name", ["buy", "sell", "cancel", "confirm_external_cancel"]) def test_order_tools_document_idempotency(name): tool = next(t for t in SCHEMA["tools"] if t["name"] == name) - desc = tool["inputSchema"]["properties"]["client_order_id"]["description"] + prop = tool["inputSchema"]["properties"]["client_order_id"] + desc = prop["description"] assert "幂等" in desc and "重发" in desc + assert "client_order_id" in tool["inputSchema"]["required"] + assert prop["pattern"] == CLIENT_ORDER_ID_PATTERN + assert "UUID v7" in desc + + +def test_protocol_pins_canonical_ids_and_one_shot_auto_query(): + assert "gl-<小写 UUID v7>" in PROTOCOL + assert "data.auto_query" in PROTOCOL + assert "绝不自动重发下单" in PROTOCOL def test_protocol_states_failed_is_not_not_submitted(): diff --git a/tests/test_contract_envelope.py b/tests/test_contract_envelope.py index db848b4..9cad73a 100644 --- a/tests/test_contract_envelope.py +++ b/tests/test_contract_envelope.py @@ -48,6 +48,11 @@ def test_failed_does_not_mean_not_submitted(): assert contract.CLS_UNKNOWN_OUTCOME in contract.NON_RETRYABLE_CLASSES +def test_confirmation_required_is_not_an_automatic_retry_signal(): + """二次确认必须由用户显式授权,通用重试器不能自行跨过这一步。""" + assert contract.CLS_CONFIRMATION_REQUIRED in contract.NON_RETRYABLE_CLASSES + + # --- C2 两层错误分类 --------------------------------------------------------- @pytest.mark.parametrize("text,expected", [ diff --git a/tests/test_dialogs.py b/tests/test_dialogs.py index 5642aba..b283341 100644 --- a/tests/test_dialogs.py +++ b/tests/test_dialogs.py @@ -1,12 +1,18 @@ """DialogSentry 纯决策逻辑回归(不触碰 Win32,任意平台可跑)。 -结构化处置的决策面只有三块:按钮标签归一化、肯定按钮选择、合同编号提取。 -这三块错了,真机上点错按钮/丢回执;win32 枚举与点击留给真机联调。 +覆盖按钮标签归一化、弹窗分类、未知弹窗记录和合同编号提取。 +Win32 枚举与真实客户端行为留给 Windows 真机联调。 """ from trader.ths.dialogs import ( + DialogFingerprint, + DialogSentry, PumpResult, + classify_dialog, choose_button, extract_entrust_no, + is_known_captcha, + is_known_cancel_confirmation, + is_known_confirmation, normalize_button_label, ) @@ -42,17 +48,199 @@ def test_choose_ok_dialog(): assert choose_button(["确定"]) == "确定" -def test_choose_single_button_whatever_label(): - # 信息框的唯一按钮无论叫什么都等价于关闭 +def test_choose_single_button_keeps_legacy_handling(): + # 原逻辑:信息框唯一按钮无论叫什么都等价于关闭。 assert choose_button(["知道了"]) == "知道了" def test_choose_never_picks_negative_among_many(): - # 多按钮且无肯定项 → None(走 Enter/WM_CLOSE 兜底),绝不主动点「取消」 + # 多按钮且无肯定项 → None,绝不主动点「取消」 assert choose_button(["取消", "重试"]) is None assert choose_button([]) is None +# ---- 弹窗安全分类 ---------------------------------------------------------- + +def _dialog(*, title="", text="", buttons=None, has_edit=False): + return DialogFingerprint( + hwnd=100, + title=title, + text=text, + buttons=buttons or {}, + has_edit=has_edit, + ) + + +def test_known_copy_captcha_is_the_only_edit_dialog_allowed_to_use_ocr(): + dlg = _dialog(text="检测到您正在拷贝数据,请输入验证码", has_edit=True) + assert is_known_captcha(dlg) + assert classify_dialog(dlg) == "known_captcha" + + +def test_unknown_edit_dialog_is_recorded_as_unknown(): + # 原逻辑仍会 OCR;分类只用于反馈未知类型。 + dlg = _dialog(title="安全验证", text="请输入交易密码", has_edit=True) + assert not is_known_captcha(dlg) + assert classify_dialog(dlg) == "unknown_edit" + + +def test_unique_button_is_unknown_but_still_handled(): + dlg = _dialog(title="提示", text="风险提示", buttons={"确定": 101}) + assert not is_known_confirmation(dlg) + assert classify_dialog(dlg) == "unknown" + + +def test_known_confirmation_requires_affirmative_and_negative_buttons(): + assert is_known_confirmation(_dialog(buttons={"是": 101, "否": 102})) + assert is_known_confirmation(_dialog(buttons={"确定": 101, "取消": 102})) + assert not is_known_confirmation(_dialog(buttons={"确定": 101})) + + +def test_cancel_confirmation_with_reprice_edit_is_not_sent_to_ocr(monkeypatch): + import trader.ths.dialogs as dialogs_module + + calls = [] + + class Api: + def PostMessage(self, *args): + calls.append(args) + + class Con: + BM_CLICK = 1 + + class Backend: + ocr_called = False + + def input_ocr(self): + self.ocr_called = True + + monkeypatch.setattr(dialogs_module, "win32api", Api(), raising=False) + monkeypatch.setattr(dialogs_module, "win32con", Con(), raising=False) + dlg = _dialog( + text="您是否确定以上撤销买入委托?\n撤单确认\n(撤单并以新的价格委托)", + buttons={"否": 102, "是": 101}, + has_edit=True, + ) + backend = Backend() + + assert is_known_cancel_confirmation(dlg) + assert classify_dialog(dlg) == "known_cancel_confirmation" + assert DialogSentry(backend).dismiss(dlg) == "click:是" + assert backend.ocr_called is False + assert calls == [(101, 1, 0, 0)] + + +def test_cancel_confirmation_without_affirmative_button_stays_pending(): + class Backend: + ocr_called = False + + def input_ocr(self): + self.ocr_called = True + + dlg = _dialog( + text="您是否确定以上撤销买入委托?\n撤单确认", + buttons={"取消": 102}, + has_edit=True, + ) + backend = Backend() + + assert DialogSentry(backend).dismiss(dlg) == "pending:cancel_confirmation_no_affirmative" + assert backend.ocr_called is False + + +def test_dismiss_unknown_edit_keeps_legacy_ocr(): + class Backend: + def input_ocr(self): + self.called = True + + backend = Backend() + action = DialogSentry(backend).dismiss( + _dialog(title="身份验证", text="请输入动态口令", has_edit=True) + ) + assert action == "input_ocr" + assert backend.called is True + + +def test_dismiss_unknown_dialog_keeps_legacy_enter_fallback(monkeypatch): + import trader.ths.dialogs as dialogs_module + + calls = [] + + class Api: + def PostMessage(self, *args): + calls.append(args) + + class Con: + BM_CLICK = 1 + WM_KEYDOWN = 2 + WM_KEYUP = 3 + WM_CLOSE = 4 + VK_RETURN = 13 + + monkeypatch.setattr(dialogs_module, "win32api", Api(), raising=False) + monkeypatch.setattr(dialogs_module, "win32con", Con(), raising=False) + class Gui: + def IsWindow(self, hwnd): + return False + def IsWindowVisible(self, hwnd): + return False + + monkeypatch.setattr(dialogs_module, "win32gui", Gui(), raising=False) + action = DialogSentry(object()).dismiss( + _dialog(title="未知提示", text="请人工判断", buttons={"继续": 101}) + ) + assert action == "click:继续" + assert calls == [(101, 1, 0, 0)] + + +def test_pump_records_unknown_dialog_and_continues(monkeypatch): + import trader.ths.dialogs as dialogs_module + + dlg = _dialog(title="未知提示", text="请人工判断", buttons={"继续": 101}) + sentry = DialogSentry(object()) + monkeypatch.setattr( + dialogs_module, + "win32api", + type("Api", (), {"PostMessage": lambda self, *args: None})(), + raising=False, + ) + monkeypatch.setattr( + dialogs_module, + "win32con", + type("Con", (), {"BM_CLICK": 1})(), + raising=False, + ) + monkeypatch.setattr(sentry, "scan", lambda: [dlg]) + + result = sentry.pump(budget=1.0, settle=0.0) + + assert result.dialogs + assert all(item["unknown"] is True for item in result.dialogs) + assert all(item["reason"] == "unknown" for item in result.dialogs) + assert result.unknown_dialogs == result.dialogs + + +def test_known_confirmation_still_clicks_affirmative_button(monkeypatch): + import trader.ths.dialogs as dialogs_module + + calls = [] + + class Api: + def PostMessage(self, *args): + calls.append(args) + + class Con: + BM_CLICK = 1 + + monkeypatch.setattr(dialogs_module, "win32api", Api(), raising=False) + monkeypatch.setattr(dialogs_module, "win32con", Con(), raising=False) + action = DialogSentry(object()).dismiss( + _dialog(title="委托确认", buttons={"否": 102, "是": 101}) + ) + assert action == "click:是" + assert calls == [(101, 1, 0, 0)] + + # ---- 合同编号提取 ---------------------------------------------------------- def test_extract_entrust_no_variants(): @@ -75,6 +263,18 @@ def test_attach_to_adds_dialogs_only_when_present(): receipt2 = r2.attach_to({"code": 0}) assert receipt2["dialogs"][0]["action"] == "click:确定" + pending = PumpResult( + dialogs=[{"title": "身份验证", "text": "请输入动态口令", + "action": "input_ocr", "unknown": True, + "reason": "unknown_edit"}], + unknown_dialogs=[{"title": "身份验证", "text": "请输入动态口令", + "action": "input_ocr", "unknown": True, + "reason": "unknown_edit"}], + ) + pending_receipt = pending.attach_to({"code": 2}) + assert pending_receipt["unknown_dialog"] is True + assert pending_receipt["unknown_dialogs"][0]["reason"] == "unknown_edit" + def test_texts_falls_back_to_title(): r = PumpResult(dialogs=[ diff --git a/tests/test_dispatcher_envelope.py b/tests/test_dispatcher_envelope.py index 549fe77..9d6698e 100644 --- a/tests/test_dispatcher_envelope.py +++ b/tests/test_dispatcher_envelope.py @@ -14,6 +14,20 @@ from trader import contract, dispatcher +COID = "gl-0198f6a1-0001-7000-8000-000000000001" + + +class FakeLedger: + def reserve(self, client_order_id, method, params): + return "new", None + + def complete(self, client_order_id, receipt, entrust_no=None): + pass + + def release(self, client_order_id): + pass + + class FakeBackend: """按方法名返回预置 result dict 的假后端。""" @@ -22,6 +36,9 @@ def __init__(self, result): self.calls = [] self.win_lock = asyncio.Lock() self.agent_entrust_nos: set[str] = set() + self.ledger = FakeLedger() + self.account_trading_blocked = False + self.account_checks = 0 async def _run(self, name, *args): self.calls.append((name, args)) @@ -53,6 +70,16 @@ async def sell(self, stock_no, amount, price, client_order_id): async def cancel(self, entrust_no): return await self._run("cancel", entrust_no) + async def verify_account_for_trade(self): + self.account_checks += 1 + if self.account_trading_blocked: + return contract.fail( + contract.CODE_READ_FAILED, contract.CLS_READ_FAILED, + "当前账户身份尚未核验,已禁止买卖和撤单", + data={"account_verified": False, "submitted": False}, + ) + return contract.ok({"account_verified": True, "account_text": "测试账户"}) + async def switch_account(self, slot): return await self._run("switch_account", slot) @@ -82,7 +109,9 @@ def test_success_is_single_layer_with_id_echoed(): def test_submitted_unconfirmed_is_not_unknown_error(): """已提交未确认必须给出明确文案 + 透传信封,绝不能塌成'未知错误'。""" frame = {"type": "call", "id": "id2", "method": "sell", - "params": {"stock_no": "300459", "amount": 100}} + "params": {"stock_no": "300459", "amount": 100, + "order_type": "FIVE_LEVEL_IOC", + "client_order_id": COID}} result = contract.submitted_unconfirmed("已提交但未能在委托表中匹配到对应订单", data={"submitted": True}) reply, _ = _call(frame, result) @@ -121,7 +150,9 @@ def test_non_contract_shape_is_rejected_loudly(): def test_broker_rejection_carries_class_and_raw_text(): """柜台拒单:class 可机器分流,broker_msg 保留原文(C2 两层分类)。""" frame = {"type": "call", "id": "id5", "method": "buy", - "params": {"stock_no": "600000", "amount": 100}} + "params": {"stock_no": "600000", "amount": 100, + "order_type": "LIMIT", "price": 8.1, + "client_order_id": COID}} reply, _ = _call(frame, contract.broker_rejected("可用资金不足,无法委托")) assert reply["ok"] is False assert reply["result"]["error"]["class"] == "insufficient_funds" @@ -138,7 +169,9 @@ def test_method_not_whitelisted(): def test_backend_exception_is_caught(): frame = {"type": "call", "id": "id7", "method": "sell", - "params": {"stock_no": "300459", "amount": 100}} + "params": {"stock_no": "300459", "amount": 100, + "order_type": "LIMIT", "price": 8.1, + "client_order_id": COID}} reply, _ = _call(frame, RuntimeError("窗口未找到")) assert reply["ok"] is False assert "窗口未找到" in reply["error"] @@ -164,13 +197,15 @@ def test_settlement_default_date_range(): def test_buy_params_forwarded_to_backend(): - """确认 price 透传——市价单(price 缺省→None)不会被 dispatcher 篡改。""" + """显式 IOC 请求不带 price,后端仍收到 None 进入市价路径。""" frame = {"type": "call", "id": "id8", "method": "sell", - "params": {"stock_no": "300459", "amount": 100}} + "params": {"stock_no": "300459", "amount": 100, + "order_type": "FIVE_LEVEL_IOC", + "client_order_id": COID}} _, backend = _call(frame, {"code": 0}) name, args = backend.calls[-1] assert name == "sell" - assert args == ("300459", 100, None) # price 缺省 → None(市价语义) + assert args == ("300459", 100, None) # 显式 IOC → None(市价路径) def test_tools_list_returns_correct_schema(): @@ -213,6 +248,30 @@ def test_switch_account_forwards_slot_and_single_layer_reply(): assert backend.calls == [("switch_account", (2,))] +def test_buy_sell_blocked_after_account_verification_failure(): + """账户切换核验失败后,dispatcher 不得把买卖请求送入后端。""" + backend = FakeBackend(contract.ok({"submitted": True})) + backend.account_trading_blocked = True + frame = { + "type": "call", "id": "blocked-1", "method": "buy", + "params": { + "stock_no": "600000", "amount": 100, + "order_type": "LIMIT", "price": 8.1, + "client_order_id": COID, + }, + } + + reply = asyncio.run(dispatcher.handle_call(frame, backend)) + + assert reply["ok"] is False + assert reply["result"]["code"] == "read_failed" + assert reply["result"]["data"] == { + "account_verified": False, "submitted": False, + } + assert backend.calls == [] + assert backend.account_checks == 1 + + def test_fallback_schema_matches_file_schema(): """验证内置的 FALLBACK_TOOLS_SCHEMA 与 docs/tools_schema.json 完全一致,防止三源漂移""" import json diff --git a/tests/test_dispatcher_lock.py b/tests/test_dispatcher_lock.py index 1258ec4..cbf11f2 100644 --- a/tests/test_dispatcher_lock.py +++ b/tests/test_dispatcher_lock.py @@ -4,12 +4,27 @@ from trader import contract, dispatcher +COID = "gl-0198f6a1-0002-7000-8000-000000000002" + + +class FakeLedger: + def reserve(self, client_order_id, method, params): + return "new", None + + def complete(self, client_order_id, receipt, entrust_no=None): + pass + + def release(self, client_order_id): + pass + + class LockFakeBackend: def __init__(self): self.win_lock = asyncio.Lock() self.agent_entrust_nos: set[str] = set() self.concurrent = 0 self.max_concurrent = 0 + self.ledger = FakeLedger() async def _hold(self, result): # 记录临界区并发度,验证锁真的串行化。 @@ -22,6 +37,9 @@ async def _hold(self, result): async def orders_active(self): return await self._hold(contract.ok([])) + async def verify_account_for_trade(self): + return contract.ok({"account_verified": True, "account_text": "测试账户"}) + async def buy(self, stock_no, amount, price, client_order_id): return await self._hold(contract.ok({"entrust_no": "777"})) @@ -43,7 +61,9 @@ async def drive(): def test_buy_registers_entrust_no(): backend = LockFakeBackend() frame = {"type": "call", "id": "b", "method": "buy", - "params": {"stock_no": "600519", "amount": 100, "price": 1700.0}} + "params": {"stock_no": "600519", "amount": 100, "price": 1700.0, + "order_type": "LIMIT", + "client_order_id": COID}} reply = asyncio.run(dispatcher.handle_call(frame, backend)) assert reply["ok"] is True assert "777" in backend.agent_entrust_nos diff --git a/tests/test_dispatcher_timeout.py b/tests/test_dispatcher_timeout.py index 337c286..f087375 100644 --- a/tests/test_dispatcher_timeout.py +++ b/tests/test_dispatcher_timeout.py @@ -11,6 +11,20 @@ from trader import dispatcher +COID = "gl-0198f6a1-0003-7000-8000-000000000003" + + +class FakeLedger: + def reserve(self, client_order_id, method, params): + return "new", None + + def complete(self, client_order_id, receipt, entrust_no=None): + pass + + def release(self, client_order_id): + pass + + class HangingBackend: """sell 永远不返回(模拟弹窗卡死);查询正常。""" @@ -19,10 +33,15 @@ def __init__(self): self.agent_entrust_nos: set[str] = set() self.degraded = False self.cleanup_calls = 0 + self.ledger = FakeLedger() async def sell(self, *a, **k): await asyncio.sleep(3600) + async def verify_account_for_trade(self): + from trader import contract + return contract.ok({"account_verified": True, "account_text": "测试账户"}) + async def orders_active(self): from trader import contract return contract.ok([]) @@ -40,7 +59,9 @@ def _call(backend, method, params=None, **frame_extra): def test_order_timeout_returns_unknown_not_bare_error(monkeypatch): monkeypatch.setattr(dispatcher, "CALL_TIMEOUT_SECS", 0.05) backend = HangingBackend() - reply = _call(backend, "sell", {"stock_no": "300458", "amount": 500}) + reply = _call(backend, "sell", {"stock_no": "300458", "amount": 500, + "order_type": "FIVE_LEVEL_IOC", + "client_order_id": COID}) assert reply["ok"] is False assert reply["result"]["code"] == "submitted_unconfirmed" assert reply["result"]["error"]["class"] == "unknown_outcome" @@ -69,7 +90,9 @@ def test_lock_busy_instead_of_starvation(monkeypatch): async def drive(): await backend.win_lock.acquire() # 模拟持锁方被拖住 frame = {"type": "call", "id": "t2", "method": "buy", - "params": {"stock_no": "600000", "amount": 100}} + "params": {"stock_no": "600000", "amount": 100, + "order_type": "FIVE_LEVEL_IOC", + "client_order_id": COID}} return await dispatcher.handle_call(frame, backend) reply = asyncio.run(drive()) diff --git a/tests/test_grid_query_guard.py b/tests/test_grid_query_guard.py index 47ed7bd..d408924 100644 --- a/tests/test_grid_query_guard.py +++ b/tests/test_grid_query_guard.py @@ -9,7 +9,7 @@ import pytest from trader.ths import win as w -from trader.ths.win import WinThsBackend +from trader.ths.win import WinThsBackend, _VERIFIED_EMPTY_GRID POSITION_TABLE = ( "操作\t证券代码\t证券名称\t股票余额\t可用余额\t冻结数量\t参考成本价\t市价\t\r\n" @@ -27,6 +27,18 @@ "证券代码\t操作\t委托数量\t委托价格\t成交数量\t成交均价\t合同编号\t备注\t\r\n" "\t\t\t\t\t\t\t\t\r\n" ) +EMPTY_ACTIVE_TABLE_WITH_DISPLAY_ROW = ( + # 实机皮肤可能在空表中残留委托时间、成交价格等展示值;核心订单字段全空/零。 + "委托时间\t证券代码\t证券名称\t买卖\t委托状态\t委托数量\t成交数量\t委托价格\t" + "成交价格\t已撤数量\t合同编号\t交易市场\t\r\n" + "11:26:48\t\t\t\t\t0.00\t0.00\t0.0000\t0.0000\t0\t\t\t\r\n" + "\t\t\t\t\t0.00\t0.00\t0.0000\t0.0000\t0\t\t\t\r\n" +) +ACTIVE_TABLE_MISSING_CONTRACT = ( + "委托时间\t证券代码\t证券名称\t买卖\t委托状态\t委托数量\t成交数量\t委托价格\t" + "成交价格\t已撤数量\t合同编号\t交易市场\t\r\n" + "11:26:48\t518800\t示例基金\t买入\t已报\t100\t0\t5.000\t0\t0\t\t沪A\t\r\n" +) def _backend(monkeypatch, texts): @@ -67,6 +79,54 @@ def test_empty_table_is_still_success(monkeypatch): assert r["data"] == [] +def test_empty_order_display_row_is_not_mistaken_for_missing_contract(monkeypatch): + """空委托表即使保留时间展示行,限价单基线也应安全视为无历史订单。""" + b = _backend(monkeypatch, [EMPTY_ACTIVE_TABLE_WITH_DISPLAY_ROW]) + + baseline, error = b._read_limit_order_baseline() + + assert error is None + assert baseline == set() + assert b._last_grid_columns["active_orders"] == ( + "委托时间", "证券代码", "证券名称", "买卖", "委托状态", "委托数量", + "成交数量", "委托价格", "成交价格", "已撤数量", "合同编号", "交易市场", + ) + + +def test_limit_baseline_error_logs_clipboard_and_normalized_rows(monkeypatch, caplog): + """合同号异常时,trader.log 必须能还原本次复制原文与最终订单行。""" + b = _backend(monkeypatch, [ACTIVE_TABLE_MISSING_CONTRACT]) + + baseline, error = b._read_limit_order_baseline() + + assert baseline is None + assert error["code"] == "table_mismatch" + rendered = caplog.text + assert "[LIMIT_BASELINE_DEBUG]" in rendered + assert "clipboard_text" in rendered + assert "normalized_rows" in rendered + assert "missing_contract_row_indexes" in rendered + assert "518800" in rendered + + +def test_no_header_empty_table_is_success_only_with_verified_marker(monkeypatch): + """验证码已处理、空表却没输出表头时,才能明确返回无挂单。""" + b = _backend(monkeypatch, [_VERIFIED_EMPTY_GRID]) + r = b.get_active_orders() + assert r["status"] == "succeed" + assert r["data"] == [] + assert "active_orders" in b._last_grid_verified_empty + assert b._last_grid_columns.get("active_orders") is None + + +def test_plain_clipboard_failure_is_not_empty_table(monkeypatch): + """普通 None 没有经过验证码闭环,必须保持读取失败。""" + b = _backend(monkeypatch, [None] * WinThsBackend._GRID_ATTEMPTS) + r = b.get_active_orders() + assert r["status"] == "failed" + assert r["code"] == "read_failed" + + @pytest.mark.parametrize("method,wrong", [ ("get_position", FILLED_TABLE), ("get_active_orders", FILLED_TABLE), # 最险:错表会被读成「无挂单」 @@ -91,6 +151,17 @@ def test_retry_recovers_when_page_finally_switches(monkeypatch): assert r["data"][0]["entrust_no"] == "123456" +def test_active_orders_uses_f1_f8_hotkeys(monkeypatch): + b = _backend(monkeypatch, [ACTIVE_TABLE]) + keys = [] + monkeypatch.setattr(w, "hot_key", lambda value: keys.append(value)) + + r = b.get_active_orders() + + assert r["status"] == "succeed" + assert keys == [["F1"], ["F8"]] + + def test_clipboard_failure_keeps_old_message(monkeypatch): """抓不到文本(验证码/拷贝没落定)仍是原来的读取失败语义,不误报错表。""" b = _backend(monkeypatch, []) diff --git a/tests/test_handshake_logging.py b/tests/test_handshake_logging.py new file mode 100644 index 0000000..1199172 --- /dev/null +++ b/tests/test_handshake_logging.py @@ -0,0 +1,21 @@ +"""握手日志不能泄露一次性配对码或长期凭证。""" +from trader.handshake import _redact_response_for_log + + +def test_pair_pending_log_redacts_pairing_code(): + result = _redact_response_for_log({ + "type": "pair_pending", "code": "123456", "expires_at": 123, + }) + + assert result == { + "type": "pair_pending", "code": "", "expires_at": 123, + } + + +def test_handshake_log_redacts_token_fields(): + result = _redact_response_for_log({ + "type": "welcome", "agent_token": "secret", "session_id": "session-1", + }) + + assert result["agent_token"] == "" + assert result["session_id"] == "session-1" diff --git a/tests/test_idempotency.py b/tests/test_idempotency.py index aea403c..7e94089 100644 --- a/tests/test_idempotency.py +++ b/tests/test_idempotency.py @@ -9,6 +9,7 @@ import pytest from trader import contract, dispatcher +from trader.config import EXTERNAL_CANCEL_CONFIRMATION_DIRECT, TraderConfig from trader.order_ledger import LedgerUnavailable, OrderLedger @@ -17,36 +18,74 @@ def ledger(tmp_path): return OrderLedger(tmp_path / "orders.db") -BUY_PARAMS = {"stock_no": "600000", "amount": 100, "price": 8.1} +BUY_PARAMS = { + "stock_no": "600000", + "amount": 100, + "order_type": "LIMIT", + "price": 8.1, +} +EXTERNAL_ORDER = { + "entrust_no": "777", + "证券代码": "600000", + "方向": "买入", + "委托价": 8.1, + "委托数量": 100, + "已成数量": 0, + "状态": "已报", +} + + +def coid(sequence: int) -> str: + """符合新协议的测试 ID;仅 dispatcher 对外入口强制该格式。""" + return f"gl-0198f6a1-{sequence:04x}-7000-8000-{sequence:012x}" + + +def _register_agent_order(ledger, sequence=90, entrust_no="777"): + """登记原买卖单,模拟本系统此前成功下出的委托。""" + order_id = coid(sequence) + ledger.reserve(order_id, "buy", BUY_PARAMS) + ledger.complete(order_id, contract.ok({"entrust_no": entrust_no}), entrust_no) + return order_id # --- 台账本身 --------------------------------------------------------------- def test_reserve_then_duplicate(ledger): - assert ledger.reserve("gl-1", "buy", BUY_PARAMS) == ("new", None) - verdict, record = ledger.reserve("gl-1", "buy", BUY_PARAMS) + assert ledger.reserve(coid(1), "buy", BUY_PARAMS) == ("new", None) + verdict, record = ledger.reserve(coid(1), "buy", BUY_PARAMS) assert verdict == "duplicate" assert record["state"] == "submitting" def test_same_id_different_params_is_conflict(ledger): - ledger.reserve("gl-1", "buy", BUY_PARAMS) - verdict, _ = ledger.reserve("gl-1", "buy", {**BUY_PARAMS, "amount": 200}) + ledger.reserve(coid(1), "buy", BUY_PARAMS) + verdict, _ = ledger.reserve(coid(1), "buy", {**BUY_PARAMS, "amount": 200}) assert verdict == "conflict", "同 id 换参数必须拒绝,不能静默返回首次回执" +def test_same_id_different_order_type_is_conflict(ledger): + ledger.reserve(coid(1), "buy", BUY_PARAMS) + verdict, _ = ledger.reserve( + coid(1), + "buy", + {"stock_no": "600000", "amount": 100, + "order_type": "FIVE_LEVEL_IOC"}, + ) + assert verdict == "conflict", "同 id 切换 LIMIT/FIVE_LEVEL_IOC 必须拒绝" + + def test_complete_and_entrust_join(ledger): - ledger.reserve("gl-1", "buy", BUY_PARAMS) - ledger.complete("gl-1", contract.ok({"entrust_no": "777"}), "777") - assert ledger.get("gl-1")["state"] == "done" - assert ledger.coid_by_entrust() == {"777": "gl-1"} + ledger.reserve(coid(1), "buy", BUY_PARAMS) + ledger.complete(coid(1), contract.ok({"entrust_no": "777"}), "777") + assert ledger.get(coid(1))["state"] == "done" + assert ledger.coid_by_entrust() == {"777": coid(1)} def test_survives_reopen(tmp_path): """落盘:受控端重启后幂等仍然成立(否则重发=重复下单)。""" path = tmp_path / "orders.db" - OrderLedger(path).reserve("gl-1", "buy", BUY_PARAMS) - verdict, _ = OrderLedger(path).reserve("gl-1", "buy", BUY_PARAMS) + OrderLedger(path).reserve(coid(1), "buy", BUY_PARAMS) + verdict, _ = OrderLedger(path).reserve(coid(1), "buy", BUY_PARAMS) assert verdict == "duplicate" @@ -54,7 +93,7 @@ def test_corrupt_ledger_raises_not_silently_degrades(tmp_path): bad = tmp_path / "orders.db" bad.write_bytes(b"this is not a sqlite file, not even close" * 10) with pytest.raises(LedgerUnavailable): - OrderLedger(bad).reserve("gl-1", "buy", BUY_PARAMS) + OrderLedger(bad).reserve(coid(1), "buy", BUY_PARAMS) # --- dispatcher 闸门 --------------------------------------------------------- @@ -67,29 +106,78 @@ def __init__(self, ledger, result=None): self.ledger = ledger self.submits = 0 self._result = result or contract.ok({"entrust_no": "777"}) + self.active_queries = 0 + self.filled_queries = 0 + self.all_order_queries = 0 + self.calls: list[str] = [] + self.account_checks = 0 + self.account_preflight = contract.ok( + {"account_verified": True, "account_text": "测试账户"} + ) + + async def verify_account_for_trade(self): + self.account_checks += 1 + return self.account_preflight async def buy(self, stock_no, amount, price, client_order_id): self.submits += 1 + self.calls.append("buy") + return self._result + + async def sell(self, stock_no, amount, price, client_order_id): + self.submits += 1 + self.calls.append("sell") + return self._result + + async def cancel(self, entrust_no): + self.submits += 1 + self.calls.append("cancel") return self._result async def orders_active(self): + self.active_queries += 1 return contract.ok([]) async def orders_filled(self): + self.filled_queries += 1 + return contract.ok([]) + + async def orders_active_all(self): + self.all_order_queries += 1 return contract.ok([]) def _buy(backend, coid, amount=100): frame = {"type": "call", "id": "x", "method": "buy", - "params": {"stock_no": "600000", "amount": amount, "price": 8.1, + "params": {"stock_no": "600000", "amount": amount, + "order_type": "LIMIT", "price": 8.1, "client_order_id": coid}} return asyncio.run(dispatcher.handle_call(frame, backend)) +def _cancel(backend, coid, entrust_no="777"): + frame = {"type": "call", "id": "cancel", "method": "cancel", + "params": {"entrust_no": entrust_no, "client_order_id": coid}} + return asyncio.run(dispatcher.handle_call(frame, backend)) + + +def _confirm_external_cancel(backend, coid, confirmation_token): + frame = { + "type": "call", + "id": "confirm-cancel", + "method": "confirm_external_cancel", + "params": { + "confirmation_token": confirmation_token, + "client_order_id": coid, + }, + } + return asyncio.run(dispatcher.handle_call(frame, backend)) + + def test_resend_same_coid_never_submits_twice(ledger): backend = OrderBackend(ledger) - first = _buy(backend, "gl-1") - second = _buy(backend, "gl-1") + first = _buy(backend, coid(1)) + second = _buy(backend, coid(1)) assert backend.submits == 1, "同 coid 重发绝不能产生第二次提交" assert first["result"]["data"]["entrust_no"] == "777" @@ -101,22 +189,66 @@ def test_resend_after_unknown_outcome_returns_unknown_not_new_order(ledger): """首次结果不可知时,重发拿到的仍是「不可知」——契约不撒谎,但也绝不重下。""" backend = OrderBackend(ledger, contract.submitted_unconfirmed( "已提交但未能确认", data={"submitted": True})) - _buy(backend, "gl-2") - second = _buy(backend, "gl-2") + first = _buy(backend, coid(2)) + second = _buy(backend, coid(2)) assert backend.submits == 1 + assert backend.active_queries == 2 + assert backend.filled_queries == 2 + assert first["result"]["data"]["auto_query"]["code"] == "ok" + assert first["result"]["data"]["auto_query"]["data"]["state"] == "未知" assert second["result"]["code"] == "submitted_unconfirmed" assert second["result"]["error"]["class"] == "unknown_outcome" + assert second["result"]["data"]["idempotent_replay"] is True + assert second["result"]["data"]["auto_query"]["code"] == "ok" def test_same_coid_different_params_rejected(ledger): backend = OrderBackend(ledger) - _buy(backend, "gl-3", amount=100) - other = _buy(backend, "gl-3", amount=200) + _buy(backend, coid(3), amount=100) + other = _buy(backend, coid(3), amount=200) assert backend.submits == 1 assert other["result"]["code"] == "invalid_params" assert other["result"]["data"]["submitted"] is False +@pytest.mark.parametrize( + "params", + [ + {"stock_no": "600000", "amount": 100}, + {"stock_no": "600000", "amount": 100, "price": 8.1}, + {"stock_no": "600000", "amount": 100, + "order_type": "UNKNOWN", "price": 8.1}, + {"stock_no": "600000", "amount": 100, + "order_type": "LIMIT"}, + {"stock_no": "600000", "amount": 100, + "order_type": "LIMIT", "price": 0}, + {"stock_no": "600000", "amount": 100, + "order_type": "LIMIT", "price": -1}, + {"stock_no": "600000", "amount": 100, + "order_type": "LIMIT", "price": float("inf")}, + {"stock_no": "600000", "amount": 100, + "order_type": "FIVE_LEVEL_IOC", "price": None}, + {"stock_no": "600000", "amount": 100, + "order_type": "FIVE_LEVEL_IOC", "price": 8.1}, + ], +) +@pytest.mark.parametrize("method", ["buy", "sell"]) +def test_invalid_order_contract_rejected_before_ledger_or_backend(ledger, method, params): + class NoReserveLedger: + def reserve(self, *args, **kwargs): + raise AssertionError("非法买卖请求不应触碰台账") + + backend = OrderBackend(ledger) + backend.ledger = NoReserveLedger() + frame = {"type": "call", "id": "invalid-order", "method": method, + "params": {**params, "client_order_id": coid(40)}} + reply = asyncio.run(dispatcher.handle_call(frame, backend)) + assert reply["ok"] is False + assert reply["result"]["code"] == "invalid_params" + assert reply["result"]["data"]["submitted"] is False + assert backend.submits == 0 + + def test_ledger_unavailable_rejects_order(tmp_path): """台账不可用一律拒单,禁静默降级为无幂等下单。""" class NoLedgerBackend(OrderBackend): @@ -124,27 +256,358 @@ class NoLedgerBackend(OrderBackend): backend = NoLedgerBackend.__new__(NoLedgerBackend) OrderBackend.__init__(backend, None) - reply = _buy(backend, "gl-4") + reply = _buy(backend, coid(4)) assert backend.submits == 0 assert reply["result"]["code"] == "ledger_unavailable" assert reply["result"]["error"]["class"] == "ledger_unavailable" -def test_order_without_coid_still_works(ledger): - """不传 coid 仍可下单(不享受幂等)——不强制,但契约里写明后果。""" +@pytest.mark.parametrize("method, params", [ + ("buy", {"stock_no": "600000", "amount": 100, + "order_type": "LIMIT", "price": 8.1}), + ("sell", {"stock_no": "600000", "amount": 100, + "order_type": "LIMIT", "price": 8.1}), + ("cancel", {"entrust_no": "777"}), +]) +@pytest.mark.parametrize("value", [ + None, + "", + " ", + "gl-1", + "GL-0198f6a1-0001-7000-8000-000000000001", + "gl-0198f6a1-0001-6000-8000-000000000001", + "gl-0198f6a1-0001-7000-7000-000000000001", + "gl-0198f6a1-0001-7000-8000-000000000001 ", +]) +def test_order_requires_canonical_uuid_v7(ledger, method, params, value): + """非法幂等键绝不允许接触交易端,包括看似 UUID 但版本或 variant 错误的值。""" backend = OrderBackend(ledger) - frame = {"type": "call", "id": "x", "method": "buy", - "params": {"stock_no": "600000", "amount": 100, "price": 8.1}} + frame = {"type": "call", "id": "x", "method": method, + "params": {**params, + **({} if value is None else {"client_order_id": value})}} reply = asyncio.run(dispatcher.handle_call(frame, backend)) + assert reply["ok"] is False + assert reply["result"]["code"] == "invalid_params" + assert reply["result"]["data"]["submitted"] is False + assert backend.submits == 0 + + +@pytest.mark.parametrize("method, params", [ + ("buy", {"stock_no": "600000", "amount": 100, + "order_type": "LIMIT", "price": 8.1}), + ("sell", {"stock_no": "600000", "amount": 100, + "order_type": "LIMIT", "price": 8.1}), + ("cancel", {"entrust_no": "777"}), +]) +def test_order_accepts_canonical_uuid_v7(ledger, method, params): + backend = OrderBackend(ledger) + if method == "cancel": + _register_agent_order(ledger) + frame = {"type": "call", "id": "x", "method": method, + "params": {**params, "client_order_id": coid(10)}} + reply = asyncio.run(dispatcher.handle_call(frame, backend)) + assert reply["ok"] is True + assert backend.calls == [method] + + +def test_failed_account_preflight_blocks_buy_and_releases_idempotency_reservation(ledger): + """核验失败不得触发下单;修复后可用同一 ID 安全重试。""" + backend = OrderBackend(ledger) + backend.account_preflight = contract.fail( + contract.CODE_READ_FAILED, contract.CLS_READ_FAILED, + "账户文本不一致", data={"account_verified": False, "submitted": False}, + ) + + blocked = _buy(backend, coid(70)) + assert blocked["result"]["code"] == "read_failed" + assert backend.calls == [] + + backend.account_preflight = contract.ok( + {"account_verified": True, "account_text": "测试账户"} + ) + retried = _buy(backend, coid(70)) + assert retried["ok"] is True + assert backend.calls == ["buy"] + assert backend.account_checks == 2 + + +def test_failed_account_preflight_blocks_external_cancel_before_table_read(ledger): + """人工订单撤单在核验失败时不能读表、发确认令牌或点击撤单。""" + backend = OrderBackend(ledger) + backend.account_preflight = contract.fail( + contract.CODE_READ_FAILED, contract.CLS_READ_FAILED, + "账户文本不一致", data={"account_verified": False, "submitted": False}, + ) + + blocked = _cancel(backend, coid(71)) + assert blocked["result"]["code"] == "read_failed" + assert backend.all_order_queries == 0 + assert backend.calls == [] + + +def test_failed_account_preflight_does_not_consume_external_cancel_token(ledger): + """确认撤单先核账户;失败后原令牌仍可在账户恢复后完成一次确认。""" + class B(OrderBackend): + async def orders_active_all(self): + self.all_order_queries += 1 + return contract.ok([EXTERNAL_ORDER]) + + backend = B(ledger) + token = _cancel(backend, coid(72))["result"]["data"]["confirmation_token"] + backend.account_preflight = contract.fail( + contract.CODE_READ_FAILED, contract.CLS_READ_FAILED, + "账户文本不一致", data={"account_verified": False, "submitted": False}, + ) + + blocked = _confirm_external_cancel(backend, coid(73), token) + assert blocked["result"]["code"] == "read_failed" + assert backend.calls == [] + assert backend.all_order_queries == 1 + + backend.account_preflight = contract.ok( + {"account_verified": True, "account_text": "测试账户"} + ) + confirmed = _confirm_external_cancel(backend, coid(74), token) + assert confirmed["ok"] is True + assert backend.calls == ["cancel"] + + +def test_unconfirmed_cancel_auto_verifies_target_without_resubmitting(ledger): + """撤单未知时读全量委托表核验目标单,绝不再次点击撤单。""" + class B(OrderBackend): + async def orders_active_all(self): + self.all_order_queries += 1 + return contract.ok([{ + **EXTERNAL_ORDER, + "状态": "已撤", + }]) + + _register_agent_order(ledger) + backend = B(ledger, contract.submitted_unconfirmed( + "撤单已提交但尚未确认", data={"submitted": True})) + first = _cancel(backend, coid(11)) + second = _cancel(backend, coid(11)) + assert first["result"]["code"] == "submitted_unconfirmed" + assert second["result"]["code"] == "submitted_unconfirmed" + assert first["result"]["data"]["auto_query"]["data"]["cancel_state"] == "已撤" + assert second["result"]["data"]["auto_query"]["data"]["cancel_state"] == "已撤" + assert backend.submits == 1 + assert backend.active_queries == 0 + assert backend.filled_queries == 0 + assert backend.all_order_queries == 2 + + +def test_external_cancel_refreshes_prompt_without_persisting_or_reusing_token(ledger): + """提示重放只换令牌,既不点击 GUI,也不把授权令牌写进台账。""" + class B(OrderBackend): + async def orders_active_all(self): + self.all_order_queries += 1 + return contract.ok([EXTERNAL_ORDER]) + + backend = B(ledger) + first = _cancel(backend, coid(18)) + second = _cancel(backend, coid(18)) + + assert first["ok"] is False + assert first["result"]["code"] == "confirmation_required" + assert first["result"]["error"]["class"] == "confirmation_required" + assert first["result"]["data"]["submitted"] is False + assert first["result"]["data"]["order"] == EXTERNAL_ORDER + first_token = first["result"]["data"]["confirmation_token"] + second_token = second["result"]["data"]["confirmation_token"] + assert second_token != first_token + stored_receipt = ledger.get(coid(18))["receipt"] + assert "confirmation_token" not in stored_receipt["data"] + + stale = _confirm_external_cancel(backend, coid(33), first_token) + assert stale["result"]["data"]["confirmation_state"] == "missing" + assert backend.calls == [] + + confirmed = _confirm_external_cancel(backend, coid(34), second_token) + assert confirmed["ok"] is True + assert backend.calls == ["cancel"] + assert backend.all_order_queries == 3 + + +def test_external_cancel_confirmation_rereads_and_records_target_for_query(ledger): + """确认必须二次读表后才撤,并把目标编号写入确认动作台账。""" + class B(OrderBackend): + def __init__(self, ledger): + super().__init__(ledger) + self.rows = [dict(EXTERNAL_ORDER)] + + async def orders_active_all(self): + self.all_order_queries += 1 + return contract.ok(self.rows) + + backend = B(ledger) + prompt = _cancel(backend, coid(19)) + token = prompt["result"]["data"]["confirmation_token"] + confirmed = _confirm_external_cancel(backend, coid(20), token) + + assert confirmed["ok"] is True + assert backend.calls == ["cancel"] + assert backend.all_order_queries == 2 + assert ledger.get(coid(20))["entrust_no"] == "777" + + backend.rows = [{**EXTERNAL_ORDER, "状态": "已撤"}] + queried = asyncio.run(dispatcher.handle_call( + {"type": "call", "id": "query", "method": "query_order", + "params": {"client_order_id": coid(20)}}, backend, + )) + assert queried["result"]["data"]["resolution"] == "by_entrust_no" + assert queried["result"]["data"]["cancel_state"] == "已撤" + + +def test_external_cancel_rejects_reused_token_without_second_click(ledger): + class B(OrderBackend): + async def orders_active_all(self): + self.all_order_queries += 1 + return contract.ok([EXTERNAL_ORDER]) + + backend = B(ledger) + token = _cancel(backend, coid(21))["result"]["data"]["confirmation_token"] + assert _confirm_external_cancel(backend, coid(22), token)["ok"] is True + repeated = _confirm_external_cancel(backend, coid(23), token) + + assert repeated["result"]["code"] == "confirmation_required" + assert repeated["result"]["data"]["confirmation_state"] == "used" + assert backend.calls == ["cancel"] + + +def test_external_cancel_stops_when_order_changes_between_prompt_and_confirmation(ledger): + class B(OrderBackend): + def __init__(self, ledger): + super().__init__(ledger) + self.rows = [dict(EXTERNAL_ORDER)] + + async def orders_active_all(self): + self.all_order_queries += 1 + return contract.ok(self.rows) + + backend = B(ledger) + token = _cancel(backend, coid(24))["result"]["data"]["confirmation_token"] + backend.rows = [{**EXTERNAL_ORDER, "已成数量": 10, "状态": "部成"}] + changed = _confirm_external_cancel(backend, coid(25), token) + + assert changed["result"]["code"] == "confirmation_required" + assert changed["result"]["data"]["submitted"] is False + assert changed["result"]["data"]["current_order"]["已成数量"] == 10 + assert backend.calls == [] + + +def test_external_cancel_rejects_expired_token_without_clicking(ledger, monkeypatch): + class B(OrderBackend): + async def orders_active_all(self): + self.all_order_queries += 1 + return contract.ok([EXTERNAL_ORDER]) + + monkeypatch.setattr(dispatcher, "EXTERNAL_CANCEL_CONFIRMATION_TTL_SECS", -1.0) + backend = B(ledger) + token = _cancel(backend, coid(26))["result"]["data"]["confirmation_token"] + expired = _confirm_external_cancel(backend, coid(27), token) + + assert expired["result"]["code"] == "confirmation_required" + assert expired["result"]["data"]["confirmation_state"] == "expired" + assert backend.calls == [] + + +def test_external_cancel_connection_reset_invalidates_pending_token(ledger): + class B(OrderBackend): + async def orders_active_all(self): + self.all_order_queries += 1 + return contract.ok([EXTERNAL_ORDER]) + + backend = B(ledger) + token = _cancel(backend, coid(30))["result"]["data"]["confirmation_token"] + dispatcher.clear_external_cancel_confirmations() + invalidated = _confirm_external_cancel(backend, coid(31), token) + + assert invalidated["result"]["code"] == "confirmation_required" + assert invalidated["result"]["data"]["confirmation_state"] == "missing" + assert backend.calls == [] + + refreshed = _cancel(backend, coid(30)) + refreshed_token = refreshed["result"]["data"]["confirmation_token"] + assert refreshed_token != token + assert backend.calls == [] + assert backend.all_order_queries == 2 + + +def test_external_cancel_read_timeout_is_never_reported_as_submitted(ledger, monkeypatch): + """确认前读表超时没有调用 backend.cancel,不能伪装为未知的真实撤单。""" + class B(OrderBackend): + async def orders_active_all(self): + await asyncio.sleep(3600) + + monkeypatch.setattr(dispatcher, "CALL_TIMEOUT_SECS", 0.01) + backend = B(ledger) + timed_out = _cancel(backend, coid(32)) + + assert timed_out["result"]["code"] == "call_timeout" + assert timed_out["result"]["data"]["submitted"] is False + assert backend.calls == [] + + +def test_registered_cancel_skips_confirmation_and_does_not_read_full_table(ledger): + class B(OrderBackend): + async def orders_active_all(self): + pytest.fail("已登记订单不应进入人工订单全量表确认路径") + + _register_agent_order(ledger) + backend = B(ledger) + reply = _cancel(backend, coid(28)) + + assert reply["ok"] is True + assert backend.calls == ["cancel"] + + +def test_external_cancel_direct_mode_skips_confirmation(ledger, monkeypatch): + class B(OrderBackend): + async def orders_active_all(self): + pytest.fail("direct 模式不应读取人工订单确认表") + + monkeypatch.setattr( + dispatcher._config, + "load", + lambda: TraderConfig( + device_id="", + external_cancel_confirmation=EXTERNAL_CANCEL_CONFIRMATION_DIRECT, + ), + ) + backend = B(ledger) + reply = _cancel(backend, coid(29)) + assert reply["ok"] is True + assert backend.calls == ["cancel"] + + +def test_auto_query_timeout_preserves_unknown_without_resubmitting(ledger, monkeypatch): + """自动核单失败只能作为附加证据,绝不能覆盖原回执或补发下单。""" + monkeypatch.setattr(dispatcher, "AUTO_QUERY_TIMEOUT_SECS", 0.01) + + class SlowQueryBackend(OrderBackend): + async def orders_active(self): + self.active_queries += 1 + await asyncio.sleep(3600) + + backend = SlowQueryBackend(ledger, contract.submitted_unconfirmed( + "已提交但未能确认", data={"submitted": True})) + reply = _buy(backend, coid(12)) + result = reply["result"] + assert result["code"] == "submitted_unconfirmed" + assert result["error"]["class"] == "unknown_outcome" + assert result["data"]["auto_query"]["code"] == "call_timeout" + assert backend.calls == ["buy"] assert backend.submits == 1 + assert backend.degraded is True # --- C5b query_order --------------------------------------------------------- def test_query_order_resolves_by_entrust_no(ledger): - ledger.reserve("gl-5", "buy", BUY_PARAMS) - ledger.complete("gl-5", contract.ok({"entrust_no": "777"}), "777") + ledger.reserve(coid(5), "buy", BUY_PARAMS) + ledger.complete(coid(5), contract.ok({"entrust_no": "777"}), "777") class B(OrderBackend): async def orders_active(self): @@ -153,7 +616,7 @@ async def orders_active(self): reply = asyncio.run(dispatcher.handle_call( {"type": "call", "id": "q", "method": "query_order", - "params": {"client_order_id": "gl-5"}}, B(ledger))) + "params": {"client_order_id": coid(5)}}, B(ledger))) data = reply["result"]["data"] assert data["state"] == "已报" assert data["resolution"] == "by_entrust_no" @@ -161,18 +624,20 @@ async def orders_active(self): def test_query_order_unresolved_when_ambiguous(ledger): """entrust_no 未知 + 实表有两笔同参单 → 不猜,报未知(需人工)。""" - ledger.reserve("gl-6", "buy", BUY_PARAMS) + ledger.reserve(coid(6), "buy", BUY_PARAMS) class B(OrderBackend): async def orders_active(self): return contract.ok([ - {"entrust_no": "1", "证券代码": "600000", "委托数量": 100, "状态": "已报"}, - {"entrust_no": "2", "证券代码": "600000", "委托数量": 100, "状态": "已报"}, + {"entrust_no": "1", "证券代码": "600000", "方向": "买入", + "委托数量": 100, "委托价": 8.1, "状态": "已报"}, + {"entrust_no": "2", "证券代码": "600000", "方向": "买入", + "委托数量": 100, "委托价": 8.1, "状态": "已报"}, ]) reply = asyncio.run(dispatcher.handle_call( {"type": "call", "id": "q", "method": "query_order", - "params": {"client_order_id": "gl-6"}}, B(ledger))) + "params": {"client_order_id": coid(6)}}, B(ledger))) data = reply["result"]["data"] assert data["state"] == "未知" assert data["resolution"] == "unresolved" @@ -183,3 +648,128 @@ def test_query_order_unknown_coid_is_not_found(ledger): {"type": "call", "id": "q", "method": "query_order", "params": {"client_order_id": "never-seen"}}, OrderBackend(ledger))) assert reply["result"]["code"] == "not_found" + + +def test_query_order_keeps_legacy_id_readable(ledger): + """格式升级不能让历史台账里的未知订单无法核查。""" + legacy_id = "legacy-coid" + ledger.reserve(legacy_id, "buy", BUY_PARAMS) + reply = asyncio.run(dispatcher.handle_call( + {"type": "call", "id": "q", "method": "query_order", + "params": {"client_order_id": legacy_id}}, OrderBackend(ledger))) + assert reply["result"]["code"] == "ok" + assert reply["result"]["data"]["client_order_id"] == legacy_id + + +@pytest.mark.parametrize(("row", "expected"), [ + ({"entrust_no": "777", "委托数量": 100, "已成数量": 0, "状态": "已撤"}, "已撤"), + ({"entrust_no": "777", "委托数量": 100, "已成数量": 20, "状态": "已撤"}, "部成后已撤"), + ({"entrust_no": "777", "委托数量": 100, "已成数量": 100, "状态": "已成"}, "已成"), + ({"entrust_no": "777", "委托数量": 100, "已成数量": 20, "状态": "部成"}, "仍在飞"), + ({"entrust_no": "777", "委托数量": 100, "已成数量": 0, "状态": "废单"}, "废单"), +]) +def test_query_cancel_resolves_terminal_or_in_flight_state(ledger, row, expected): + """撤单 ID 必须按目标 entrust_no 查全量表,不能从成交表推断已撤。""" + query_id = coid(13) + ledger.reserve(query_id, "cancel", {"entrust_no": "777"}) + + class B(OrderBackend): + async def orders_active_all(self): + self.all_order_queries += 1 + return contract.ok([row]) + + backend = B(ledger) + reply = asyncio.run(dispatcher.handle_call( + {"type": "call", "id": "q", "method": "query_order", + "params": {"client_order_id": query_id}}, backend)) + data = reply["result"]["data"] + assert data["resolution"] == "by_entrust_no" + assert data["cancel_state"] == expected + assert data["entrust_no"] == "777" + assert backend.all_order_queries == 1 + assert backend.active_queries == 0 + assert backend.filled_queries == 0 + + +def test_query_cancel_is_unknown_when_full_order_table_lacks_target(ledger): + query_id = coid(14) + ledger.reserve(query_id, "cancel", {"entrust_no": "777"}) + backend = OrderBackend(ledger) + + reply = asyncio.run(dispatcher.handle_call( + {"type": "call", "id": "q", "method": "query_order", + "params": {"client_order_id": query_id}}, backend)) + data = reply["result"]["data"] + assert data["resolution"] == "unresolved" + assert data["cancel_state"] == "未知" + assert data["tables_readable"] is True + + +def test_query_cancel_is_unknown_when_full_order_table_is_unreadable(ledger): + query_id = coid(17) + ledger.reserve(query_id, "cancel", {"entrust_no": "777"}) + + class B(OrderBackend): + async def orders_active_all(self): + self.all_order_queries += 1 + return contract.fail(contract.CODE_READ_FAILED, contract.CLS_READ_FAILED, "验证码弹窗") + + backend = B(ledger) + reply = asyncio.run(dispatcher.handle_call( + {"type": "call", "id": "q", "method": "query_order", + "params": {"client_order_id": query_id}}, backend)) + data = reply["result"]["data"] + assert data["resolution"] == "unresolved" + assert data["cancel_state"] == "未知" + assert data["tables_readable"] is False + + +def test_cancel_target_does_not_overwrite_original_order_id_join(ledger): + """历史撤单台账也不得让撤单 ID 覆盖原买卖单的表格回显关联。""" + buy_id, cancel_id = coid(15), coid(16) + ledger.reserve(buy_id, "buy", BUY_PARAMS) + ledger.complete(buy_id, contract.ok({"entrust_no": "777"}), "777") + ledger.reserve(cancel_id, "cancel", {"entrust_no": "777"}) + ledger.complete(cancel_id, contract.ok({"entrust_no": "777"}), "777") + + assert ledger.coid_by_entrust() == {"777": buy_id} + + +@pytest.mark.parametrize("row", [ + {"证券代码": "600000", "方向": "卖出", "委托数量": 100, "委托价": 8.1, "状态": "已报"}, + {"证券代码": "600000", "方向": "买入", "委托数量": 100, "委托价": 8.2, "状态": "已报"}, +]) +def test_query_order_heuristic_rejects_wrong_direction_or_limit_price(ledger, row): + """外部同代码单不得因方向相反或限价不同而被归因为本单。""" + query_id = coid(7) + ledger.reserve(query_id, "buy", BUY_PARAMS) + + class B(OrderBackend): + async def orders_active(self): + return contract.ok([row]) + + reply = asyncio.run(dispatcher.handle_call( + {"type": "call", "id": "q", "method": "query_order", + "params": {"client_order_id": query_id}}, B(ledger))) + data = reply["result"]["data"] + assert data["state"] == "未知" + assert data["resolution"] == "unresolved" + + +def test_query_order_heuristic_matches_unique_full_limit_fingerprint(ledger): + query_id = coid(8) + ledger.reserve(query_id, "buy", BUY_PARAMS) + + class B(OrderBackend): + async def orders_active(self): + return contract.ok([{ + "entrust_no": "888", "证券代码": "600000", "方向": "买入", + "委托数量": 100, "委托价": 8.1, "状态": "已报", + }]) + + reply = asyncio.run(dispatcher.handle_call( + {"type": "call", "id": "q", "method": "query_order", + "params": {"client_order_id": query_id}}, B(ledger))) + data = reply["result"]["data"] + assert data["state"] == "已报" + assert data["resolution"] == "heuristic" diff --git a/tests/test_market_baseline.py b/tests/test_market_baseline.py index 1e8c816..56cd1d9 100644 --- a/tests/test_market_baseline.py +++ b/tests/test_market_baseline.py @@ -14,8 +14,8 @@ def _backend(monkeypatch, pre_result): calls = [] monkeypatch.setattr(b, "switch_to_normal", lambda: None) monkeypatch.setattr(b, "get_filled_orders", lambda: pre_result) - monkeypatch.setattr(b, "_select_tree_child", - lambda parent, child: calls.append("navigate") or True) + monkeypatch.setattr(b, "_select_market_tree_path", + lambda path, **kwargs: calls.append(("navigate", path)) or True) monkeypatch.setattr(w, "_activate_window", lambda hwnd: None) monkeypatch.setattr(w, "sleep_time", 0) return b, calls @@ -42,3 +42,98 @@ def test_wrong_table_baseline_also_aborts(monkeypatch): r = b._submit_market_trade("卖出", "300458", 500) assert r["status"] == "failed" assert calls == [] + + +def test_empty_filled_table_is_a_valid_market_order_baseline(monkeypatch): + """成功读取到空成交表就是合法基线,不能误判为读取失败。""" + from trader import contract + b, calls = _backend(monkeypatch, contract.ok([])) + monkeypatch.setattr( + b, "_select_market_tree_path", + lambda path, **kwargs: calls.append(("navigate", path)) or False, + ) + + r = b._submit_market_trade("买入", "300458", 500) + + assert r["status"] == "failed" + assert "未能导航到市价委托面板" in r["error"]["message"] + assert calls == [("navigate", "买入")], "空成交表必须通过基线校验,继续进入下单面板" + + +def test_verified_no_header_empty_active_table_is_valid_limit_baseline(monkeypatch): + """同花顺空委托表不复制表头时,已通过验证码核验的空结果可作空基线。""" + from trader import contract + + b = WinThsBackend() + monkeypatch.setattr(b, "get_active_orders_all", lambda: contract.ok([])) + b._last_grid_verified_empty.add("active_orders") + + baseline, error = b._read_limit_order_baseline() + + assert error is None + assert baseline == set() + + +def test_unverified_no_header_active_table_still_aborts_limit_baseline(monkeypatch): + """普通读取失败不能借空表语义绕过限价单的归属保护。""" + from trader import contract + + b = WinThsBackend() + monkeypatch.setattr(b, "get_active_orders_all", lambda: contract.ok([])) + + baseline, error = b._read_limit_order_baseline() + + assert baseline is None + assert error["status"] == "failed" + assert error["code"] == "table_mismatch" + + +def test_limit_baseline_accepts_real_broker_buy_sell_columns(monkeypatch): + """真机“买卖/委托状态/合同编号”表头可安全组成限价单归属基线。""" + from trader import contract + + b = WinThsBackend() + monkeypatch.setattr( + b, "get_active_orders_all", + lambda: contract.ok([{"entrust_no": "A-1"}]), + ) + b._last_grid_columns["active_orders"] = ( + "委托时间", "证券代码", "证券名称", "买卖", "委托状态", + "委托数量", "成交数量", "委托价格", "成交价格", "已撤数量", + "合同编号", "交易市场", + ) + + baseline, error = b._read_limit_order_baseline() + + assert error is None + assert baseline == {"A-1"} + + +def test_market_sell_uses_compatible_market_entry(monkeypatch): + """卖出只能从精确市价路径进入,不得落到普通卖出 F2。""" + from trader import contract + b, calls = _backend(monkeypatch, contract.ok([])) + monkeypatch.setattr( + b, "_select_market_tree_path", + lambda path, **kwargs: calls.append(("navigate", path)) or False, + ) + + r = b._submit_market_trade("卖出", "300458", 500) + + assert r["status"] == "failed" + assert calls == [("navigate", "卖出")] + + +def test_market_navigation_falls_back_to_market_parent_child_path(monkeypatch): + b = WinThsBackend() + attempted = [] + monkeypatch.setattr( + b, "_select_tree_path", + lambda path, **kwargs: attempted.append((path, kwargs)) or path == ("市价委托", "买入"), + ) + + assert b._select_market_tree_path("买入") is True + assert attempted == [ + (("市价买入",), {"require_window_safety": True}), + (("市价委托", "买入"), {"require_window_safety": True}), + ] diff --git a/tests/test_order_watch.py b/tests/test_order_watch.py index 0ddfadf..2aa85bd 100644 --- a/tests/test_order_watch.py +++ b/tests/test_order_watch.py @@ -3,8 +3,9 @@ 沿用本仓库测试约定:同步测试,async 用 asyncio.run 驱动,不依赖 pytest-asyncio。 """ from datetime import datetime +from types import SimpleNamespace -from trader import order_watch +from trader import order_watch, watchlist_watch def test_in_trading_session_morning_and_afternoon(): @@ -143,6 +144,7 @@ def __init__(self, backend): async def send_frame(self, frame): self.sent.append(frame) + return True def test_first_round_builds_baseline_no_emit(): @@ -217,52 +219,153 @@ def test_next_interval_idle_when_empty(): assert order_watch.next_interval({}, 300, 60) == 300 -def test_send_frame_failure_does_not_advance_baseline(): - """Regression: if send_frame raises, baseline should not advance; next round retries.""" +def test_false_send_frame_queues_same_event_and_seq_for_reconnect_retry(): + """False keeps the observed transition in FIFO; a retry uses its original seq.""" r0 = _active([_row("1", 100, 0, "已报")]) r1 = _active([_row("1", 100, 100, "已成", avg=1699.8)]) backend = WatchFakeBackend([r0, r1]) - # Client that raises on first send_frame call - class FailingClient: + # Runtime WsClient returns False on send failure. + class ReconnectingClient: def __init__(self, backend): self.backend = backend self.sent = [] - self._call_count = 0 + self.connected = True async def send_frame(self, frame): - self._call_count += 1 - if self._call_count == 1: - raise RuntimeError("simulated send_frame failure") self.sent.append(frame) + return self.connected async def drive(): - failing_client = FailingClient(backend) + client = ReconnectingClient(backend) # Round 1: establish baseline from r0 - prev, seq, ok = await order_watch._poll_once(backend, failing_client, None, 0) + prev, seq, ok = await order_watch._poll_once(backend, client, None, 0) assert ok is True assert set(prev) == {"1"} - baseline_snapshot = prev - # Round 2: try to send filled event (r0→r1), but send_frame raises - prev_after, seq_after, ok_after = await order_watch._poll_once( - backend, failing_client, prev, seq - ) + # Round 2: try to send filled event (r0→r1), but send_frame returns False. + client.connected = False + prev_after, seq_after, ok_after = await order_watch._poll_once(backend, client, prev, seq) assert ok_after is False, "should return False when send fails" - assert ( - prev_after is baseline_snapshot - ), "baseline should not advance on send failure" - assert len(failing_client.sent) == 0, "no events should be sent on failure" + assert prev_after == order_watch.build_snapshot(r1) + assert seq_after == 1 + assert [frame["seq"] for frame in client.sent] == [1] - # Round 3: same baseline with new working client re-emits the event - working_client = WatchFakeClient(backend) + # The same WsClient instance survives reconnect; it only replays the + # failed frame and never reconstructs/resubmits a trading RPC. + client.connected = True prev_retry, seq_retry, ok_retry = await order_watch._poll_once( - backend, working_client, prev_after, seq_after + backend, client, prev_after, seq_after ) assert ok_retry is True, "should succeed on retry" - assert len(working_client.sent) == 1, "event should be sent on retry" - assert working_client.sent[0]["event"] == "filled" - assert working_client.sent[0]["entrust_no"] == "1" + assert len(client.sent) == 2 + assert [frame["seq"] for frame in client.sent] == [1, 1] + assert client.sent[-1]["event"] == "filled" + assert client.sent[-1]["entrust_no"] == "1" + assert seq_retry == 1 + + asyncio.run(drive()) + + +def test_multi_event_failure_replays_only_unsent_frame(): + r0 = _active([]) + r1 = _active([ + _row("1", 100, 0, "已报"), + _row("2", 200, 0, "已报", code="000001"), + ]) + backend = WatchFakeBackend([r0, r1]) + + class PartiallyFailingClient: + def __init__(self, backend): + self.backend = backend + self.sent = [] + + async def send_frame(self, frame): + self.sent.append(frame) + return len(self.sent) != 2 + + async def drive(): + client = PartiallyFailingClient(backend) + prev, seq, ok = await order_watch._poll_once(backend, client, None, 0) + assert ok is True + + prev_after, seq_after, ok_after = await order_watch._poll_once( + backend, client, prev, seq + ) + assert ok_after is False + assert prev_after == order_watch.build_snapshot(r1) + assert seq_after == 2 + assert [frame["seq"] for frame in client.sent] == [1, 2] + + prev_retry, seq_retry, ok_retry = await order_watch._poll_once( + backend, client, prev_after, seq_after + ) + assert ok_retry is True + assert prev_retry == order_watch.build_snapshot(r1) + assert seq_retry == 2 + assert [frame["seq"] for frame in client.sent] == [1, 2, 2] asyncio.run(drive()) + + +def test_watchlist_false_send_keeps_baseline_and_retries(monkeypatch): + class WatchlistBackend: + def __init__(self): + self.win_lock = asyncio.Lock() + self._results = [ + {"status": "succeed", "data": {"codes": ["600519"]}}, + {"status": "succeed", "data": {"codes": ["000001", "600519"]}}, + ] + self._index = 0 + + async def watchlist(self): + result = self._results[min(self._index, len(self._results) - 1)] + self._index += 1 + return result + + class RetryingClient: + def __init__(self, backend): + self.backend = backend + self.sent = [] + + def send_frame(self, frame): + self.sent.append(frame) + return len(self.sent) > 1 + + class ConnectedState: + def __init__(self): + self._states = iter(("CONNECTED", "CONNECTED", "DISCONNECTED", "CONNECTED")) + + def snapshot(self): + return { + "connection_state": next(self._states), + "enable_ths_plugin": True, + } + + sleep_calls = 0 + + async def fake_sleep(_seconds): + nonlocal sleep_calls + sleep_calls += 1 + if sleep_calls >= 5: + raise asyncio.CancelledError + + monkeypatch.setattr( + watchlist_watch.config, + "load", + lambda: SimpleNamespace( + enable_watchlist_watch=True, + watchlist_sync_hours="8,12,16,20", + ), + ) + monkeypatch.setattr(watchlist_watch.asyncio, "sleep", fake_sleep) + + backend = WatchlistBackend() + client = RetryingClient(backend) + asyncio.run(watchlist_watch.watchlist_watch_task(ConnectedState(), client)) + + assert len(client.sent) == 2 + assert [frame["seq"] for frame in client.sent] == [1, 1] + assert client.sent[0]["codes"] == client.sent[1]["codes"] == ["000001", "600519"] + assert backend._index == 2, "replay must not OCR/read THS again" diff --git a/tests/test_rows_contract.py b/tests/test_rows_contract.py index aee97ab..4a383dd 100644 --- a/tests/test_rows_contract.py +++ b/tests/test_rows_contract.py @@ -32,6 +32,23 @@ def test_client_order_id_joined_from_ledger_else_null(): assert rows.normalize_active_row(ACTIVE_RAW, {})["client_order_id"] is None +def test_active_row_accepts_broker_buy_sell_column_alias(): + """实机委托表的“买卖”列也必须进入完整下单回执指纹。""" + raw = { + "合同编号": "A-1", "证券代码": "518800", "买卖": "买入", + "委托数量": "100", "委托价格": "1.000", "成交数量": "0", + "委托状态": "已报", + } + + row = rows.normalize_active_row(raw) + + assert row["entrust_no"] == "A-1" + assert row["方向"] == "买入" + assert row["委托数量"] == 100 + assert row["委托价"] == 1.0 + assert row["状态"] == "已报" + + @pytest.mark.parametrize("note,expected", [ ("已报", rows.ST_PLACED), ("未报", rows.ST_PENDING), ("部成", rows.ST_PARTIAL), ("已成", rows.ST_FILLED), ("已撤", rows.ST_CANCELED), ("废单", rows.ST_REJECTED), diff --git a/tests/test_switch_account.py b/tests/test_switch_account.py index 3aa6e03..400cd2b 100644 --- a/tests/test_switch_account.py +++ b/tests/test_switch_account.py @@ -5,10 +5,30 @@ 绑定/发键层用打桩隔离,全部用例可在非 Windows 平台运行。 """ import asyncio +from types import SimpleNamespace + +import pytest from trader import contract +from trader.ths import win as w + +from trader.ths.win import ( + WinThsBackend, + _account_candidates_from_listbox, + _account_identity, + _account_selector_matches_target, +) + -from trader.ths.win import WinThsBackend +def _account_list(*texts): + return contract.ok({ + "accounts": [ + {"slot": index, "shortcut": f"Alt+{index}", "text": text} + for index, text in enumerate(texts, start=1) + ], + "current_account_text": None, + "partial": False, + }) def _switch(slot): @@ -51,3 +71,251 @@ def test_coerced_int_slot_forwarded_to_do_switch(monkeypatch): result = asyncio.run(backend.switch_account("2")) assert result["status"] == "succeed" assert seen == [2] + + +def test_switch_matches_requested_list_slot_and_returns_balance(monkeypatch): + """切换后必须匹配所选 ListBox 槽位,而不是只判断文本发生变化。""" + backend = WinThsBackend() + monkeypatch.setattr( + backend, "get_account_list", + lambda: _account_list("示例券商-甲*乙", "示例券商-丙*丁"), + ) + monkeypatch.setattr( + backend, + "_read_account_selector_text", + iter(["示例券商 甲*乙", "示例券商-z某营业部 丙*丁"]).__next__, + ) + sent = [] + monkeypatch.setattr(backend, "_send_hotkey", + lambda keys, where: sent.append((keys, where))) + monkeypatch.setattr( + backend, "get_balance", + lambda: contract.ok({"可用金额": 20000.78, "总资产": 20000.78}), + ) + monkeypatch.setattr(w, "sleep_time", 0) + + result = backend.do_switch_account(2) + + assert result["status"] == "succeed" + assert result["data"]["account_verified"] is True + assert result["data"]["account_text"] == "示例券商-z某营业部 丙*丁" + assert result["data"]["target_account_text"] == "示例券商-丙*丁" + assert result["data"]["already_active"] is False + assert result["data"]["balance"]["可用金额"] == 20000.78 + assert result["data"]["msg"] == "已切换到:示例券商-z某营业部 丙*丁" + assert sent == [(["alt", "2"], "switch_account")] + assert backend.account_trading_blocked is False + + +def test_switch_unmatched_target_blocks_trading(monkeypatch): + """热键后仍未匹配所选槽位时不能伪报成功,且保留交易闸门。""" + backend = WinThsBackend() + monkeypatch.setattr( + backend, "get_account_list", + lambda: _account_list("示例券商-甲*乙", "示例券商-丙*丁"), + ) + monkeypatch.setattr( + backend, + "_read_account_selector_text", + lambda: "示例券商-z某营业部 甲*乙", + ) + monkeypatch.setattr(backend, "_send_hotkey", lambda keys, where: None) + monkeypatch.setattr(w, "ACCOUNT_VERIFY_TIMEOUT_SECS", 0) + balance_called = [] + monkeypatch.setattr(backend, "get_balance", lambda: balance_called.append(True)) + + result = backend.do_switch_account(2) + + assert result["status"] == "failed" + assert result["code"] == "read_failed" + assert result["data"]["account_verified"] is False + assert backend.account_trading_blocked is True + assert balance_called == [] + + +def test_switch_to_current_account_verifies_without_sending_hotkey(monkeypatch): + """用户选中当前账户时,应完成显式核验而不是因文本不变锁死交易。""" + backend = WinThsBackend() + monkeypatch.setattr( + backend, "get_account_list", + lambda: _account_list("示例券商-甲*乙", "示例券商-丙*丁"), + ) + monkeypatch.setattr( + backend, + "_read_account_selector_text", + lambda: "示例券商-z某营业部 甲*乙", + ) + sent = [] + monkeypatch.setattr(backend, "_send_hotkey", lambda keys, where: sent.append((keys, where))) + monkeypatch.setattr(backend, "get_balance", lambda: contract.ok({"可用金额": 20000.78})) + + result = backend.do_switch_account(1) + + assert result["status"] == "succeed" + assert result["data"]["already_active"] is True + assert result["data"]["msg"] == "当前已是:示例券商-z某营业部 甲*乙" + assert sent == [] + assert backend.account_trading_blocked is False + + +def test_trade_account_preflight_requires_explicit_switch_account(monkeypatch): + """重启后不能把首次读到的账户自动当成已选择账户。""" + backend = WinThsBackend() + assert backend.account_trading_blocked is True + + monkeypatch.setattr(backend, "_read_account_selector_text", + lambda: "示例券商 甲*乙") + first = backend._verify_account_for_trade() + + assert first["code"] == "read_failed" + assert first["data"]["account_verified"] is False + assert backend.account_trading_blocked is True + + +def test_connection_reset_requires_a_new_explicit_account_selection(): + backend = WinThsBackend() + backend._last_account_text = "示例券商 甲*乙" + backend._account_trading_blocked = False + + backend.require_explicit_account_selection() + + assert backend._last_account_text is None + assert backend.account_trading_blocked is True + + +def test_trade_account_preflight_blocks_changed_or_unreadable_text(monkeypatch): + """手动切户或控件不可读时,后续买卖和撤单都必须保持关闭。""" + backend = WinThsBackend() + backend._last_account_text = "示例券商 甲*乙" + backend._account_trading_blocked = False + monkeypatch.setattr(backend, "_read_account_selector_text", + lambda: "示例券商 甲*乙") + assert backend._verify_account_for_trade()["status"] == "succeed" + + monkeypatch.setattr(backend, "_read_account_selector_text", + lambda: "示例券商 丙*丁") + changed = backend._verify_account_for_trade() + assert changed["code"] == "read_failed" + assert changed["data"]["expected_account_text"] == "示例券商 甲*乙" + assert changed["data"]["account_text"] == "示例券商 丙*丁" + assert backend.account_trading_blocked is True + + monkeypatch.setattr(backend, "_read_account_selector_text", lambda: None) + unreadable = backend._verify_account_for_trade() + assert unreadable["code"] == "read_failed" + assert unreadable["data"]["account_text"] is None + assert backend.account_trading_blocked is True + + +def test_account_listbox_filters_edit_item_and_assigns_slots_by_row_order(): + """真机 ListBox 最后一行“编辑账户”不是账户,账户行从上至下对应 Alt+1..9。""" + accounts = _account_candidates_from_listbox([ + "示例券商-甲*乙", "示例券商-丙*丁", "编辑账户", + ]) + + assert accounts == [ + {"slot": 1, "shortcut": "Alt+1", "text": "示例券商-甲*乙"}, + {"slot": 2, "shortcut": "Alt+2", "text": "示例券商-丙*丁"}, + ] + + +def test_account_listbox_rejects_more_than_nine_accounts(): + with pytest.raises(ValueError, match="超过 Alt\\+1..Alt\\+9 范围"): + _account_candidates_from_listbox([f"账户{i}" for i in range(10)]) + + +def test_account_identity_only_ignores_verified_formatting_difference(): + assert _account_identity("示例券商-甲*乙") == _account_identity("示例券商 甲*乙") + assert _account_identity("示例券商-甲*乙") != _account_identity("示例券商-甲*丙") + + +def test_account_selector_allows_only_the_observed_branch_insertion(): + target = "示例券商-甲*乙" + + assert _account_selector_matches_target("示例券商-z示例营业部 甲*乙", target) + assert _account_selector_matches_target("示例券商 甲*乙", target) + assert not _account_selector_matches_target("示例券商-z示例营业部 甲*丙", target) + assert not _account_selector_matches_target("其他券商-z示例营业部 甲*乙", target) + assert not _account_selector_matches_target("示例券商-z示例营业部甲*乙", target) + + +def test_account_listbox_uses_a_private_four_argument_send_message(monkeypatch): + """ListBox 的四参数绑定不能污染后续普通控件的两参数 SendMessageW 调用。""" + calls = [] + + class SendMessage: + def __call__(self, hwnd, message, wparam, lparam): + calls.append((hwnd, message, wparam, lparam)) + if message == w.LB_GETCOUNT: + return 2 + return 0 + + sender = SendMessage() + monkeypatch.setattr( + w.ctypes, "WinDLL", lambda *_args, **_kwargs: SimpleNamespace(SendMessageW=sender), + raising=False, + ) + + assert WinThsBackend()._read_account_listbox_items(123) == ["", ""] + assert calls == [ + (123, w.LB_GETCOUNT, 0, 0), + (123, w.LB_GETTEXTLEN, 0, 0), + (123, w.LB_GETTEXT, 0, pytest.approx(calls[2][3])), + (123, w.LB_GETTEXTLEN, 1, 0), + (123, w.LB_GETTEXT, 1, pytest.approx(calls[4][3])), + ] + + +def test_get_account_list_reads_listbox_text_without_ocr(monkeypatch): + backend = WinThsBackend() + monkeypatch.setattr(backend, "_switch_to_normal_safely", lambda: None) + monkeypatch.setattr(backend, "_read_account_selector_text", lambda: "示例券商-甲*乙") + monkeypatch.setattr(backend, "_open_account_dropdown", lambda: True) + monkeypatch.setattr(backend, "_find_open_account_listbox", lambda: 123) + monkeypatch.setattr( + backend, "_read_account_listbox_items", + lambda hwnd: ["示例券商-甲*乙", "编辑账户"], + ) + closed = [] + monkeypatch.setattr(backend, "_send_hotkey", lambda keys, where: closed.append((keys, where))) + monkeypatch.setattr(w, "ACCOUNT_DROPDOWN_SETTLE_SECS", 0) + + result = backend.get_account_list() + + assert result["status"] == "succeed" + assert result["data"] == { + "accounts": [{"slot": 1, "shortcut": "Alt+1", "text": "示例券商-甲*乙"}], + "current_account_text": "示例券商-甲*乙", + "partial": False, + "source": "listbox_text", + "msg": "已读取账户下拉列表原始文本,未选择或切换任何账户", + } + assert closed == [(["esc"], "close_account_dropdown")] + + +def test_account_dropdown_click_uses_visible_verified_combobox(monkeypatch): + """账户列表入口只点击可见、启用且归属于当前进程的 0x0912 ComboBox。""" + backend = WinThsBackend() + backend.hwnd_main = 100 + calls = [] + monkeypatch.setattr( + backend, "_find_ctrl_by_id", + lambda root, cid, cls=None, visible=False: ( + calls.append((root, cid, cls, visible)) or 200 + ), + ) + monkeypatch.setattr(backend, "_window_is_owned_by_bound_process", lambda hwnd: True) + monkeypatch.setattr(w, "win32gui", SimpleNamespace( + GetWindowRect=lambda hwnd: (10, 20, 110, 60), + IsWindowEnabled=lambda hwnd: True, + ), raising=False) + monkeypatch.setattr( + backend, "_click_screen", + lambda x, y, where: calls.append(((x, y), where)), + ) + + assert backend._open_account_dropdown() is True + assert calls == [ + (100, 0x0912, "ComboBox", True), + ((60, 40), "account_dropdown"), + ] diff --git a/tests/test_tools_schema_sync.py b/tests/test_tools_schema_sync.py index 1455657..2399d8a 100644 --- a/tests/test_tools_schema_sync.py +++ b/tests/test_tools_schema_sync.py @@ -11,19 +11,24 @@ def _tool(tools, name): return next(t for t in tools if t["name"] == name) -def test_buy_sell_desc_mentions_market_and_limit(): +def test_buy_sell_schema_requires_explicit_order_type(): for name in ("buy", "sell"): t = _tool(FALLBACK_TOOLS_SCHEMA["tools"], name) - price_desc = t["inputSchema"]["properties"]["price"]["description"] - assert "五档即成剩撤" in price_desc - assert "限价" in price_desc - # 旧文案不得残留 + schema = t["inputSchema"] + assert schema["properties"]["order_type"]["enum"] == [ + "LIMIT", "FIVE_LEVEL_IOC" + ] + assert "order_type" in schema["required"] + assert "order_type" in t["description"] + price_desc = schema["properties"]["price"]["description"] + assert "LIMIT" in price_desc + assert "禁止传入" in price_desc assert "对手价市价单" not in price_desc def test_fallback_matches_tools_schema_json(): disk = json.loads((ROOT / "docs/tools_schema.json").read_text("utf-8")) - for name in ("buy", "sell"): - code_desc = _tool(FALLBACK_TOOLS_SCHEMA["tools"], name)["inputSchema"]["properties"]["price"]["description"] - disk_desc = _tool(disk["tools"], name)["inputSchema"]["properties"]["price"]["description"] - assert code_desc == disk_desc + for name in ("buy", "sell", "cancel", "confirm_external_cancel"): + code_schema = _tool(FALLBACK_TOOLS_SCHEMA["tools"], name)["inputSchema"] + disk_schema = _tool(disk["tools"], name)["inputSchema"] + assert code_schema == disk_schema diff --git a/tests/test_window_safety.py b/tests/test_window_safety.py new file mode 100644 index 0000000..9bcae5f --- /dev/null +++ b/tests/test_window_safety.py @@ -0,0 +1,170 @@ +"""窗口身份与前台保护必须在非 Windows CI 中可验证。""" + +from types import SimpleNamespace + +import pytest + +from trader.ths import win as w +from trader.ths.win import WindowSafetyError, WinThsBackend + + +def _bound_backend(monkeypatch): + backend = WinThsBackend() + backend.hwnd_main = 100 + backend._bound_pid = 7 + backend._bound_executable = r"c:\\ths\\xiadan.exe" + monkeypatch.setattr(backend, "_bound_window_is_valid", lambda: True) + monkeypatch.setattr( + w, + "_window_process_identity", + lambda hwnd: (7, r"c:\\ths\\xiadan.exe") if hwnd in {100, 200} else (99, r"c:\\other.exe"), + ) + return backend + + +def _mouse_api(events): + return SimpleNamespace( + SetCursorPos=lambda pos: events.append(("move", pos)), + mouse_event=lambda *args: events.append(("mouse", args)), + PostMessage=lambda *args: events.append(("post", args)), + ) + + +def _con(): + return SimpleNamespace(MOUSEEVENTF_LEFTDOWN=2, MOUSEEVENTF_LEFTUP=4, BM_CLICK=0xF5) + + +def test_global_hotkey_is_not_sent_when_bound_window_cannot_take_foreground(monkeypatch): + backend = _bound_backend(monkeypatch) + keys = [] + monkeypatch.setattr(backend, "_activate_bound_window", lambda: False) + monkeypatch.setattr(w, "hot_key", lambda value, before_dispatch=None: keys.append(value)) + + with pytest.raises(WindowSafetyError): + backend._send_hotkey(["enter"], "test_submit") + + assert keys == [] + + +def test_hotkey_rechecks_focus_immediately_before_key_dispatch(monkeypatch): + backend = _bound_backend(monkeypatch) + keys = [] + foreground = [100] + monkeypatch.setattr(w, "_foreground_window", lambda: foreground[0]) + + def delayed_hotkey(value, before_dispatch=None): + foreground[0] = 999 + before_dispatch() + keys.append(value) + + monkeypatch.setattr(w, "hot_key", delayed_hotkey) + + with pytest.raises(WindowSafetyError): + backend._send_hotkey(["enter"], "test_submit") + + assert keys == [] + + +def test_captcha_hotkey_requires_the_exact_popup_not_any_bound_window(monkeypatch): + backend = _bound_backend(monkeypatch) + keys = [] + monkeypatch.setattr(w, "_foreground_window", lambda: 100) + monkeypatch.setattr(backend, "_activate_owned_window", lambda hwnd: False) + monkeypatch.setattr(w, "hot_key", lambda value, before_dispatch=None: keys.append(value)) + + with pytest.raises(WindowSafetyError): + backend._send_hotkey(["enter"], "captcha_confirm", expected_popup=200) + + assert keys == [] + + +def test_generation_change_during_hotkey_delay_aborts_before_dispatch(monkeypatch): + backend = _bound_backend(monkeypatch) + keys = [] + monkeypatch.setattr(w, "_foreground_window", lambda: 100) + + def delayed_hotkey(value, before_dispatch=None): + backend.invalidate_inflight() + before_dispatch() + keys.append(value) + + monkeypatch.setattr(w, "hot_key", delayed_hotkey) + + result = backend._run_guarded( + lambda: backend._send_hotkey(["enter"], "test_submit") + ) + + assert keys == [] + assert result["code"] == "aborted" + + +def test_click_rechecks_focus_after_pointer_move_before_button_down(monkeypatch): + backend = _bound_backend(monkeypatch) + events = [] + monkeypatch.setattr(w, "win32api", _mouse_api(events), raising=False) + monkeypatch.setattr(w, "win32con", _con(), raising=False) + monkeypatch.setattr(backend, "_activate_bound_window", lambda: True) + monkeypatch.setattr(w, "_foreground_window", lambda: 999) + + with pytest.raises(WindowSafetyError): + backend._click_screen(12, 34, "test_click") + + assert events == [("move", (12, 34))] + + +def test_direct_button_message_requires_control_from_bound_process(monkeypatch): + backend = _bound_backend(monkeypatch) + events = [] + monkeypatch.setattr(w, "win32api", _mouse_api(events), raising=False) + monkeypatch.setattr(w, "win32con", _con(), raising=False) + + with pytest.raises(WindowSafetyError): + backend._post_owned_button_click(999, "test_button") + + assert events == [] + + +def test_cancel_stops_before_second_click_when_focus_guard_rejects_it(monkeypatch): + backend = _bound_backend(monkeypatch) + clicks = [] + monkeypatch.setattr(backend, "switch_to_normal", lambda **kwargs: None) + monkeypatch.setattr(backend, "_send_hotkey", lambda *args, **kwargs: None) + monkeypatch.setattr(backend, "refresh", lambda **kwargs: None) + monkeypatch.setattr(backend, "get_right_hwnd", lambda: 10) + monkeypatch.setattr(backend, "_find_grid", lambda hwnd: 20) + monkeypatch.setattr( + backend, + "read_table_text", + lambda hwnd: "委托编号\t证券代码\t\r\n42\t600000\t\r\n", + ) + monkeypatch.setattr(backend, "_require_owned_window_for_input", lambda *args: None) + monkeypatch.setattr(w, "win32gui", SimpleNamespace(GetWindowRect=lambda hwnd: (0, 0, 100, 100)), raising=False) + monkeypatch.setattr(w, "sleep_time", 0) + + def click(*args): + clicks.append(args) + if len(clicks) == 2: + raise WindowSafetyError("focus lost before second click") + + monkeypatch.setattr(backend, "_click_screen", click) + result = backend._do_cancel("42") + + assert len(clicks) == 2 + assert result["status"] == "failed" + assert result["data"]["submitted"] is False + + +def test_query_navigation_keeps_its_existing_unblocked_mode(monkeypatch): + backend = WinThsBackend() + events = [] + monkeypatch.setattr(backend, "_require_owned_window_for_input", lambda *args: pytest.fail("read was blocked")) + monkeypatch.setattr(backend, "get_left_bottom_tabs", lambda: 33) + monkeypatch.setattr(w, "win32api", _mouse_api(events), raising=False) + monkeypatch.setattr(w, "win32con", _con(), raising=False) + monkeypatch.setattr(w, "win32gui", SimpleNamespace(GetWindowRect=lambda hwnd: (0, 0, 100, 100)), raising=False) + monkeypatch.setattr(w, "_activate_window", lambda hwnd: None) + monkeypatch.setattr(w, "sleep_time", 0) + + backend.switch_to_normal() + + assert [event[0] for event in events] == ["move", "mouse", "mouse"] diff --git a/tests/test_ws_account_event.py b/tests/test_ws_account_event.py new file mode 100644 index 0000000..edb961c --- /dev/null +++ b/tests/test_ws_account_event.py @@ -0,0 +1,65 @@ +"""连接后账户列表提示的回归测试。""" +import asyncio +import json + +from trader import contract +from trader.ws_client import ConnectionState, WsClient + + +class _Socket: + def __init__(self): + self.sent = [] + + async def send(self, raw): + self.sent.append(json.loads(raw)) + + +class _Backend: + def __init__(self, result): + self.win_lock = asyncio.Lock() + self.result = result + self.calls = 0 + + async def list_accounts(self): + self.calls += 1 + return self.result + + +def _connected_client(result): + backend = _Backend(result) + client = WsClient(backend=backend) + client.state = ConnectionState.CONNECTED + client.ws = _Socket() + client._connection_generation = 1 + return client, backend + + +def test_connected_client_pushes_available_accounts_once(): + client, backend = _connected_client(contract.ok({ + "accounts": [{"slot": 1, "shortcut": "Alt+1", "text": "示例券商-甲*乙"}], + "current_account_text": "示例券商 甲*乙", + "partial": False, + })) + + asyncio.run(client._publish_accounts_after_connected()) + asyncio.run(client._publish_accounts_after_connected()) + + assert backend.calls == 1 + assert client.ws.sent[0]["type"] == "account_event" + assert client.ws.sent[0]["event"] == "available" + assert client.ws.sent[0]["accounts"][0]["slot"] == 1 + assert client.ws.sent[0]["current_account_text"] == "示例券商 甲*乙" + + +def test_connected_client_reports_unavailable_accounts_without_trading(): + client, backend = _connected_client(contract.fail( + "read_failed", "read_failed", "账户下拉框不可读", + data={"accounts": [], "current_account_text": None, "partial": True}, + )) + + asyncio.run(client._publish_accounts_after_connected()) + + assert backend.calls == 1 + assert client.ws.sent[0]["event"] == "unavailable" + assert client.ws.sent[0]["accounts"] == [] + assert client.ws.sent[0]["message"] == "账户下拉框不可读" diff --git a/tests/test_ws_logging.py b/tests/test_ws_logging.py new file mode 100644 index 0000000..f4328c3 --- /dev/null +++ b/tests/test_ws_logging.py @@ -0,0 +1,89 @@ +"""WebSocket 本地审计日志:保留诊断信息,但任何凭证都不能落盘。""" +import asyncio +import logging + +from trader import ws_client + + +class FakeWs: + def __init__(self): + self.sent = [] + + async def send(self, raw): + self.sent.append(raw) + + +def test_audit_json_redacts_secret_values_and_keeps_trading_fields(): + rendered = ws_client._audit_json({ + "type": "call", + "params": { + "stock_no": "600000", + "amount": 100, + "agent_token": "agent-secret", + "nested": {"confirmation_token": "confirm-secret"}, + }, + }) + + assert '"stock_no": "600000"' in rendered + assert '"amount": 100' in rendered + assert "agent-secret" not in rendered + assert "confirm-secret" not in rendered + assert rendered.count("") == 2 + + +def test_pair_pending_code_is_redacted(): + rendered = ws_client._audit_json({ + "type": "pair_pending", "code": "123456", "expires_at": "soon", + }) + + assert "123456" not in rendered + assert '"code": ""' in rendered + + +def test_rpc_audit_logs_full_sanitized_request_and_reply(monkeypatch, caplog): + async def fake_handle_call(frame, _backend): + return { + "type": "reply", + "id": frame["id"], + "ok": True, + "result": { + "status": "succeed", + "data": { + "entrust_no": "E-001", + "confirmation_token": "reply-secret", + }, + }, + } + + monkeypatch.setattr(ws_client.dispatcher, "handle_call", fake_handle_call) + client = ws_client.WsClient(backend=object()) + client.ws = FakeWs() + frame = { + "type": "call", + "id": "rpc-audit-1", + "method": "buy", + "params": { + "stock_no": "600000", + "amount": 100, + "order_type": "LIMIT", + "price": 10.5, + "agent_token": "request-secret", + }, + } + + caplog.set_level(logging.INFO, logger="trader.ws_client") + + async def drive(): + await client._handle_frame(frame) + await asyncio.gather(*list(client._call_tasks)) + + asyncio.run(drive()) + + rendered = caplog.text + assert "[WS<-] received=" in rendered + assert "[WS->] reply_ready id=rpc-audit-1 method=buy" in rendered + assert "[WS->] reply_written id=rpc-audit-1 method=buy" in rendered + assert '"stock_no": "600000"' in rendered + assert '"entrust_no": "E-001"' in rendered + assert "request-secret" not in rendered + assert "reply-secret" not in rendered diff --git a/tests/test_ws_send_frame.py b/tests/test_ws_send_frame.py new file mode 100644 index 0000000..4ad1ab1 --- /dev/null +++ b/tests/test_ws_send_frame.py @@ -0,0 +1,90 @@ +"""WsClient.send_frame 发送结果的回归测试。""" +import asyncio + +from websockets.protocol import State + +from trader import ws_client + + +class ClosedWs: + state = State.CLOSED + + def __init__(self): + self.send_called = False + + async def send(self, _raw): + self.send_called = True + + +class FailingWs: + async def send(self, _raw): + raise RuntimeError("simulated write failure") + + +class SendingWs: + def __init__(self): + self.sent = [] + + async def send(self, raw): + self.sent.append(raw) + + +class BlockingWs: + def __init__(self): + self.send_started = asyncio.Event() + self.release_send = asyncio.Event() + + async def send(self, _raw): + self.send_started.set() + await self.release_send.wait() + + +def _client(): + return ws_client.WsClient(backend=object()) + + +def test_send_frame_returns_false_when_unconnected_or_closed(): + client = _client() + assert asyncio.run(client.send_frame({"type": "order_event"})) is False + + closed = ClosedWs() + client.ws = closed + assert asyncio.run(client.send_frame({"type": "order_event"})) is False + assert closed.send_called is False + + +def test_send_frame_returns_false_when_write_raises(): + client = _client() + client.ws = FailingWs() + + assert asyncio.run(client.send_frame({"type": "order_event"})) is False + + +def test_send_frame_returns_true_after_successful_write(): + client = _client() + ws = SendingWs() + client.ws = ws + + assert asyncio.run(client.send_frame({"type": "order_event"})) is True + assert len(ws.sent) == 1 + + +def test_send_frame_returns_false_when_connection_changes_during_write(): + async def drive(): + client = _client() + old_ws = BlockingWs() + client.ws = old_ws + + sending = asyncio.create_task(client.send_frame({"type": "order_event"})) + await old_ws.send_started.wait() + + # run() may have torn down the old socket and established a new one while + # send_frame was awaiting the old write. The frame must remain retryable. + client.ws = SendingWs() + client._connection_generation += 1 + old_ws.release_send.set() + + assert await sending is False + assert client.ws.sent == [] + + asyncio.run(drive())