Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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(图形识别环境),无感进行。
Expand Down
20 changes: 20 additions & 0 deletions docs/PROTOCOL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down
253 changes: 253 additions & 0 deletions docs/superpowers/specs/2026-07-13-ths-dialog-handling-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,253 @@
# 同花顺交易弹窗处理与超时语义修复 — 设计

- 日期:2026-07-13
- 状态:已确认并实现(P0/P1 随本 PR 落码;P2 真机回归待用户配合执行)
- 事故驱动: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,`src/trader/ths/dialogs.py`)

**总原则(用户 2026-07-13 定)**:xiadan 出现任何弹窗,都以**肯定**方式快速
消除、回到既定操作轨道;处理逻辑**不耦合弹窗内容**——不读正文做语义分类,
弹窗标题/全文只做**存证**(记入回执 `dialogs` 字段与日志),让调用方与用户
事后知道流程中发生过什么。安全性不靠读懂弹窗,靠既有的成交表/委托表回查。

> 曾考虑过「标题/文本关键词分类 + 分场景点是/否」的矩阵式方案,被否决:
> 关键词表随版本/券商漂移、维护成本高,且误分类的后果比「肯定+核查」更糟。

### 5.1 发现机制

复用既有同构代码(`get_result` / `get_ocr_hwnd` 的 `EnumThreadWindows` 模式):
枚举 xiadan 主窗口线程的顶层可见、enabled 窗口,排除主窗口自身;对每个候选
收集结构指纹:窗口标题、全部 `Static` 文本(存证用)、全部 `Button` 的
归一化标签("是(Y)"→"是")、是否含 `Edit` 输入框。

### 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 也能关提示框,但对交易确认框是错误动作,故整体弃用)。

每个被处置的弹窗:处置前截图存证到 `work_dir`,标题+全文+所采取动作记入
`PumpResult.dialogs` → 挂到回执。全文中机会性正则提取 `合同编号`(拿不到
不算失败,回查兜底)。

### 5.3 时机:用「等待-发现-处置」循环取代盲 Enter

`_submit_trade` / `_submit_market_trade` / `_cancel_inner` / `_bulk_cancel`
提交动作后调用 `pump()`:

```
deadline = now + 5s
loop every 0.1s:
dialogs = scan()
连续 0.3s 无弹窗 → 提前落定返回(无弹窗配置下延迟 ≈0.3s)
有弹窗 → 按 §5.2 处置 + 存证(同一弹窗 0.5s 内不重复点击)
```

随后照旧进入回查(`_lookup_entrust_no` / 成交表差分)。回查失败但 pump
捕获过弹窗文本时,把原文带进回执(限价路径回 `code=1 failed +「客户端提示:
…」`;市价路径 unknown 的 msg 附原文)——废单真实原因从此进回执。

### 5.4 degraded 自愈清扫

§4.2 的 degraded 入口调用 `dialog_cleanup()`(= 短预算 `pump()`,同一套
「肯定+存证」规则):上一笔已按 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 股小额限价单 | 警示弹窗被肯定后委托继续,回执 `dialogs` 含警示全文 |
| 验证码场景 | 连续高频查询触发 | `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 采纳;其**内容关键词
分类**被本设计有意放弃(见 §5 总原则)。
- easytrader 对「模态弹窗卡死」**没有正式解法**(Issue #452 只有问题报告);
其推荐路线同样是客户端关闭委托确认。本设计的 §4.1(PostMessage 化)
即是该缺口的解。
- 客户端设置:「系统 → 快速交易」可关委托前确认/下单提示;验证码、废单
提示、风险警示类普遍**关不掉**(部分为社区资料推测,§6.3 真机核对)。
- xiadan 弹窗是否全为标准 `#32770` 未确证(新版皮肤疑似自绘)→ 按钮标签
枚举为主、投递回车为辅(用户真机验证新版「提示」框吃回车);
`tools/ths_dialog_dump.py` 用于逐版本核对弹窗控件结构。

## 9. 验收标准对照

| 验收项 | 由谁满足 |
|---|---|
| 任一弹窗场景,回执如实报成交状态或明确 unknown + 核单指引 | P1 存证回执 + 回查 + P0 超时回执 |
| 无人值守委托不允许无限期卡死 | P0(PostMessage + 25s 总超时 + 锁超时 + 消息循环解耦) |
| 超时后受控端主动上报卡死原因 | P0 degraded 标志 + P1 清扫时把残留弹窗文本入日志/回执 |
| 「报错但静默成交」不再发生 | 受控端 25s unknown 先于网关 30s;网关兜底文案改 unknown 语义 |

## 10. 实施顺序

1. **P0 全部 + PROTOCOL.md 条款**(半天级,纯代码 + 单测,可先行合入);
2. **P1 DialogSentry**(新模块 `ths/dialogs.py` + 四条下单/撤单路径改造 +
纯决策逻辑单测;`tools/ths_dialog_dump.py` 供真机核对弹窗控件结构);
3. **P2 真机回归**(依赖 1、2 合入后的 Windows 实测,回填文档与兜底规则);
4. 网关侧需求单独提给 `guling-mcp-gateway`。

## 11. 已选默认项(可改,改动只影响 const/config)

- 所有弹窗(含风险警示)一律肯定式消除——**用户已确认**:无人值守时流程
不允许停在中途,处理不耦合弹窗内容;知情权由回执 `dialogs` 存证字段保障。
因此不设 `dialog_risk_policy` 之类的配置开关。
- 受控端总超时 25s、锁等待 5s、弹窗等待循环 5s。
- 未知弹窗不动作、只截图 + unknown 上报。
23 changes: 23 additions & 0 deletions docs/ths_architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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。
Expand Down
Loading
Loading