From c051228e2641d7cca963e728734f921c001b8ce3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=AD=99=E6=B4=8B?= Date: Mon, 13 Jul 2026 15:01:03 +0800 Subject: [PATCH 1/2] =?UTF-8?q?docs(spec):=20=E5=90=8C=E8=8A=B1=E9=A1=BA?= =?UTF-8?q?=E4=BA=A4=E6=98=93=E5=BC=B9=E7=AA=97=E5=A4=84=E7=90=86=E4=B8=8E?= =?UTF-8?q?=E8=B6=85=E6=97=B6=E8=AF=AD=E4=B9=89=E4=BF=AE=E5=A4=8D=E8=AE=BE?= =?UTF-8?q?=E8=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 事故驱动(2026-07-13 委托确认弹窗卡死、MCP 报错但实际成交)。 根因实证:市价提交用 SendMessage(BM_CLICK) 遇模态框死锁 + to_thread 无超时/win_lock 饿死/WS 消息循环内联 await 放大为全端瘫痪。 方案:P0 防卡死(PostMessage 化 + 25s 总超时 + 循环解耦)、 P1 弹窗看门人(识别-分类-处置-回执)、P2 引导与真机回归、 网关超时回执改 unknown 语义。 Co-Authored-By: Claude Fable 5 --- .../2026-07-13-ths-dialog-handling-design.md | 243 ++++++++++++++++++ 1 file changed, 243 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-13-ths-dialog-handling-design.md diff --git a/docs/superpowers/specs/2026-07-13-ths-dialog-handling-design.md b/docs/superpowers/specs/2026-07-13-ths-dialog-handling-design.md new file mode 100644 index 0000000..2152cc1 --- /dev/null +++ b/docs/superpowers/specs/2026-07-13-ths-dialog-handling-design.md @@ -0,0 +1,243 @@ +# 同花顺交易弹窗处理与超时语义修复 — 设计 + +- 日期:2026-07-13 +- 状态:待确认(根因已实证于代码,方案默认项可调整) +- 事故驱动:2026-07-13 14:42 真实成交事故(见 §1) + +## 1. 事故还原 + +1. MCP 调 `sell`(300458,500 股,市价/五档即成剩撤,不带 price)。 +2. 同花顺弹出「委托确认」模态对话框(股东账号/证券代码/委托策略/最新价/数量,是(Y)/否(N))。 +3. 委托流程卡死在弹窗上;30 秒后云端网关向调用方返回 + `MCP error -32003: 指令下发超时:Windows 交易受控端在 30 秒内未响应`。 +4. 用户 14:42:34 手动点「是」,订单继续并成交 @39.56。 +5. 暴露的最危险行为:**MCP 报错,但订单实际成交**——调用方凭报错补单即双倍下单。 + 无人值守场景下该委托会永远卡死,且(见 §2.3)**整个受控端瘫痪**。 + +## 2. 根因分析(三层,均已在代码中实证) + +### 2.1 直接根因:`SendMessage(BM_CLICK)` 遇模态弹窗死锁 + +`_submit_market_trade`(`src/trader/ths/win.py:1415`)点提交按钮用的是: + +```python +win32api.SendMessage(submit_btn, win32con.BM_CLICK, 0, 0) +``` + +`SendMessage` 是**同步跨进程调用**:xiadan 的按钮 `WM_COMMAND` handler 弹出模态 +「委托确认」框后进入模态消息循环、不返回 → Python 线程永远卡在这一行, +后面本该关弹窗的 `hot_key(["enter"])`(win.py:1418)**根本没有执行机会**。 +用户点「是」→ handler 返回 → 流程才继续——与事故时间线完全吻合。 + +对照组:批量撤单 `_bulk_cancel`(win.py:626)用的是 `PostMessage(BM_CLICK)`, +异步投递、不会卡。市价单路径是后来引入的不一致。同类隐患还有一处: +`input_ocr` 点验证码确定按钮(win.py:1771)也是 `SendMessage(BM_CLICK)`。 + +> 为什么用户「已在客户端关闭确认弹窗」只是临时规避:任何**未被设置覆盖的**模态框 +> (废单提示、风险警示、身份验证等,见 §5.2)都会再次触发同一死锁。 + +### 2.2 弹窗处理本质是「盲按 Enter」 + +限价(`_submit_trade` win.py:1308-1311)与市价路径提交后都是固定节奏连按 +Enter:不识别弹窗类型、不读内容、依赖「弹窗恰好在前台且默认按钮恰好是确认」。 +后果: + +- 确认框没获得焦点 / 出现晚于按键 → Enter 落空,流程带着未处理的弹窗往下走; +- 废单/错误提示的**真实原因带不回回执**(`get_result` 有读取「提示」框文本的 + 雏形(win.py:1498),但下单路径根本没调用它),调用方只能拿到超时或 unknown; +- 风险警示类弹窗被无差别 Enter,等价于「自动确认一切」,只是碰巧常常按不中。 + +### 2.3 系统性放大:单笔卡死 → 全端瘫痪 + 回执永不发出 + +- `backend.sell/buy/cancel` 经 `asyncio.to_thread` 执行,**无任何超时** + (win.py:1873-1905); +- 卡死线程持有 `backend.win_lock`(dispatcher.py:230)**永不释放**, + 后续所有交易/查询排队饿死——包括本可用来核单的 `orders_filled`; +- `ws_client._handle_frame` 对 `call` 帧是**内联 await**(ws_client.py:329), + 单笔 RPC 卡死连 WS 消息循环一起停摆,受控端不再处理任何后续帧; +- 受控端发不出 reply → 云端网关 30s 超时自造 `-32003` 裸错误, + 不含「委托可能已提交」语义 → 「报错但静默成交」。 + +## 3. 方案总览:三道防线 + 协议语义修复 + +| 优先级 | 内容 | 解决 | 规模 | +|---|---|---|---| +| P0 防卡死 | PostMessage 化 + 调用超时 + 消息循环解耦 | §2.1 §2.3 | 小,纯受控端 | +| P1 弹窗看门人 | 识别-分类-处置-回执 | §2.2 及关不掉的弹窗 | 中,核心工作 | +| P2 引导与回归 | 设置清单 + 启动提示 + 真机回归 | 需求 2、4 | 文档+真机验证 | +| 协议 | unknown 语义贯穿到网关回执 | 需求 3 | 受控端小改 + 网关侧需求 | + +三道防线独立生效:P0 保证**任何**未预见弹窗都不再无限期卡死;P1 让已知弹窗 +得到正确处置、真实原因进回执;P2 减少弹窗出现的机会并验证全链路。 + +## 4. P0:防卡死(必做,先行合入) + +### 4.1 跨进程点击一律 `PostMessage` + +- win.py:1415(市价提交按钮)、win.py:1771(验证码确定按钮): + `SendMessage(BM_CLICK)` → `PostMessage(BM_CLICK)`,与 `_bulk_cancel` 对齐。 +- 立新规写入 `ths_architecture.md`:**对 xiadan 的任何可能触发弹窗的动作 + (按钮、菜单)禁止同步 `SendMessage`**;读文本类消息(`WM_GETTEXT` 等) + 改用 `SendMessageTimeout(SMTO_ABORTIFHUNG, ~1s)` 兜底(弹窗挂起时读控件 + 同样可能阻塞)。 + +### 4.2 受控端调用总超时(低于网关 30s) + +`dispatcher.handle_call` 对交易方法包 `asyncio.wait_for(…, timeout=25)`: + +- 超时回执固定为: + ```json + {"code": 2, "status": "unknown", + "msg": "受控端处理超时(疑似弹窗阻塞)。委托可能已提交,请调 orders_filled/orders_active 核实后再决定,勿直接重复下单"} + ``` +- `wait_for` 超时**不会杀掉**卡住的工作线程 → 配套两点: + - `win_lock.acquire()` 也包超时(~5s):拿不到锁直接回 + `{"code": 2, "status": "busy", "msg": "受控端正忙或被弹窗阻塞,请稍后重试并先核单"}`, + 而不是排队饿死; + - 超时发生后置 `backend.degraded` 标志,下一次任何调用进入前先跑一轮 + P1 的弹窗清扫(§5.4),尝试解除阻塞并自愈。 + +### 4.3 WS 消息循环解耦 + +`ws_client._handle_frame` 对 `call` 帧改 `asyncio.create_task` 执行 +(reply 在 task 内发送)。执行顺序不受影响——交易/查询本就由 `win_lock` +(asyncio.Lock,FIFO)串行。收益:单笔慢/卡的 RPC 不再阻塞心跳外的 +一切帧处理,核单查询永远进得来。 + +## 5. P1:弹窗看门人(DialogSentry) + +### 5.1 发现机制 + +复用既有同构代码(`get_result` / `get_ocr_hwnd` 的 `EnumThreadWindows` 模式): +枚举 xiadan 主窗口线程的顶层可见、enabled 窗口,排除主窗口自身;对每个候选 +读窗口标题 + 递归收集所有 `Static` 文本,合并为「弹窗指纹」供分类。 +不依赖 `#32770` 类名精确匹配(xiadan 部分弹窗是自绘类,文本才是稳定锚点; +easytrader 同样以标题/文本关键词分类,见 §8 调研结论)。 + +### 5.2 分类-处置矩阵 + +| 类别 | 识别依据(标题/文本关键词,进 `const.py` 可维护) | 动作 | 回执 | +|---|---|---|---| +| 确认类 | 标题含「委托确认」「交易确认」「撤单确认」等,且文本中**能匹配到本笔委托的证券代码与数量** | 点「是(Y)」:按钮文本匹配(是/确定)→ `PostMessage(BM_CLICK)`;兜底按控件 ID `IDYES=6`/`IDOK=1` | 流程继续 | +| 确认类·内容不符 | 同上但代码/数量对不上(**防误确认别人的单**) | 点「否(N)」中止 | `code=1 failed` + 弹窗全文 | +| 结果/成功类 | 「提示」框文本含「已成功提交」「合同编号」 | 提取 `entrust_no` 后点「确定」(收编既有 `get_result`) | `entrust_no` 进回执 | +| 废单/错误类 | 「提示」框文本含废单/错误特征(可用资金不足、超出涨跌幅、数量不符、废单…) | 提取全文后点「确定」 | `code=1 failed` + **真实原因原文** | +| 验证码类 | 「检测到您正在拷贝数据」/ 身份验证特征 | 既有 `input_ocr` 流程 | — | +| 风险警示类 | 文本含 ST/退市/风险警示/价格笼子/跌停…(关键词表待真机补全) | **默认点「否」中止**;提供配置 `dialog_risk_policy: abort(默认) \| accept` | `code=1, status="risk_warning"` + 弹窗全文,交调用方决策 | +| 未知类 | 兜底 | **不动作**,截图存证到 `work_dir`(复用 `capture_window`) | `code=2 unknown` + 弹窗标题与文本 | + +误识别风险控制的两条铁律: + +1. **自动点「是」仅限确认类,且必须通过内容匹配**(本笔证券代码 + 数量出现在 + 弹窗文本中);匹配不上一律降级按未知类处理。 +2. 未知弹窗**宁可 unknown 也不盲点**——unknown + 核单指引是安全态, + 盲点是事故态。 + +### 5.3 时机:用「等待-识别-处理」循环取代盲 Enter + +`_submit_trade` / `_submit_market_trade` / `_cancel_inner` 提交动作后: + +``` +deadline = now + 5s +loop every 0.1s: + dialogs = sentry.scan() + 无弹窗且已拿到结果特征 → break + 有弹窗 → 按 §5.2 处置;处置产生终态回执(failed/risk_warning)→ 直接返回 +``` + +随后才进入现有的回查(`_lookup_entrust_no` / 成交表差分)。删除三连盲 Enter。 + +### 5.4 查询路径与自愈清扫 + +- 查询读表失败(`read_table_text` 返回 None)时先 `sentry.scan()` 清一轮 + 弹窗再重试——替代现在「可能验证码弹窗」的猜测式报错; +- §4.2 的 degraded 自愈入口调用同一清扫:确认类残留弹窗此时**只点「否」** + (那笔委托已按 unknown 上报,不能事后偷偷替用户确认)。 + +## 6. P2:引导、检测与回归 + +### 6.1 文档 + 启动提示(关闭弹窗设置清单) + +README 与启动界面(`main.py` 已有 `_check_xiadan_running` 检查链,同处追加 +一条提示)给出精确清单。外部调研(§8)给出的路径为「xiadan 系统设置 → +快速交易:委托前是否需要确认 = 否」等,**确切措辞与逐项名称必须真机核对后 +定稿**(外部资料确证度中等)。同时明确告知: + +- 关不掉的弹窗:拷贝数据验证码、废单/错误提示、风险警示(ST/价格笼子)、 + 身份验证——遇到时受控端按 §5.2 处置,最坏回 unknown + 核单指引; +- 关闭确认弹窗后,委托确认由 AI 调用方承担——工具描述里已有 + 「会真实下单,慎重调用」声明,维持现状。 + +### 6.2 设置状态检测(尽力而为,不承诺) + +xiadan 的设置疑似存于安装目录本地文件(ini/dat),**能否程序化读取需真机 +dump 验证**,列为待验证项。可靠的退路(无论检测成不成都做): +受控端在**首次捕获到「委托确认」弹窗**时,除正常处置外,在回执 `msg` 与 +本地日志中附一句「检测到委托确认弹窗,建议在 xiadan 关闭下单确认以降低 +延迟与风险(路径见文档)」——把「检测」从读配置改为读事实。 + +### 6.3 真机回归清单(用户已关弹窗配置) + +| 用例 | 路径 | 验证点 | +|---|---|---| +| 市价买 / 市价卖 | `_submit_market_trade` | 无确认弹窗残留;回执 filled/partially_filled 真实 | +| 限价买 / 限价卖 | `_submit_trade` | 同上;entrust_no 正确 | +| 单笔撤单 / 批量撤 | `_cancel_inner` / `_bulk_cancel` | 撤单确认框是否被设置覆盖(存疑,须实测) | +| 废单场景 | 限价单价格超涨跌幅 | 回执带真实废单原因,非 unknown/超时 | +| 风险警示场景 | ST 股小额限价单 | 默认 abort:回 `risk_warning` + 全文,不成交 | +| 验证码场景 | 连续高频查询触发 | `input_ocr` 通过,查询正常返回 | + +每例记录:出现的弹窗截图、回执 JSON、耗时。产出「关闭设置覆盖不到的弹窗 +清单」回填 §6.1 文档与 §5.2 关键词表。 + +> 本设计在 macOS 环境完成,以上回归需 Windows + xiadan 真机执行。 + +## 7. 协议语义修复(需求 3,两端) + +- **受控端**(本仓库):§4.2 已保证弹窗/阻塞场景下 25s 内必有 + `status: unknown` 回执,先于网关 30s 超时——正常情况下 `-32003` 不应再出现。 +- **网关侧**(`guling-mcp-gateway`,不在本仓库):提需求——30s 兜底超时的 + MCP 回执不得为裸错误,必须为 + 「`status: unknown`:受控端未响应,委托**可能已提交**,请先调 + `orders_filled` / `orders_active` 核实,禁止直接补单」。 +- **PROTOCOL.md** 增补条款:网关对 `call` 超时的翻译规范(同上语义), + 与既有 `code == 2` 条款并列。 + +## 8. 外部调研结论(摘要) + +- easytrader(`pop_dialog_handler.py` / `clienttrader.py`):轮询检测弹窗 → + 标题关键词分类(委托确认/提示/提示信息)→ 按钮文本点击(确定/是, + pywinauto 封装)→ 正则从「提示」文本提取合同编号或返回废单原因。 + 验证了 §5 的分类-处置-提取套路是行业成熟路径。 +- easytrader 对「模态弹窗卡死」**没有正式解法**(Issue #452 只有问题报告); + 其推荐路线同样是客户端关闭委托确认。本设计的 §4.1(PostMessage 化) + 即是该缺口的解。 +- 客户端设置:「系统 → 快速交易」可关委托前确认/下单提示;验证码、废单 + 提示、风险警示类普遍**关不掉**(部分为社区资料推测,§6.3 真机核对)。 +- Windows 标准对话框按钮 ID:`IDOK=1 / IDCANCEL=2 / IDYES=6 / IDNO=7`, + 作为按钮文本匹配失败时的兜底(xiadan 弹窗是否全为标准 `#32770` 未确证, + 故文本匹配为主、ID 为辅)。 + +## 9. 验收标准对照 + +| 验收项 | 由谁满足 | +|---|---| +| 任一弹窗场景,回执如实报成交状态或明确 unknown + 核单指引 | P1 分类回执 + P0 超时回执 | +| 无人值守委托不允许无限期卡死 | P0(PostMessage + 25s 总超时 + 锁超时 + 消息循环解耦) | +| 超时后受控端主动上报卡死原因 | P0 degraded 标志 + P1 清扫时把残留弹窗文本入日志/回执 | +| 「报错但静默成交」不再发生 | 受控端 25s unknown 先于网关 30s;网关兜底文案改 unknown 语义 | + +## 10. 实施顺序 + +1. **P0 全部 + PROTOCOL.md 条款**(半天级,纯代码 + 单测,可先行合入); +2. **P1 DialogSentry**(主体工作:新模块 + 三条下单/撤单路径改造 + 单测 + 打桩弹窗指纹;关键词表初版取自 §8,真机校准); +3. **P2 真机回归**(依赖 1、2 合入后的 Windows 实测,回填文档与关键词表); +4. 网关侧需求单独提给 `guling-mcp-gateway`。 + +## 11. 已选默认项(可改,改动只影响 const/config) + +- 风险警示类默认 `abort`(点「否」+ 上报全文)——把风险决策还给调用方; + 若确认希望无人值守时自动通过某些警示,用白名单关键词开 `accept`。 +- 受控端总超时 25s、锁等待 5s、弹窗等待循环 5s。 +- 未知弹窗不动作、只截图 + unknown 上报。 From 7bffe0769925bce202451594d03e7e1fb625a964 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=AD=99=E6=B4=8B?= Date: Mon, 13 Jul 2026 15:28:56 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(ths):=20=E5=BC=B9=E7=AA=97=E7=BB=93?= =?UTF-8?q?=E6=9E=84=E5=8C=96=E5=A4=84=E7=BD=AE=20+=20=E9=98=B2=E5=8D=A1?= =?UTF-8?q?=E6=AD=BB=20+=20=E8=B6=85=E6=97=B6=20unknown=20=E8=AF=AD?= =?UTF-8?q?=E4=B9=89=EF=BC=882026-07-13=20=E4=BA=8B=E6=95=85=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 根因修复: - 市价提交/验证码确定按钮 SendMessage(BM_CLICK)→PostMessage:同步跨进程 调用遇模态「委托确认」框死锁是本次委托卡死的直接根因 - dispatcher 单笔调用 25s 总超时(低于网关 30s):下单类超时回 code=2 status=unknown +「先核单勿补单」;win_lock 拿锁 5s 超时回 busy; 超时置 degraded,下次调用先跑弹窗清扫自愈 - ws_client call 帧改 create_task:单笔 RPC 卡死不再瘫痪整个消息循环 弹窗看门人(ths/dialogs.py DialogSentry): - 下单/撤单提交后用 pump()「等待-发现-处置」取代三连盲 Enter - 处置不耦合弹窗内容:含 Edit→input_ocr;肯定按钮(是>确定>确认>同意>唯一) →PostMessage 点击;无按钮→向弹窗投递回车(真机验证);两次回车不消才 WM_CLOSE;全程禁 ESC(对确认框语义是否) - 每个弹窗截图存证 + 标题/全文/动作记入回执 dialogs 字段;机会性提取 合同编号;回查失败时弹窗原文进回执(废单真实原因不再丢失) 文档与工具:spec 定稿、PROTOCOL.md 网关超时语义条款、架构文档 7.7 节 PostMessage 铁律、README 弹窗设置建议、tools/ths_dialog_dump.py 真机 弹窗结构诊断脚本。测试:106 passed(新增弹窗决策/超时语义/消息循环 16 例) Co-Authored-By: Claude Fable 5 --- README.md | 1 + docs/PROTOCOL.md | 20 ++ .../2026-07-13-ths-dialog-handling-design.md | 100 ++++--- docs/ths_architecture.md | 23 ++ src/trader/dispatcher.py | 114 +++++--- src/trader/ths/const.py | 6 + src/trader/ths/dialogs.py | 265 ++++++++++++++++++ src/trader/ths/win.py | 105 ++++--- src/trader/ws_client.py | 60 ++-- tests/test_dialogs.py | 84 ++++++ tests/test_dispatcher_timeout.py | 93 ++++++ tests/test_ws_call_nonblocking.py | 54 ++++ tools/ths_dialog_dump.py | 67 +++++ uv.lock | 2 +- 14 files changed, 846 insertions(+), 148 deletions(-) create mode 100644 src/trader/ths/dialogs.py create mode 100644 tests/test_dialogs.py create mode 100644 tests/test_dispatcher_timeout.py create mode 100644 tests/test_ws_call_nonblocking.py create mode 100644 tools/ths_dialog_dump.py diff --git a/README.md b/README.md index fdab374..2e021ce 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,7 @@ guling-trader 与同花顺、MCP 三合一,是这一范式落地 A 股的开 你需要一台**7×24 运行的 Windows 机器**(物理机、云 VPS,或 Mac 虚拟机如 Parallels Desktop)。 1. **登录同花顺**:打开同花顺独立委托客户端(`xiadan.exe`),用你的证券账户登录,停留在下单主页。**新版 / 旧版皮肤均可**——v0.5.0 起自动适配控件,无需再手动切"旧版"。请勿最小化。 + - **建议关闭下单确认弹窗**(更快更稳):在 xiadan 的系统设置中把「委托前确认/下单确认提示」类选项关掉(不同券商版本措辞略有差异)。关不掉的弹窗(验证码、废单提示、风险警示等)不用管——助手会自动处理并把弹窗内容记录进回执。 2. **运行交易助手**:从 [GitHub Releases](https://github.com/Guling-Pro/guling-trader/releases/latest/download/guling-trader.exe) 下载 `guling-trader.exe`(单文件免安装),双击运行。 - 首次启动会自动静默安装 Tesseract OCR(图形识别环境),无感进行。 diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index 1ed2b27..147363a 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -150,6 +150,26 @@ Crucial safety safeguard for network jitter, verification popups, or delay in lo ``` *Relays/gateways MUST preserve this detailed error text to prevent the AI from mistaking this as a trade failure and issuing a duplicated buy order.* +Since v0.7 the trader may additionally return `"status": "busy"` with `code == 2` +(window lock contention — the command was **not** executed; retry after +verifying pending orders), and any reply may carry a `dialogs` array recording +client popups the trader auto-dismissed while executing the command +(`[{"title", "text", "action"}]`, forensic evidence — no action required). + +#### Gateway-side call timeout (MANDATORY semantics): +The trader answers every order command within its internal 25 s budget — +deliberately below a gateway's typical 30 s wait. If a gateway's own timeout +still fires with no `reply` (trader offline, network loss), the gateway MUST +NOT surface a bare transport error (e.g. `-32003 指令下发超时`): a missing +reply after an order command means the order **may have been submitted**. The +MCP tool result MUST carry unknown-semantics text equivalent to: + +> `status: unknown`:受控端未在时限内响应,委托**可能已提交**。请先调用 +> `orders_filled` / `orders_active` 核实,**禁止直接重复下单**。 + +Rationale: on 2026-07-13 a bare timeout error while the order actually filled +("报错但静默成交") nearly caused a duplicated-order incident. + #### Ordinary failure response (`code == 1`): ```json { diff --git a/docs/superpowers/specs/2026-07-13-ths-dialog-handling-design.md b/docs/superpowers/specs/2026-07-13-ths-dialog-handling-design.md index 2152cc1..c5be8ac 100644 --- a/docs/superpowers/specs/2026-07-13-ths-dialog-handling-design.md +++ b/docs/superpowers/specs/2026-07-13-ths-dialog-handling-design.md @@ -1,7 +1,7 @@ # 同花顺交易弹窗处理与超时语义修复 — 设计 - 日期:2026-07-13 -- 状态:待确认(根因已实证于代码,方案默认项可调整) +- 状态:已确认并实现(P0/P1 随本 PR 落码;P2 真机回归待用户配合执行) - 事故驱动:2026-07-13 14:42 真实成交事故(见 §1) ## 1. 事故还原 @@ -63,7 +63,7 @@ Enter:不识别弹窗类型、不读内容、依赖「弹窗恰好在前台且 | 优先级 | 内容 | 解决 | 规模 | |---|---|---|---| | P0 防卡死 | PostMessage 化 + 调用超时 + 消息循环解耦 | §2.1 §2.3 | 小,纯受控端 | -| P1 弹窗看门人 | 识别-分类-处置-回执 | §2.2 及关不掉的弹窗 | 中,核心工作 | +| P1 弹窗看门人 | 发现-处置-存证(内容解耦) | §2.2 及关不掉的弹窗 | 中,核心工作 | | P2 引导与回归 | 设置清单 + 启动提示 + 真机回归 | 需求 2、4 | 文档+真机验证 | | 协议 | unknown 语义贯穿到网关回执 | 需求 3 | 受控端小改 + 网关侧需求 | @@ -104,55 +104,63 @@ Enter:不识别弹窗类型、不读内容、依赖「弹窗恰好在前台且 (asyncio.Lock,FIFO)串行。收益:单笔慢/卡的 RPC 不再阻塞心跳外的 一切帧处理,核单查询永远进得来。 -## 5. P1:弹窗看门人(DialogSentry) +## 5. P1:弹窗看门人(DialogSentry,`src/trader/ths/dialogs.py`) + +**总原则(用户 2026-07-13 定)**:xiadan 出现任何弹窗,都以**肯定**方式快速 +消除、回到既定操作轨道;处理逻辑**不耦合弹窗内容**——不读正文做语义分类, +弹窗标题/全文只做**存证**(记入回执 `dialogs` 字段与日志),让调用方与用户 +事后知道流程中发生过什么。安全性不靠读懂弹窗,靠既有的成交表/委托表回查。 + +> 曾考虑过「标题/文本关键词分类 + 分场景点是/否」的矩阵式方案,被否决: +> 关键词表随版本/券商漂移、维护成本高,且误分类的后果比「肯定+核查」更糟。 ### 5.1 发现机制 复用既有同构代码(`get_result` / `get_ocr_hwnd` 的 `EnumThreadWindows` 模式): 枚举 xiadan 主窗口线程的顶层可见、enabled 窗口,排除主窗口自身;对每个候选 -读窗口标题 + 递归收集所有 `Static` 文本,合并为「弹窗指纹」供分类。 -不依赖 `#32770` 类名精确匹配(xiadan 部分弹窗是自绘类,文本才是稳定锚点; -easytrader 同样以标题/文本关键词分类,见 §8 调研结论)。 +收集结构指纹:窗口标题、全部 `Static` 文本(存证用)、全部 `Button` 的 +归一化标签("是(Y)"→"是")、是否含 `Edit` 输入框。 -### 5.2 分类-处置矩阵 - -| 类别 | 识别依据(标题/文本关键词,进 `const.py` 可维护) | 动作 | 回执 | -|---|---|---|---| -| 确认类 | 标题含「委托确认」「交易确认」「撤单确认」等,且文本中**能匹配到本笔委托的证券代码与数量** | 点「是(Y)」:按钮文本匹配(是/确定)→ `PostMessage(BM_CLICK)`;兜底按控件 ID `IDYES=6`/`IDOK=1` | 流程继续 | -| 确认类·内容不符 | 同上但代码/数量对不上(**防误确认别人的单**) | 点「否(N)」中止 | `code=1 failed` + 弹窗全文 | -| 结果/成功类 | 「提示」框文本含「已成功提交」「合同编号」 | 提取 `entrust_no` 后点「确定」(收编既有 `get_result`) | `entrust_no` 进回执 | -| 废单/错误类 | 「提示」框文本含废单/错误特征(可用资金不足、超出涨跌幅、数量不符、废单…) | 提取全文后点「确定」 | `code=1 failed` + **真实原因原文** | -| 验证码类 | 「检测到您正在拷贝数据」/ 身份验证特征 | 既有 `input_ocr` 流程 | — | -| 风险警示类 | 文本含 ST/退市/风险警示/价格笼子/跌停…(关键词表待真机补全) | **默认点「否」中止**;提供配置 `dialog_risk_policy: abort(默认) \| accept` | `code=1, status="risk_warning"` + 弹窗全文,交调用方决策 | -| 未知类 | 兜底 | **不动作**,截图存证到 `work_dir`(复用 `capture_window`) | `code=2 unknown` + 弹窗标题与文本 | +### 5.2 处置规则(纯结构,逐级兜底) -误识别风险控制的两条铁律: +1. **含 `Edit` 输入框** → 验证码/身份验证类,回车关不掉(需输入内容)→ + 走既有 `input_ocr()`(内部自带 OCR 重试); +2. **枚举到肯定按钮**(优先级:是 > 确定 > 确认 > 同意 > 唯一按钮)→ + `PostMessage(BM_CLICK)`——等价于"精确版回车":语义同为肯定,但不赌 + 默认按钮是谁、不依赖焦点;多按钮且无肯定项时**绝不主动点否/取消**; +3. **无可用按钮**(自绘弹窗)→ 向弹窗窗口投递回车(`WM_KEYDOWN/UP + VK_RETURN`,非全局按键——不依赖前台、绝不敲进别的窗口)。真机已验证 + 新版皮肤「提示」框吃回车; +4. 回车两次仍不消失 → `WM_CLOSE` 兜底(≈点X),日志大声留痕; +5. **全程禁止 ESC**——在下单/撤单确认框上 ESC 语义是「否/取消」(用户真机 + 验证过 ESC 也能关提示框,但对交易确认框是错误动作,故整体弃用)。 -1. **自动点「是」仅限确认类,且必须通过内容匹配**(本笔证券代码 + 数量出现在 - 弹窗文本中);匹配不上一律降级按未知类处理。 -2. 未知弹窗**宁可 unknown 也不盲点**——unknown + 核单指引是安全态, - 盲点是事故态。 +每个被处置的弹窗:处置前截图存证到 `work_dir`,标题+全文+所采取动作记入 +`PumpResult.dialogs` → 挂到回执。全文中机会性正则提取 `合同编号`(拿不到 +不算失败,回查兜底)。 -### 5.3 时机:用「等待-识别-处理」循环取代盲 Enter +### 5.3 时机:用「等待-发现-处置」循环取代盲 Enter -`_submit_trade` / `_submit_market_trade` / `_cancel_inner` 提交动作后: +`_submit_trade` / `_submit_market_trade` / `_cancel_inner` / `_bulk_cancel` +提交动作后调用 `pump()`: ``` deadline = now + 5s loop every 0.1s: - dialogs = sentry.scan() - 无弹窗且已拿到结果特征 → break - 有弹窗 → 按 §5.2 处置;处置产生终态回执(failed/risk_warning)→ 直接返回 + dialogs = scan() + 连续 0.3s 无弹窗 → 提前落定返回(无弹窗配置下延迟 ≈0.3s) + 有弹窗 → 按 §5.2 处置 + 存证(同一弹窗 0.5s 内不重复点击) ``` -随后才进入现有的回查(`_lookup_entrust_no` / 成交表差分)。删除三连盲 Enter。 +随后照旧进入回查(`_lookup_entrust_no` / 成交表差分)。回查失败但 pump +捕获过弹窗文本时,把原文带进回执(限价路径回 `code=1 failed +「客户端提示: +…」`;市价路径 unknown 的 msg 附原文)——废单真实原因从此进回执。 -### 5.4 查询路径与自愈清扫 +### 5.4 degraded 自愈清扫 -- 查询读表失败(`read_table_text` 返回 None)时先 `sentry.scan()` 清一轮 - 弹窗再重试——替代现在「可能验证码弹窗」的猜测式报错; -- §4.2 的 degraded 自愈入口调用同一清扫:确认类残留弹窗此时**只点「否」** - (那笔委托已按 unknown 上报,不能事后偷偷替用户确认)。 +§4.2 的 degraded 入口调用 `dialog_cleanup()`(= 短预算 `pump()`,同一套 +「肯定+存证」规则):上一笔已按 unknown 上报、调用方被要求核单,无论残留 +弹窗被肯定还是关闭,真相都以核单为准,规则无需分叉。 ## 6. P2:引导、检测与回归 @@ -184,11 +192,11 @@ dump 验证**,列为待验证项。可靠的退路(无论检测成不成都 | 限价买 / 限价卖 | `_submit_trade` | 同上;entrust_no 正确 | | 单笔撤单 / 批量撤 | `_cancel_inner` / `_bulk_cancel` | 撤单确认框是否被设置覆盖(存疑,须实测) | | 废单场景 | 限价单价格超涨跌幅 | 回执带真实废单原因,非 unknown/超时 | -| 风险警示场景 | ST 股小额限价单 | 默认 abort:回 `risk_warning` + 全文,不成交 | +| 风险警示场景 | ST 股小额限价单 | 警示弹窗被肯定后委托继续,回执 `dialogs` 含警示全文 | | 验证码场景 | 连续高频查询触发 | `input_ocr` 通过,查询正常返回 | 每例记录:出现的弹窗截图、回执 JSON、耗时。产出「关闭设置覆盖不到的弹窗 -清单」回填 §6.1 文档与 §5.2 关键词表。 +清单」回填 §6.1 文档;若发现回车/肯定按钮消不掉的弹窗类型,回填 §5.2 兜底规则。 > 本设计在 macOS 环境完成,以上回归需 Windows + xiadan 真机执行。 @@ -208,21 +216,22 @@ dump 验证**,列为待验证项。可靠的退路(无论检测成不成都 - easytrader(`pop_dialog_handler.py` / `clienttrader.py`):轮询检测弹窗 → 标题关键词分类(委托确认/提示/提示信息)→ 按钮文本点击(确定/是, pywinauto 封装)→ 正则从「提示」文本提取合同编号或返回废单原因。 - 验证了 §5 的分类-处置-提取套路是行业成熟路径。 + 其「发现弹窗→点肯定按钮→提取文本」的骨架被 §5 采纳;其**内容关键词 + 分类**被本设计有意放弃(见 §5 总原则)。 - easytrader 对「模态弹窗卡死」**没有正式解法**(Issue #452 只有问题报告); 其推荐路线同样是客户端关闭委托确认。本设计的 §4.1(PostMessage 化) 即是该缺口的解。 - 客户端设置:「系统 → 快速交易」可关委托前确认/下单提示;验证码、废单 提示、风险警示类普遍**关不掉**(部分为社区资料推测,§6.3 真机核对)。 -- Windows 标准对话框按钮 ID:`IDOK=1 / IDCANCEL=2 / IDYES=6 / IDNO=7`, - 作为按钮文本匹配失败时的兜底(xiadan 弹窗是否全为标准 `#32770` 未确证, - 故文本匹配为主、ID 为辅)。 +- xiadan 弹窗是否全为标准 `#32770` 未确证(新版皮肤疑似自绘)→ 按钮标签 + 枚举为主、投递回车为辅(用户真机验证新版「提示」框吃回车); + `tools/ths_dialog_dump.py` 用于逐版本核对弹窗控件结构。 ## 9. 验收标准对照 | 验收项 | 由谁满足 | |---|---| -| 任一弹窗场景,回执如实报成交状态或明确 unknown + 核单指引 | P1 分类回执 + P0 超时回执 | +| 任一弹窗场景,回执如实报成交状态或明确 unknown + 核单指引 | P1 存证回执 + 回查 + P0 超时回执 | | 无人值守委托不允许无限期卡死 | P0(PostMessage + 25s 总超时 + 锁超时 + 消息循环解耦) | | 超时后受控端主动上报卡死原因 | P0 degraded 标志 + P1 清扫时把残留弹窗文本入日志/回执 | | 「报错但静默成交」不再发生 | 受控端 25s unknown 先于网关 30s;网关兜底文案改 unknown 语义 | @@ -230,14 +239,15 @@ dump 验证**,列为待验证项。可靠的退路(无论检测成不成都 ## 10. 实施顺序 1. **P0 全部 + PROTOCOL.md 条款**(半天级,纯代码 + 单测,可先行合入); -2. **P1 DialogSentry**(主体工作:新模块 + 三条下单/撤单路径改造 + 单测 - 打桩弹窗指纹;关键词表初版取自 §8,真机校准); -3. **P2 真机回归**(依赖 1、2 合入后的 Windows 实测,回填文档与关键词表); +2. **P1 DialogSentry**(新模块 `ths/dialogs.py` + 四条下单/撤单路径改造 + + 纯决策逻辑单测;`tools/ths_dialog_dump.py` 供真机核对弹窗控件结构); +3. **P2 真机回归**(依赖 1、2 合入后的 Windows 实测,回填文档与兜底规则); 4. 网关侧需求单独提给 `guling-mcp-gateway`。 ## 11. 已选默认项(可改,改动只影响 const/config) -- 风险警示类默认 `abort`(点「否」+ 上报全文)——把风险决策还给调用方; - 若确认希望无人值守时自动通过某些警示,用白名单关键词开 `accept`。 +- 所有弹窗(含风险警示)一律肯定式消除——**用户已确认**:无人值守时流程 + 不允许停在中途,处理不耦合弹窗内容;知情权由回执 `dialogs` 存证字段保障。 + 因此不设 `dialog_risk_policy` 之类的配置开关。 - 受控端总超时 25s、锁等待 5s、弹窗等待循环 5s。 - 未知弹窗不动作、只截图 + unknown 上报。 diff --git a/docs/ths_architecture.md b/docs/ths_architecture.md index bfdd637..f437f5b 100644 --- a/docs/ths_architecture.md +++ b/docs/ths_architecture.md @@ -163,6 +163,29 @@ xiadan 是 32 位,`TVITEMW` 的 `hItem/pszText/lParam` 是 4 字节;64 位 P 和 `status`(`filled`/`partially_filled`)。8s 内查不到成交 → `status:"unknown"` 并提示**可能非连续 竞价时段/涨跌停被拒/无成交**,绝不当成功。 +## 7.7 交易弹窗处理(DialogSentry)与 PostMessage 铁律 + +**铁律:对 xiadan 任何可能触发弹窗的动作(按钮 `BM_CLICK`、菜单)禁止同步 +`SendMessage`,一律 `PostMessage`。** `SendMessage` 是同步跨进程调用——按钮 +handler 弹出模态框后进入模态消息循环不返回,Python 线程死锁在这一行 +(2026-07-13 市价卖出事故根因,详见 +`docs/superpowers/specs/2026-07-13-ths-dialog-handling-design.md`)。 + +**弹窗处理(`ths/dialogs.py` DialogSentry)**:下单/撤单提交后不再盲按 +Enter,改为 `pump()`「等待-发现-处置」循环。处置**不耦合弹窗内容**(不读 +正文做语义分类),只看结构,逐级兜底: + +1. 含 `Edit` 输入框 → 验证码类 → `input_ocr()`(回车关不掉,需输入); +2. 枚举到肯定按钮(是 > 确定 > 确认 > 同意 > 唯一按钮)→ `PostMessage(BM_CLICK)`; + 多按钮无肯定项**绝不点否/取消**; +3. 无可用按钮(自绘弹窗)→ 向弹窗投递回车(`WM_KEYDOWN VK_RETURN`, + 真机验证新版「提示」框有效);两次回车不消失才 `WM_CLOSE`; +4. **禁止 ESC**(对确认框语义是「否」)。 + +每个被处置的弹窗:截图存证到 work_dir、标题+全文+动作记入回执 `dialogs` +字段;全文机会性提取合同编号。安全性靠委托表/成交表回查,不靠读懂弹窗。 +弹窗结构对不上时跑 `python tools/ths_dialog_dump.py`(开着弹窗)核对。 + ## 8. 出新版本时怎么排查 1. 切到目标皮肤、登录 xiadan。 diff --git a/src/trader/dispatcher.py b/src/trader/dispatcher.py index e7deb02..a4a1bc9 100644 --- a/src/trader/dispatcher.py +++ b/src/trader/dispatcher.py @@ -1,4 +1,5 @@ """RPC 分派:call frame → backend method → reply frame""" +import asyncio import json import logging from pathlib import Path @@ -8,6 +9,14 @@ logger = logging.getLogger(__name__) +# 受控端单笔调用总预算:必须低于网关侧 30s 超时,保证网关永远等得到带 +# unknown 语义的 reply,而不是自造裸错误(-32003)。 +CALL_TIMEOUT_SECS = 25.0 +# win_lock 排队上限:持锁方被拖住时,排队方回 busy 而非无限饿死。 +LOCK_TIMEOUT_SECS = 5.0 +# 会真实改变账户状态的方法:超时/busy 回执必须带「可能已提交,先核单」语义。 +ORDER_METHODS = {"buy", "sell", "cancel"} + # Fallback tools schema in case the external JSON file cannot be found (e.g., in a packaged PyInstaller environment) FALLBACK_TOOLS_SCHEMA = { "tools": [ @@ -225,47 +234,76 @@ async def handle_call( return reply # 串行化 THS 单窗口访问:order_watch 轮询与下单/查询共用 backend.win_lock。 + # 拿锁带超时:持锁方若被弹窗/慢操作拖住,排队方不能无限饿死——回 busy + # 让调用方稍后重试,并提醒先核实前序委托。 needs_window = method in trading_methods if needs_window: - await backend.win_lock.acquire() + try: + await asyncio.wait_for(backend.win_lock.acquire(), LOCK_TIMEOUT_SECS) + except asyncio.TimeoutError: + msg = ("受控端正忙或被弹窗阻塞,本笔指令未执行。" + "请先调 orders_active/orders_filled 核实前序委托状态后再重试") + reply["ok"] = False + reply["result"] = {"code": 2, "status": "busy", "msg": msg} + reply["error"] = msg + return reply try: - if method == "balance": - logger.info("[RPC] method=balance, frame_id=%s", frame_id) - result = await backend.balance() - logger.info("[RPC] balance → code=%s", result.get("code")) - elif method == "position": - result = await backend.position() - elif method == "orders_active": - result = await backend.orders_active() - elif method == "orders_filled": - result = await backend.orders_filled() - elif method == "settlement": - result = await backend.settlement(params.get("date_range", "近一年")) - elif method == "watchlist": - result = await backend.watchlist() - elif method == "buy": - stock_no = params.get("stock_no") - amount = params.get("amount") - price = params.get("price") - client_order_id = params.get("client_order_id") - result = await backend.buy(stock_no, amount, price, client_order_id) - _eno = (result or {}).get("entrust_no") - if _eno: - backend.agent_entrust_nos.add(str(_eno)) - elif method == "sell": - stock_no = params.get("stock_no") - amount = params.get("amount") - price = params.get("price") - client_order_id = params.get("client_order_id") - result = await backend.sell(stock_no, amount, price, client_order_id) - _eno = (result or {}).get("entrust_no") - if _eno: - backend.agent_entrust_nos.add(str(_eno)) - elif method == "cancel": - entrust_no = params.get("entrust_no") - result = await backend.cancel(entrust_no) - else: - result = {"code": 1, "error": "内部错误"} + # 上一笔调用超时(疑似弹窗阻塞)后进入 degraded:先清残留弹窗再干活。 + # 清扫失败不阻断本次调用。 + if getattr(backend, "degraded", False): + try: + await asyncio.to_thread(backend.dialog_cleanup) + except Exception: + logger.exception("degraded dialog_cleanup 失败") + backend.degraded = False + + async def _invoke() -> Any: + if method == "balance": + logger.info("[RPC] method=balance, frame_id=%s", frame_id) + r = await backend.balance() + logger.info("[RPC] balance → code=%s", r.get("code")) + return r + if method == "position": + return await backend.position() + if method == "orders_active": + return await backend.orders_active() + if method == "orders_filled": + return await backend.orders_filled() + if method == "settlement": + return await backend.settlement(params.get("date_range", "近一年")) + if method == "watchlist": + return await backend.watchlist() + if method in ("buy", "sell"): + stock_no = params.get("stock_no") + amount = params.get("amount") + price = params.get("price") + client_order_id = params.get("client_order_id") + fn = backend.buy if method == "buy" else backend.sell + r = await fn(stock_no, amount, price, client_order_id) + _eno = (r 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")) + return {"code": 1, "error": "内部错误"} + + try: + # 受控端总超时(低于网关 30s):无论内部卡在哪,25s 内必有明确回执。 + # 弹窗/无响应导致的超时绝不能表现为裸报错——委托可能已提交, + # 必须回 unknown + 核单指引(2026-07-13「报错但静默成交」事故)。 + result = await asyncio.wait_for(_invoke(), CALL_TIMEOUT_SECS) + except asyncio.TimeoutError: + backend.degraded = True + logger.error("[RPC] %s 超过 %ss 未完成,标记 degraded,回 unknown", + method, CALL_TIMEOUT_SECS) + if method in ORDER_METHODS: + msg = ("受控端处理超时(疑似弹窗或客户端无响应)。委托可能已提交," + "请调 orders_filled/orders_active 核实后再决定,勿直接重复下单") + result = {"code": 2, "status": "unknown", "msg": msg} + else: + result = {"code": 1, "status": "failed", + "msg": "受控端查询超时(疑似弹窗或客户端无响应),请稍后重试"} if not isinstance(result, dict): reply["ok"] = False diff --git a/src/trader/ths/const.py b/src/trader/ths/const.py index 02813fd..9b99833 100644 --- a/src/trader/ths/const.py +++ b/src/trader/ths/const.py @@ -181,3 +181,9 @@ "当日盈亏": 0x402, "当日盈亏比": 0x405, } + +# --- 交易弹窗结构化处理(DialogSentry,见 docs/superpowers/specs/2026-07-13-ths-dialog-handling-design.md) +# 决策只看结构:弹窗含 Edit 输入框 → 验证码(input_ocr);否则按按钮标签的 +# 肯定优先级点击。按钮标签是有限稳定集合(控件属性),不做正文语义分类。 +DIALOG_AFFIRM_LABELS = ("是", "确定", "确认", "同意") # 点击优先级从左到右 +DIALOG_ENTRUST_NO_RE = r"合同编号[::]?\s*(\d+)" # 从弹窗全文提取合同编号(机会性) diff --git a/src/trader/ths/dialogs.py b/src/trader/ths/dialogs.py new file mode 100644 index 0000000..2b2c046 --- /dev/null +++ b/src/trader/ths/dialogs.py @@ -0,0 +1,265 @@ +"""交易弹窗看门人(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``。 + +安全性不靠读懂弹窗,靠两条:处置窗口仅限我们自己发起的动作前后; +以及点完后照旧走成交表/委托表核实回执。每个被处置的弹窗的 +标题 + 全文 + 所采取动作都记录进返回值(进而进回执与日志)—— +调用方永远知道流程中间发生过什么。 + +本模块 Windows-only 部分全部惰性引用 win32 模块;纯决策函数 +(choose_button / extract_entrust_no)无平台依赖,可在任意平台单测。 +""" + +from __future__ import annotations + +import logging +import os +import platform +import re +import time +from dataclasses import dataclass, field +from typing import Optional + +from .const import DIALOG_AFFIRM_LABELS, DIALOG_ENTRUST_NO_RE + +if platform.system() == "Windows": + import win32api + import win32con + import win32gui + import win32process + +logger = logging.getLogger(__name__) + + +def normalize_button_label(raw: str) -> str: + """按钮文本归一化:"是(Y)" / "确 定" / "确定(&O)" → "是" / "确定"。""" + s = (raw or "").strip() + s = re.sub(r"[\((]\s*&?[A-Za-z]\s*[\))]\s*$", "", s) + return s.replace("&", "").replace(" ", "").strip() + + +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: + return want + if len(labels) == 1: + return labels[0] + return None + + +def extract_entrust_no(text: str) -> Optional[str]: + """从弹窗全文机会性提取合同编号(拿不到不算失败,回查兜底)。""" + m = re.search(DIALOG_ENTRUST_NO_RE, text or "") + return m.group(1) if m else None + + +@dataclass +class DialogFingerprint: + """一个弹窗的结构指纹:定位 + 存证所需的全部信息。""" + + hwnd: int + title: str + text: str # 所有 Static 文本按行合并 + buttons: dict[str, int] = field(default_factory=dict) # 归一化标签 → hwnd + has_edit: bool = False # 含输入框 = 验证码类,不可盲点 + + +@dataclass +class PumpResult: + """pump() 的汇总结果,供下单/撤单路径充实回执。""" + + dialogs: list[dict] = field(default_factory=list) # {title, text, action} + entrust_no: Optional[str] = None + + def attach_to(self, receipt: dict) -> dict: + """把弹窗存证挂到回执上(无弹窗则不加字段,保持回执干净)。""" + if self.dialogs: + receipt["dialogs"] = self.dialogs + return receipt + + @property + def texts(self) -> list[str]: + return [d["text"] or d["title"] for d in self.dialogs if d.get("text") or d.get("title")] + + +class DialogSentry: + """围绕一个 WinThsBackend 的弹窗发现与处置。""" + + def __init__(self, backend): + self.backend = backend + + # ---- 发现 ----------------------------------------------------------- + + def scan(self) -> list[DialogFingerprint]: + """枚举 xiadan 主窗口线程的顶层可见 enabled 弹窗(排除主窗口)。""" + hwnd_main = getattr(self.backend, "hwnd_main", None) + if not hwnd_main: + return [] + tid, _pid = win32process.GetWindowThreadProcessId(hwnd_main) + tops: list[int] = [] + try: + win32gui.EnumThreadWindows(tid, lambda h, acc: acc.append(h) or True, tops) + except Exception: + logger.exception("EnumThreadWindows failed") + return [] + out = [] + for h in tops: + try: + if h == hwnd_main: + continue + if not (win32gui.IsWindowVisible(h) and win32gui.IsWindowEnabled(h)): + continue + out.append(self._fingerprint(h)) + except Exception: + logger.exception("fingerprint failed hwnd=%s", hex(h)) + return out + + def _fingerprint(self, hwnd: int) -> DialogFingerprint: + title = win32gui.GetWindowText(hwnd) or "" + texts: list[str] = [] + buttons: dict[str, int] = {} + has_edit = False + + def walker(h, _): + nonlocal has_edit + try: + cls = win32gui.GetClassName(h) + if cls == "Static": + t = _get_text(h).strip() + if t: + texts.append(t) + elif cls == "Button": + label = normalize_button_label(win32gui.GetWindowText(h)) + if label and label not in buttons: + buttons[label] = h + elif cls == "Edit": + has_edit = True + except Exception: + pass + return True + + try: + win32gui.EnumChildWindows(hwnd, walker, None) + except Exception: + pass # 无子控件的自绘弹窗:指纹只有标题,动作走 Enter/WM_CLOSE 兜底 + return DialogFingerprint(hwnd=hwnd, title=title, text="\n".join(texts), + buttons=buttons, has_edit=has_edit) + + # ---- 处置 ----------------------------------------------------------- + + def dismiss(self, dlg: DialogFingerprint) -> str: + """按结构规则处置一个弹窗,返回所采取的动作(进存证)。 + + 原则(2026-07-13 用户定):任何弹窗都以**肯定**方式快速消除、回到既定 + 轨道,不耦合弹窗内容。肯定优先级:点「是/确定」按钮(=精确版回车, + 不依赖焦点与默认按钮设定)→ 向弹窗投递回车(真机验证对新版自绘提示框 + 有效)→ 两次回车仍在才 WM_CLOSE 兜底。**禁止 ESC**——在下单/撤单 + 确认框上 ESC 语义是「否/取消」。 + """ + if dlg.has_edit: + # 验证码/身份验证:必须输入内容才能通过,回车关不掉 → + # 交给既有 OCR 流程(内部自带重试)。 + 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}" + # 无可用按钮标签(自绘弹窗):回车 = 默认按钮(肯定)。投递给弹窗 + # 本身而非全局按键——不依赖弹窗是否前台,也绝不会敲进别的窗口。 + 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) + win32api.PostMessage(dlg.hwnd, win32con.WM_CLOSE, 0, 0) + return "enter*2+wm_close" + + def pump(self, budget: float = 5.0, settle: float = 0.3) -> PumpResult: + """提交动作后的「等待-发现-处置」循环,取代盲按 Enter。 + + 连续 ``settle`` 秒无弹窗即认为落定提前返回;总预算 ``budget`` 秒。 + 每个处置过的弹窗都截图存证(work_dir)并记录标题/全文/动作。 + """ + result = PumpResult() + deadline = time.time() + budget + quiet_since: Optional[float] = None + handled: dict[int, float] = {} # hwnd → 上次处置时刻(防对同一弹窗连点) + while time.time() < deadline: + dialogs = self.scan() + if not dialogs: + now = time.time() + if quiet_since is None: + quiet_since = now + elif now - quiet_since >= settle: + break + time.sleep(0.05) + continue + quiet_since = None + for dlg in dialogs: + last = handled.get(dlg.hwnd, 0.0) + if time.time() - last < 0.5: + continue # 刚点过,给它时间消失 + self._snapshot(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}) + if not result.entrust_no: + result.entrust_no = extract_entrust_no(dlg.text) + time.sleep(0.1) + return result + + def cleanup(self) -> PumpResult: + """degraded 自愈:清掉残留弹窗(同一套「肯定+存证」规则)。 + + 与 pump 的区别只有预算更短——此时上一笔已按 unknown 上报, + 调用方被要求核单,无论弹窗被肯定还是关闭,真相都以核单为准。 + """ + return self.pump(budget=2.0, settle=0.2) + + def _snapshot(self, dlg: DialogFingerprint) -> None: + """处置前截图存证(尽力而为)。""" + try: + from . import win as _win + path = os.path.join(_win.work_dir, + f"dialog_{int(time.time() * 1000)}_{dlg.hwnd:x}.png") + self.backend.capture_window(dlg.hwnd, path) + except Exception: + logger.debug("dialog snapshot failed", exc_info=True) + + +def _get_text(hwnd: int) -> str: + import ctypes + import win32con as _wc + u32 = ctypes.windll.user32 + n = u32.SendMessageW(hwnd, _wc.WM_GETTEXTLENGTH, 0, 0) + buf = ctypes.create_unicode_buffer(n + 1) + u32.SendMessageW(hwnd, _wc.WM_GETTEXT, n + 1, ctypes.byref(buf)) + return buf.value + + +def _safe_is_window(hwnd: int) -> bool: + try: + return bool(win32gui.IsWindow(hwnd)) + except Exception: + return False diff --git a/src/trader/ths/win.py b/src/trader/ths/win.py index 4252c67..666a5e4 100644 --- a/src/trader/ths/win.py +++ b/src/trader/ths/win.py @@ -404,6 +404,22 @@ def __init__(self): self.agent_entrust_nos: set[str] = set() # 内存态:查询结果的 last-known 存储(剪贴板仅作毫秒级中转)。 self.state = ThsState() + # dispatcher 侧调用超时后置位;下一次调用进入前先跑 dialog_cleanup 自愈。 + self.degraded = False + + def _pump_dialogs(self): + """提交动作后的弹窗「发现-处置-存证」循环(见 ths/dialogs.py)。""" + from .dialogs import DialogSentry + return DialogSentry(self).pump() + + def dialog_cleanup(self): + """degraded 自愈入口:清掉残留弹窗并留存证(dispatcher 在超时后的 + 下一次调用前执行)。返回 PumpResult,内容进日志。""" + from .dialogs import DialogSentry + result = DialogSentry(self).cleanup() + if result.dialogs: + logger.warning("dialog_cleanup 清掉残留弹窗:%s", result.dialogs) + return result def _ensure_bound(self) -> dict[str, Any] | None: """检查是否已绑定;否则 lazy bind,返回错误 dict 或 None(成功)""" @@ -625,19 +641,14 @@ def _bulk_cancel(self, action: str): # BM_CLICK fires the button's WM_COMMAND. Cross-process safe. win32api.PostMessage(btn, win32con.BM_CLICK, 0, 0) time.sleep(sleep_time) - # xiadan typically pops a "您确定要撤销..." confirmation. The OK button - # is default-focused; Enter accepts it. If there's no confirmation - # (e.g. when there's nothing to cancel), Enter is a harmless no-op. - hot_key(["enter"]) - time.sleep(sleep_time) - # Handle anti-bot captcha if it appears. - self.input_ocr() - return { + # "您确定要撤销..." 确认框 / 验证码:结构化处置 + 存证(取代盲 Enter)。 + pump = self._pump_dialogs() + return pump.attach_to({ "code": 0, "status": "succeed", "action": action, "button_id": f"0x{btn_id:04X}", - } + }) def cancel_all(self): return self._bulk_cancel("all") @@ -1299,20 +1310,17 @@ def _submit_trade(self, panel_key, op_keyword, stock_no, amount, price): ctrl = self._find_input(hwnd, 0x40A) set_text(ctrl, str(amount)) time.sleep(sleep_time) - # Submit form → 确认买卖 dialog → confirm. THS may then pop an anti-bot - # captcha that blocks the whole window; input_ocr() solves it (and is a - # no-op when no popup is present). Only after the captcha clears does the - # "已成功提交" result popup show, so handle the captcha BETWEEN the confirm - # Enter and the final dismiss — three blind Enters alone can't dismiss a - # captcha (it needs the actual code typed) and leave the order stuck. - hot_key(["enter"]) # submit form → 确认买卖 dialog - hot_key(["enter"]) # confirm → 提交委托(可能弹验证码) - self.input_ocr() # 处理反机器人验证码(无弹窗立即返回) - hot_key(["enter"]) # dismiss 结果弹窗 + # Submit form(Enter 提交表单本身)→ 之后可能出现的确认框/验证码/结果框 + # 交给 DialogSentry 结构化处置(发现弹窗→点肯定按钮→存证;含 Edit 的 + # 验证码框走 input_ocr)。取代旧的三连盲 Enter:不再依赖焦点与时序, + # 弹窗标题/全文/所点按钮全部带回回执,绝不静默。 + hot_key(["enter"]) # submit form → 可能弹「委托确认」 + pump = self._pump_dialogs() time.sleep(sleep_time) - entrust_no = self._lookup_entrust_no(stock_no, op_keyword, amount, price) + entrust_no = pump.entrust_no or self._lookup_entrust_no( + stock_no, op_keyword, amount, price) if entrust_no: - return { + return pump.attach_to({ "code": 0, "status": "succeed", "entrust_no": entrust_no, @@ -1320,12 +1328,20 @@ def _submit_trade(self, panel_key, op_keyword, stock_no, amount, price): "amount": int(amount), "price": float(price) if price is not None else None, "op": op_keyword, - } - return { + }) + if pump.texts: + # 回查无此单 + 有弹窗文本 ⇒ 大概率被拒/废单,把真实原因原文带回, + # 而不是让调用方拿着 unknown 干瞪眼。 + return pump.attach_to({ + "code": 1, + "status": "failed", + "msg": "委托未进入委托列表,客户端提示:" + ";".join(pump.texts), + }) + return pump.attach_to({ "code": 2, "status": "unknown", "msg": "已提交但未能在 orders/active 表中匹配到对应订单,请自行确认状态", - } + }) def _do_sell(self, stock_no, amount, price): # price is None ⇒ 真·市价委托(五档即成剩撤);有值 ⇒ F2 限价挂单(原逻辑)。 @@ -1408,16 +1424,17 @@ def _submit_market_trade(self, op_keyword, stock_no, amount): return {"code": 1, "status": "failed", "msg": "委托策略未能设为五档即成剩撤,已中止(避免下错单)"} - # 提交:点提交按钮(焦点无关,避开 combo 焦点吞 Enter)→ 确认框 →(可能验证码)→ 关弹窗 + # 提交:点提交按钮(焦点无关,避开 combo 焦点吞 Enter)。 + # 必须 PostMessage:SendMessage 是同步跨进程调用,按钮 handler 弹出模态 + # 「委托确认」框时不返回 → 线程死锁(2026-07-13 事故根因),后续弹窗 + # 处理代码永远执行不到。 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.SendMessage(submit_btn, win32con.BM_CLICK, 0, 0) + win32api.PostMessage(submit_btn, win32con.BM_CLICK, 0, 0) else: hot_key(["enter"]) - hot_key(["enter"]) # 确认买卖 dialog - self.input_ocr() # 反机器人验证码(无弹窗立即返回) - hot_key(["enter"]) # 关结果弹窗 + pump = self._pump_dialogs() # 确认框/验证码/结果框:结构化处置 + 存证 time.sleep(sleep_time) # 回执:轮询成交表拿本次新增成交(五档即成剩撤成交极快,给足 8s) @@ -1428,13 +1445,19 @@ def _submit_market_trade(self, op_keyword, stock_no, amount): r = _match_market_fill(before, post.get("data", []), stock_no, op_keyword, amount) if r["code"] == 0: - return r + return pump.attach_to(r) time.sleep(0.3) - logger.warning("market submit unconfirmed stock=%s op=%s amount=%s", - stock_no, op_keyword, amount) - return {"code": 2, "status": "unknown", "stock_no": str(stock_no), - "op": op_keyword, "requested_amount": int(amount), "filled_amount": 0, - "msg": "已提交但未在成交表确认成交,可能非连续竞价时段/涨跌停被拒/无成交,请自行核对成交与委托"} + logger.warning("market submit unconfirmed stock=%s op=%s amount=%s dialogs=%s", + stock_no, op_keyword, amount, pump.dialogs) + if pump.texts: + msg = ("已提交但未在成交表确认成交,客户端提示:" + ";".join(pump.texts) + + "。请自行核对成交与委托") + else: + msg = "已提交但未在成交表确认成交,可能非连续竞价时段/涨跌停被拒/无成交,请自行核对成交与委托" + return pump.attach_to({ + "code": 2, "status": "unknown", "stock_no": str(stock_no), + "op": op_keyword, "requested_amount": int(amount), "filled_amount": 0, + "msg": msg}) def _do_cancel(self, entrust_no): try: @@ -1490,10 +1513,10 @@ def _cancel_inner(self, entrust_no): win32api.mouse_event(win32con.MOUSEEVENTF_LEFTDOWN, 0, 0, 0, 0) win32api.mouse_event(win32con.MOUSEEVENTF_LEFTUP, 0, 0, 0, 0) time.sleep(sleep_time) - hot_key(["enter"]) - time.sleep(sleep_time) - hot_key(["enter"]) - return {"code": 0, "status": "succeed"} + # 双击委托行后可能弹「撤单确认」——结构化处置(取代两次盲 Enter), + # 弹窗内容带回回执。 + pump = self._pump_dialogs() + return pump.attach_to({"code": 0, "status": "succeed"}) def get_result(self, cid=0x3EC): tid, pid = win32process.GetWindowThreadProcessId(self.hwnd_main) @@ -1768,7 +1791,9 @@ def walker(h, _): ) time.sleep(short_sleep_time) if ok_btn: - win32api.SendMessage(ok_btn, win32con.BM_CLICK, 0, 0) + # PostMessage:确定按钮的 handler 若再弹模态框(如"验证码错误"), + # SendMessage 会同步卡死本线程(同 2026-07-13 事故根因)。 + win32api.PostMessage(ok_btn, win32con.BM_CLICK, 0, 0) else: hot_key(["enter"]) time.sleep(sleep_time) diff --git a/src/trader/ws_client.py b/src/trader/ws_client.py index 7023931..9a9a0a2 100644 --- a/src/trader/ws_client.py +++ b/src/trader/ws_client.py @@ -162,6 +162,8 @@ def __init__( self.on_state_change = on_state_change self.on_rpc_log = on_rpc_log self.backend = backend or WinThsBackend() + # 在飞的 call 任务强引用(防 GC 提前回收),完成即自清。 + self._call_tasks: set[asyncio.Task] = set() def _set_state(self, new_state: ConnectionState) -> None: """更新状态并触发回调""" @@ -318,30 +320,40 @@ async def _handle_frame(self, frame: dict[str, Any]) -> None: raise SessionRejectedException(reason) elif frame_type == "call": - rpc_id = frame.get("id") - method = frame.get("method") - params = frame.get("params", {}) - logger.info("收到 RPC call:id=%s, method=%s", rpc_id, method) - try: - # dispatcher.handle_call 已返回完整 reply 帧(type/id/ok/result|error)。 - # 直接转发,不要再包一层 {ok:true, result:...}——否则外层永远 ok:true, - # 真实失败被掩盖,成功结果也多嵌一层导致下游解析错位。 - reply = await dispatcher.handle_call(frame, self.backend) - if self.on_rpc_log: - if reply.get("ok"): - self.on_rpc_log( - _format_rpc_log(method, params, result=reply.get("result")) - ) - else: - self.on_rpc_log( - _format_rpc_log(method, params, error=reply.get("error")) - ) - except Exception as e: - 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))) - if self.ws: - await self.ws.send(json.dumps(reply, ensure_ascii=False)) + # 后台 task 执行:单笔 RPC 卡住/变慢时,消息循环必须继续跑——否则 + # 连用于核单的 orders_active/orders_filled 都进不来(2026-07-13 事故: + # 一笔卡死瘫痪整个受控端)。执行顺序不受影响:交易/查询本就由 + # backend.win_lock(FIFO)串行。 + task = asyncio.create_task(self._process_call(frame)) + self._call_tasks.add(task) + task.add_done_callback(self._call_tasks.discard) + + async def _process_call(self, frame: dict[str, Any]) -> None: + """执行一个 call 帧并回发 reply(在独立 task 中运行)。""" + rpc_id = frame.get("id") + method = frame.get("method") + params = frame.get("params", {}) + logger.info("收到 RPC call:id=%s, method=%s", rpc_id, method) + try: + # dispatcher.handle_call 已返回完整 reply 帧(type/id/ok/result|error)。 + # 直接转发,不要再包一层 {ok:true, result:...}——否则外层永远 ok:true, + # 真实失败被掩盖,成功结果也多嵌一层导致下游解析错位。 + reply = await dispatcher.handle_call(frame, self.backend) + if self.on_rpc_log: + if reply.get("ok"): + self.on_rpc_log( + _format_rpc_log(method, params, result=reply.get("result")) + ) + else: + self.on_rpc_log( + _format_rpc_log(method, params, error=reply.get("error")) + ) + except Exception as e: + 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))) + if self.ws: + await self.ws.send(json.dumps(reply, ensure_ascii=False)) async def send_frame(self, frame: dict[str, Any]) -> None: """发送帧""" diff --git a/tests/test_dialogs.py b/tests/test_dialogs.py new file mode 100644 index 0000000..5642aba --- /dev/null +++ b/tests/test_dialogs.py @@ -0,0 +1,84 @@ +"""DialogSentry 纯决策逻辑回归(不触碰 Win32,任意平台可跑)。 + +结构化处置的决策面只有三块:按钮标签归一化、肯定按钮选择、合同编号提取。 +这三块错了,真机上点错按钮/丢回执;win32 枚举与点击留给真机联调。 +""" +from trader.ths.dialogs import ( + PumpResult, + choose_button, + extract_entrust_no, + normalize_button_label, +) + + +# ---- 按钮标签归一化 -------------------------------------------------------- + +def test_normalize_strips_accelerator_suffix(): + assert normalize_button_label("是(Y)") == "是" + assert normalize_button_label("否(N)") == "否" + assert normalize_button_label("确定(&O)") == "确定" + assert normalize_button_label("是(&Y)") == "是" + assert normalize_button_label("确定(Y)") == "确定" # 全角括号 + + +def test_normalize_strips_spaces_and_amp(): + assert normalize_button_label("确 定") == "确定" + assert normalize_button_label("&确定") == "确定" + assert normalize_button_label(" 是 ") == "是" + assert normalize_button_label("") == "" + assert normalize_button_label(None) == "" + + +# ---- 肯定按钮选择 ---------------------------------------------------------- + +def test_choose_prefers_yes_over_ok(): + # 委托确认框:是(Y)/否(N) → 点「是」 + assert choose_button(["是", "否"]) == "是" + assert choose_button(["否", "是"]) == "是" + + +def test_choose_ok_dialog(): + # 结果/提示框:单「确定」 + assert choose_button(["确定"]) == "确定" + + +def test_choose_single_button_whatever_label(): + # 信息框的唯一按钮无论叫什么都等价于关闭 + assert choose_button(["知道了"]) == "知道了" + + +def test_choose_never_picks_negative_among_many(): + # 多按钮且无肯定项 → None(走 Enter/WM_CLOSE 兜底),绝不主动点「取消」 + assert choose_button(["取消", "重试"]) is None + assert choose_button([]) is None + + +# ---- 合同编号提取 ---------------------------------------------------------- + +def test_extract_entrust_no_variants(): + assert extract_entrust_no("您的买入委托已成功提交,合同编号:12345。") == "12345" + assert extract_entrust_no("合同编号: 67890") == "67890" + assert extract_entrust_no("合同编号889900") == "889900" + assert extract_entrust_no("可用资金不足") is None + assert extract_entrust_no("") is None + assert extract_entrust_no(None) is None + + +# ---- PumpResult 回执附加 --------------------------------------------------- + +def test_attach_to_adds_dialogs_only_when_present(): + r = PumpResult() + receipt = r.attach_to({"code": 0}) + assert "dialogs" not in receipt # 无弹窗不加字段,回执保持干净 + + r2 = PumpResult(dialogs=[{"title": "提示", "text": "请选择意向申报委托", "action": "click:确定"}]) + receipt2 = r2.attach_to({"code": 0}) + assert receipt2["dialogs"][0]["action"] == "click:确定" + + +def test_texts_falls_back_to_title(): + r = PumpResult(dialogs=[ + {"title": "提示", "text": "废单:可用资金不足", "action": "click:确定"}, + {"title": "委托确认", "text": "", "action": "click:是"}, + ]) + assert r.texts == ["废单:可用资金不足", "委托确认"] diff --git a/tests/test_dispatcher_timeout.py b/tests/test_dispatcher_timeout.py new file mode 100644 index 0000000..556f7a7 --- /dev/null +++ b/tests/test_dispatcher_timeout.py @@ -0,0 +1,93 @@ +"""dispatcher 超时/busy/degraded 语义回归(2026-07-13 弹窗卡死事故)。 + +三条铁律: +1. 下单类调用无论卡在哪,超时后必回 code=2 status=unknown +「先核单勿补单」, + 绝不表现为裸报错或永不回复; +2. 拿不到 win_lock 回 busy,不无限饿死; +3. 超时后置 degraded,下一次调用先跑 dialog_cleanup 自愈。 +""" +import asyncio + +from trader import dispatcher + + +class HangingBackend: + """sell 永远不返回(模拟弹窗卡死);查询正常。""" + + def __init__(self): + self.win_lock = asyncio.Lock() + self.agent_entrust_nos: set[str] = set() + self.degraded = False + self.cleanup_calls = 0 + + async def sell(self, *a, **k): + await asyncio.sleep(3600) + + async def orders_active(self): + return {"code": 0, "status": "succeed", "data": []} + + def dialog_cleanup(self): # dispatcher 经 asyncio.to_thread 调用(同步) + self.cleanup_calls += 1 + + +def _call(backend, method, params=None, **frame_extra): + frame = {"type": "call", "id": "t1", "method": method, "params": params or {}} + frame.update(frame_extra) + return asyncio.run(dispatcher.handle_call(frame, backend)) + + +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}) + assert reply["ok"] is False + assert reply["result"]["code"] == 2 + assert reply["result"]["status"] == "unknown" + # 核单指引必须在错误文本里,防调用方凭报错补单 + assert "orders_filled" in reply["error"] + assert "勿直接重复下单" in reply["error"] + assert backend.degraded is True + + +def test_query_timeout_is_failed_not_unknown(monkeypatch): + monkeypatch.setattr(dispatcher, "CALL_TIMEOUT_SECS", 0.05) + + class SlowQueryBackend(HangingBackend): + async def orders_active(self): + await asyncio.sleep(3600) + + reply = _call(SlowQueryBackend(), "orders_active") + assert reply["ok"] is False + assert reply["result"]["code"] == 1 # 查询超时是普通失败,不是「可能已提交」 + + +def test_lock_busy_instead_of_starvation(monkeypatch): + monkeypatch.setattr(dispatcher, "LOCK_TIMEOUT_SECS", 0.05) + backend = HangingBackend() + + async def drive(): + await backend.win_lock.acquire() # 模拟持锁方被拖住 + frame = {"type": "call", "id": "t2", "method": "buy", + "params": {"stock_no": "600000", "amount": 100}} + return await dispatcher.handle_call(frame, backend) + + reply = asyncio.run(drive()) + assert reply["ok"] is False + assert reply["result"]["status"] == "busy" + assert "orders_active" in reply["error"] # 下单类 busy 也要带核单提醒 + + +def test_degraded_triggers_cleanup_on_next_call(): + backend = HangingBackend() + backend.degraded = True + reply = _call(backend, "orders_active") + assert reply["ok"] is True + assert backend.cleanup_calls == 1 + assert backend.degraded is False # 自愈后复位 + + +def test_normal_call_unaffected(): + backend = HangingBackend() + reply = _call(backend, "orders_active") + assert reply["ok"] is True + assert backend.cleanup_calls == 0 diff --git a/tests/test_ws_call_nonblocking.py b/tests/test_ws_call_nonblocking.py new file mode 100644 index 0000000..c03c6aa --- /dev/null +++ b/tests/test_ws_call_nonblocking.py @@ -0,0 +1,54 @@ +"""ws_client:call 帧在后台 task 执行,消息循环不被单笔 RPC 阻塞。 + +2026-07-13 事故的放大器之一:_handle_frame 内联 await 执行 RPC,一笔卡死 +让受控端不再处理任何后续帧(连核单查询都进不来)。 +""" +import asyncio +import json + +from trader import ws_client + + +class FakeWs: + def __init__(self): + self.sent: list[dict] = [] + + async def send(self, raw): + self.sent.append(json.loads(raw)) + + +class SlowBackend: + def __init__(self): + self.win_lock = asyncio.Lock() + self.agent_entrust_nos: set[str] = set() + self.degraded = False + self.started = asyncio.Event() + self.release = asyncio.Event() + + async def orders_active(self): + self.started.set() + await self.release.wait() # 卡住,直到测试放行 + return {"code": 0, "status": "succeed", "data": []} + + +def test_call_frame_does_not_block_handle_frame(): + async def drive(): + backend = SlowBackend() + client = ws_client.WsClient(backend=backend) + client.ws = FakeWs() + + frame = {"type": "call", "id": "slow", "method": "orders_active", "params": {}} + # _handle_frame 必须立刻返回(RPC 在后台 task 里执行) + await asyncio.wait_for(client._handle_frame(frame), timeout=1.0) + await asyncio.wait_for(backend.started.wait(), timeout=1.0) + assert client.ws.sent == [] # RPC 还卡着,说明 _handle_frame 没有等它 + + backend.release.set() # 放行 → reply 应该被补发 + for _ in range(100): + if client.ws.sent: + break + await asyncio.sleep(0.01) + assert client.ws.sent and client.ws.sent[0]["id"] == "slow" + assert client.ws.sent[0]["ok"] is True + + asyncio.run(drive()) diff --git a/tools/ths_dialog_dump.py b/tools/ths_dialog_dump.py new file mode 100644 index 0000000..3d04f90 --- /dev/null +++ b/tools/ths_dialog_dump.py @@ -0,0 +1,67 @@ +"""同花顺弹窗【结构测绘】诊断工具:dump 当前所有弹窗的控件树(只读,不点击)。 + +用途:验证 DialogSentry(src/trader/ths/dialogs.py)的结构化处置在当前 +xiadan 版本/皮肤上是否可行——弹窗的按钮是不是原生 Button、文本在不在 +Static 里、有没有 Edit(验证码特征)。**在 xiadan 里随便弄出一个弹窗 +(如设置里触发的提示框)后运行本脚本**,把输出发回来核对。 + +用法(项目根,任意 shell,弹窗保持打开): + python tools\\ths_dialog_dump.py +""" +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "src")) + +import win32gui +import win32process +import win32api +import win32con + +from trader.ths import win as W +from trader.ths.dialogs import DialogSentry, normalize_button_label, choose_button + +W.setup("网上股票交易系统5.0", "", "") +b = W.WinThsBackend() +b.bind_client() +print("hwnd_main =", b.hwnd_main) +if not b.hwnd_main: + raise SystemExit("!! 未绑定到下单窗口,先确认已打开并登录") + +tid, pid = win32process.GetWindowThreadProcessId(b.hwnd_main) +tops = [] +win32gui.EnumThreadWindows(tid, lambda h, acc: acc.append(h) or True, tops) +print(f"\n主窗口线程 tid={tid} 共 {len(tops)} 个顶层窗口:\n") + +for h in tops: + vis = win32gui.IsWindowVisible(h) + ena = win32gui.IsWindowEnabled(h) + cls = win32gui.GetClassName(h) + title = win32gui.GetWindowText(h) + mark = " ← 主窗口" if h == b.hwnd_main else "" + print(f"[top] hwnd=0x{h & 0xFFFFFFFF:X} cls={cls!r} vis={vis} ena={ena} title={title!r}{mark}") + if h == b.hwnd_main or not (vis and ena): + continue + + def walker(ch, depth_holder): + cls_c = win32gui.GetClassName(ch) + cid = win32api.GetWindowLong(ch, win32con.GWL_ID) + txt = win32gui.GetWindowText(ch) + vis_c = win32gui.IsWindowVisible(ch) + print(f" child hwnd=0x{ch & 0xFFFFFFFF:X} cls={cls_c!r} id=0x{cid & 0xFFFF:X} " + f"vis={vis_c} text={txt!r}") + return True + + try: + win32gui.EnumChildWindows(h, walker, None) + except Exception as e: + print(f" (EnumChildWindows: {e} —— 可能是无子控件的自绘弹窗)") + +print("\n=== DialogSentry 视角(将如何处置,仅演算不点击) ===") +for dlg in DialogSentry(b).scan(): + labels = list(dlg.buttons) + pick = "input_ocr(含Edit)" if dlg.has_edit else ( + f"click:{choose_button(labels)}" if choose_button(labels) else "enter/wm_close 兜底") + print(f"hwnd=0x{dlg.hwnd & 0xFFFFFFFF:X} title={dlg.title!r}") + print(f" text={dlg.text!r}") + print(f" buttons={labels} has_edit={dlg.has_edit} → 动作: {pick}") diff --git a/uv.lock b/uv.lock index b87eb01..f0ee4fb 100644 --- a/uv.lock +++ b/uv.lock @@ -269,7 +269,7 @@ wheels = [ [[package]] name = "guling-trader" -version = "0.6.0" +version = "0.6.2" source = { editable = "." } dependencies = [ { name = "aiohttp" },