diff --git a/.gitignore b/.gitignore index d74c416..3fe8a6e 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,6 @@ ret1.png .pytest_cache/ .coverage htmlcov/ + +# 受控端运行期数据目录(日志 + 下单台账),绝不入库 +guling-trader-data/ diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index 147363a..cc85058 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -1,4 +1,4 @@ -# guling-trader & Gateway/Relay Communication Protocol (V1) +# guling-trader & Gateway/Relay Communication Protocol (V1 传输层 / 回执契约 v2) This document formally specifies the communication protocol between the Windows Trader client (`guling-trader.exe`) and the Cloud Gateway (`guling-mcp-gateway` or any custom private relay). @@ -115,76 +115,167 @@ When the AI Client triggers a tool, the Gateway unwraps the tool parameter block ``` *Note that the Gateway has completely stripped standard MCP `"tools/call"` wrappers here, presenting pure naked broker commands to the Trader.* -### 3.2. Trader-to-Gateway: `reply` Envelope -The Trader executes the transaction and responds with a single-layer reply frame: +### 3.2. Trader-to-Gateway: `reply` Envelope(契约 v2) -#### Successful response: -```json -{ - "type": "reply", - "id": "transaction-unique-id", - "ok": true, - "result": { - "code": 0, - "status": "succeed", - "entrust_no": "1928374", - "msg": "下单成功" - } -} -``` +reply 帧本身仍是单层 `{type,id,ok,result|error}`;**`result` 一律是契约 v2 信封**, +所有工具无例外形(含 buy/sell/cancel,含失败与 busy): -#### Unconfirmed order response (`code == 2`): -Crucial safety safeguard for network jitter, verification popups, or delay in local order reflection: ```json { - "type": "reply", - "id": "transaction-unique-id", - "ok": false, - "result": { - "code": 2, - "status": "unknown", - "msg": "已提交但未能在未成交委托列表中匹配到对应订单,请自行人工或重试查询确认状态" - }, - "error": "已提交但未确认,请勿重复下单,需人工或查询确认状态" + "status": "succeed" | "failed" | "busy", + "code": "<机器枚举串>", + "data": <载荷或 null>, + "error": {"class": "<枚举>", "broker_msg": "<柜台原文或 null>", "message": "<我方人话>"} | null, + "contract_version": "2" } ``` -*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). +`contract_version` 亦通过网关 `initialize` 的 `serverInfo.contract_version` 暴露, +消费侧无需先调业务工具即可判版。 + +#### `code` 值域(机器枚举) + +| code | 含义 | status | +|---|---|---| +| `ok` | 成功 | succeed | +| `busy` | 受控端窗口忙,**本笔未执行** | busy | +| `call_timeout` | 查询类超时 | failed | +| `submitted_unconfirmed` | **已点提交,结果不可知** | failed | +| `rejected` | 柜台明确拒绝 | failed | +| `read_failed` | 抓不到数据 | failed | +| `table_mismatch` | 抓到的不是本次请求的表(已拒绝返回错表) | failed | +| `not_bound` | 未检测到 xiadan 窗口 | failed | +| `plugin_disabled` | 交易插件被禁用 | failed | +| `invalid_params` | 参数非法 / coid 复用冲突 | failed | +| `ledger_unavailable` | 下单台账不可用(**已拒单**) | failed | +| `not_found` | query_order 查无此单 / 撤单找不到该委托 | failed | +| `aborted` | 本笔已被超时作废(代次机制) | failed | +| `unsupported_method` | 方法不在白名单 | failed | +| `internal_error` | 受控端内部错误 | failed | + +#### ⚠️ `status: failed` **不等于**「未提交」 + +`code == submitted_unconfirmed` 时委托**可能已经在柜台**。判定必须看 `code`,不能看 +`status`。此时调用方唯一安全动作是**用同一 `client_order_id` 原样重发**(幂等,见 +3.2.3),或调 `query_order` 核实;**禁止改单重下**。 -#### 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: +#### `error.class` 两层分类(C2) -> `status: unknown`:受控端未在时限内响应,委托**可能已提交**。请先调用 -> `orders_filled` / `orders_active` 核实,**禁止直接重复下单**。 +* **结构性判定**(我方控制流得出,可靠):`busy` `call_timeout` `unknown_outcome` + `not_bound` `plugin_disabled` `read_failed` `table_mismatch` `invalid_params` + `ledger_unavailable` `not_found` `aborted` `internal_error` +* **柜台原文尽力映射**:`insufficient_funds` `price_out_of_limit` `invalid_quantity` + `suspended` `no_permission` `broker_timeout`,**认不出一律 `unknown`**。 -Rationale: on 2026-07-13 a bare timeout error while the order actually filled -("报错但静默成交") nearly caused a duplicated-order incident. +`broker_msg` 永远保留柜台原文。**`class == unknown` 与所有 unknown_outcome +一律不可自动重试**——关键词表是尽力而为的,误判「可重试」会真的重复下单。 +不可自动重试集合:`unknown` `unknown_outcome` `insufficient_funds` `no_permission` +`invalid_quantity` `invalid_params` `ledger_unavailable`。 + +#### 3.2.1 busy 背压语义(G3) + +受控端对 THS 单窗口全程串行(`win_lock`)。排队超过 5 s 即回: -#### Ordinary failure response (`code == 1`): ```json -{ - "type": "reply", - "id": "transaction-unique-id", - "ok": false, - "result": { - "code": 1, - "status": "failed", - "msg": "可用资金不足" - }, - "error": "可用资金不足" -} +{"status": "busy", "code": "busy", "data": {"submitted": false, "retry_after_secs": 3}, + "error": {"class": "busy", "broker_msg": null, "message": "..."}, "contract_version": "2"} ``` +`submitted: false` 是硬保证——busy 时指令**根本没执行**。建议退避 `retry_after_secs` +(当前 3 s)后重试。busy 是背压信号,不是故障。 + +受控端单笔总预算 25 s(低于网关 30 s),保证网关总能等到带语义的 reply。超时后受控端 +会作废在飞线程(代次机制)并置 degraded,下一笔进入前先清残留弹窗。 + +#### 3.2.2 空表语义(B3,永久锁定) + +**「真的没有」与「拿不到」必须可区分**,这是消费侧一切降级判断的地基: + +* 今天无挂单 / 无成交 → `status: succeed`,`data: []`。**空表是成功**。 +* 抓不到 / 抓到错表 → `status: failed`,`code: read_failed | table_mismatch`, + **绝不返回空数组冒充「没有」**。 + +#### 3.2.3 client_order_id 与幂等(C4/C5a) + +* coid **不写入柜台**(同花顺委托无自定义字段),仅存于受控端本地台账(SQLite, + 保留 ≥5 交易日)。 +* **幂等**:`buy`/`sell`/`cancel` 传 coid 后,同 id 重复提交**绝不产生第二次提交**, + 返回首次记录的回执;首次结果尚未落定时返回 `submitted_unconfirmed`—— + 这是合法态,不是 bug(最危险那一刻台账自己也不知道结果)。 +* 同 id **不同参数** → `invalid_params` 拒绝执行(调用方 id 复用 bug,不静默)。 +* **台账不可用一律拒单**(`ledger_unavailable`),禁静默降级为无幂等下单。 +* **回显是尽力而为**:`orders_active` / `orders_filled` 按 entrust_no join 回显 coid; + 回查不到合同编号的单(超时那批)与外部/人工单为 `null`。**对账主键是 entrust_no, + coid 是增强关联**。 +* 建议 coid 全局唯一且含账户维度——受控端 `switch_account` 是盲切,对账户身份无感知。 + +#### 3.2.4 查单(C5b) + +`query_order(client_order_id)` → `state` ∈ 未报/已报/部成/已成/已撤/废单/**未知**, +并给出 `resolution`:`by_entrust_no`(精确命中)/ `heuristic`(台账无合同编号,按 +代码+数量匹配,**同参重复单存在歧义**)/ `unresolved`(零命中或多命中 → `state=未知`, +需人工)。`unknown` 态被收窄到「回查确认前」,但**不可能被消灭**。 + +#### 3.2.5 数值与单位(C6) + +数值字段一律 JSON number:金额单位元(取整到分)、价格单位元(到厘)、数量单位股(int)、 +百分比键名以 `_pct` 结尾(不带 % 符号)。**THS 的 `--`/空占位符一律映射 `null`, +绝不映射 0**——0 是真实数字,把「没有」写成 0 会被下游当真值用。 +键名保留中文,与同花顺界面列名同字面(人工对屏审计零翻译成本)。 + +#### 3.2.6 委托表语义(C3) + +`orders_active` **只返回在飞单**(未报/已报/部成);已成/已撤/废单不出现。 +**状态识别不出的行按「在飞」保守返回**——宁可多给一行,也不能把一张活着的挂单藏起来 +(孤儿挂单架空止损哨兵是最险的失效模式)。行结构:`client_order_id, entrust_no, +证券代码, 证券名称, 方向, 委托价, 委托数量, 已成数量, 成交均价, 状态, 柜台备注`。 + +`order_event` 推送读的是**含终态的全量委托表**(内部通道),不受上述过滤影响。 + +#### 3.2.7 成交时间与时区(B2) + +`orders_filled.成交时间` 为 ISO 8601 带偏移。THS 成交表只给 `HH:MM:SS`, +**日期与时区由受控端本机时钟补齐,不是柜台时间**——对账时按此理解。 + +任何 reply 都可能携带 `data.dialogs` 数组(受控端自动处置的客户端弹窗存证: +`[{"title","text","action"}]`,仅作取证,无需动作)。 + +#### Gateway-side call timeout (MANDATORY semantics) + +受控端在 25 s 内必给回执(低于网关 30 s)。若网关自身超时仍未收到 `reply` +(受控端离线/断网),网关**不得**只回裸传输错误(如 `-32003`):下单类命令缺回执 +意味着委托**可能已提交**,必须给出等价于 `submitted_unconfirmed` 的语义文本: + +> 受控端未在时限内响应,委托**可能已提交**。安全动作=用同一 `client_order_id` +> 原样重发(幂等),或调 `query_order`/`orders_active` 核实;**禁止改单重下**。 + +Rationale:2026-07-13「报错但静默成交」几乎导致重复下单。 + +网关另有两条硬性要求: + +1. **失败也必须把完整信封交给客户端**(`isError: true` + `content[0].text` 为信封 + JSON),只回一句散文等于在网关层丢掉机器分类能力; +2. **回执配对键 = (agentToken, 网关自生成 id)**,客户端 JSON-RPC id 只回填响应、 + **不参与配对**——id 唯一性不是客户端的契约义务(G1/G2;2026-08-03 串线事故根因)。 + +#### 3.2.8 会话生命周期(G4) + +| 场景 | 返回 | +|---|---| +| sid 过期/失效 | JSON-RPC error `-32001`,文案含 `Session expired or invalid` → 重新握手 | +| 未带凭证 | `-32001`,文案含 `Missing agent token` | +| 受控端离线 | `-32001`,文案含「Windows 交易端 WebSocket 未在线」→ 非会话问题,勿重握手 | +| 网关等待超时 | `-32003` + 上述 unknown 语义 | + +#### 3.2.9 消费侧节奏建议(S2) + +受控端对 THS 单窗口全程串行,吞吐上限由 RPA 决定,不是并发能力问题: + +* 单账户建议**并发 1**(多客户端并发只会互相 busy); +* 最小轮询间隔建议 ≥ 60 s(查询类单笔典型 1–3 s,交割单可达数十秒); +* 收到 busy 按 `retry_after_secs` 退避,不要立即重试; +* 下单类务必带 coid,超时后**重发同 id**而不是新建单。 + ### 3.3. Trader-to-Gateway: `order_event` Push (Unsolicited) Unlike `reply` (which always answers a preceding `call` and carries its `id`), diff --git a/docs/tools_schema.json b/docs/tools_schema.json index b5612ea..4fbd20e 100644 --- a/docs/tools_schema.json +++ b/docs/tools_schema.json @@ -4,7 +4,7 @@ "tools": [ { "name": "balance", - "description": "查询资金账户余额(包括资金余额、可用资金、可取资金、股票市值、总资产、当日盈亏等)。", + "description": "查询资金账户余额。data 为 number 字段:资金余额/冻结金额/可用金额/可取金额/股票市值/总资产/持仓盈亏/当日盈亏(单位元),当日盈亏比_pct(百分比数值)。缺值为 null(不是 0)。", "inputSchema": { "type": "object", "properties": {}, @@ -13,7 +13,7 @@ }, { "name": "position", - "description": "查询当前股票持仓。返回持仓列表,包含证券代码、证券名称、股票余额、可用余额、成本价、市价、盈亏等字段。", + "description": "查询当前股票持仓。每行:证券代码, 证券名称, 股票余额, 可用余额, 冻结数量(股), 参考成本价, 市价(元), market_value, 浮动盈亏(元), 盈亏比例_pct。缺值为 null。", "inputSchema": { "type": "object", "properties": {}, @@ -22,7 +22,7 @@ }, { "name": "orders_active", - "description": "查询当日未成交的委托单列表(可用于撤单),包含委托编号(entrust_no)、证券代码、证券名称、委托数量、委托价格、委托方向、委托状态等字段。", + "description": "查询**在飞**委托单(未报/已报/部成)。已成/已撤/废单不出现在本表(契约 v2 C3);状态识别不出的行按在飞保守返回。每行:client_order_id, entrust_no, 证券代码, 证券名称, 方向, 委托价, 委托数量, 已成数量, 成交均价, 状态, 柜台备注。数值为 number,缺值为 null(不是 0)。", "inputSchema": { "type": "object", "properties": {}, @@ -31,7 +31,7 @@ }, { "name": "orders_filled", - "description": "查询当日已成交的委托单历史记录,包含委托编号、成交编号、证券代码、证券名称、成交数量、成交均价、成交金额等字段。", + "description": "查询当日成交明细。每行:client_order_id, entrust_no, 成交编号, 成交时间(ISO8601,日期与时区来自受控端本机时钟,非柜台时间), 证券代码, 证券名称, 方向, 成交数量, 成交均价, 成交金额。数值为 number,缺值为 null。", "inputSchema": { "type": "object", "properties": {}, @@ -88,7 +88,7 @@ }, "client_order_id": { "type": "string", - "description": "可选的客户端自定义订单 ID" + "description": "客户端订单 ID,**幂等键**:同一 id 重复提交只会下单一次,重发返回首次回执(首次结果未知时返回 unknown_outcome,仍不会产生第二次提交)。超时后的安全动作就是用同一 id 原样重发。该 id 不写入柜台,仅存于受控端台账,orders_active/orders_filled 尽力回显(回查不到合同编号的单与外部单为 null)。建议全局唯一并含账户维度。" } }, "required": [ @@ -118,7 +118,7 @@ }, "client_order_id": { "type": "string", - "description": "可选的客户端自定义订单 ID" + "description": "客户端订单 ID,**幂等键**:同一 id 重复提交只会下单一次,重发返回首次回执(首次结果未知时返回 unknown_outcome,仍不会产生第二次提交)。超时后的安全动作就是用同一 id 原样重发。该 id 不写入柜台,仅存于受控端台账,orders_active/orders_filled 尽力回显(回查不到合同编号的单与外部单为 null)。建议全局唯一并含账户维度。" } }, "required": [ @@ -137,6 +137,10 @@ "entrust_no": { "type": "string", "description": "要撤销的委托编号(从 orders_active 中获取)" + }, + "client_order_id": { + "type": "string", + "description": "客户端订单 ID,**幂等键**:同一 id 重复提交只会下单一次,重发返回首次回执(首次结果未知时返回 unknown_outcome,仍不会产生第二次提交)。超时后的安全动作就是用同一 id 原样重发。该 id 不写入柜台,仅存于受控端台账,orders_active/orders_filled 尽力回显(回查不到合同编号的单与外部单为 null)。建议全局唯一并含账户维度。" } }, "required": [ @@ -163,6 +167,24 @@ ], "additionalProperties": false } + }, + { + "name": "query_order", + "description": "按 client_order_id 查单(契约 v2 C5b)。返回 state(未报/已报/部成/已成/已撤/废单/未知)+首次回执快照+分辨率 resolution:by_entrust_no=按合同编号精确命中;heuristic=台账无合同编号时按代码/数量匹配,存在同参重复单歧义;unresolved=实表中无法唯一定位,state=未知需人工。与 buy/sell/cancel 的幂等(同 id 重发不重复下单)配对使用。", + "inputSchema": { + "type": "object", + "properties": { + "client_order_id": { + "type": "string", + "description": "下单时传入的 client_order_id" + } + }, + "required": [ + "client_order_id" + ], + "additionalProperties": false + } } - ] + ], + "contract_version": "2" } diff --git a/src/trader/contract.py b/src/trader/contract.py new file mode 100644 index 0000000..c28d99b --- /dev/null +++ b/src/trader/contract.py @@ -0,0 +1,215 @@ +"""对外回执契约 v2(消费方契约冻结 C1/C2/C6)。 + +一处定义信封、机器枚举、错误分类与数值规范化,win/dispatcher 各处只调这里—— +契约漂移只可能发生在这一个文件里,`tests/test_contract_envelope.py` 逐条钉死。 + +信封(所有工具无例外形,含 buy/sell/cancel):: + + {"status": "succeed"|"failed"|"busy", + "code": <机器枚举串>, + "data": <载荷或 null>, + "error": {"class": <枚举>, "broker_msg": <柜台原文或 null>, "message": <我方人话>} | null, + "contract_version": "2"} + +两条容易踩的语义,PROTOCOL.md 同步写死: + +* **status=failed 不等于「未提交」**。下单动作超时时真相不可知,此时 + status=failed + code=submitted_unconfirmed + error.class=unknown_outcome。 + 调用方的安全动作是**用同一个 client_order_id 原样重发**(幂等,见 order_ledger), + 绝不是改单重下。 +* **error.class=unknown 一律不可自动重试**。柜台原文映射是尽力而为的关键词表, + 认不出来就必须认不出来——误判「可重试」会真的重复下单。 +""" +from __future__ import annotations + +from typing import Any, Optional + +CONTRACT_VERSION = "2" + +# --- status(C1 冻结为三值)------------------------------------------------ +STATUS_SUCCEED = "succeed" +STATUS_FAILED = "failed" +STATUS_BUSY = "busy" + +# --- code:机器枚举串 ------------------------------------------------------- +CODE_OK = "ok" +CODE_BUSY = "busy" +CODE_CALL_TIMEOUT = "call_timeout" +CODE_SUBMITTED_UNCONFIRMED = "submitted_unconfirmed" # 已点提交,结果不可知 +CODE_REJECTED = "rejected" # 柜台明确拒绝 +CODE_READ_FAILED = "read_failed" # 抓不到数据 +CODE_TABLE_MISMATCH = "table_mismatch" # 抓到的不是本次请求的表 +CODE_NOT_BOUND = "not_bound" # 受控端未绑定客户端窗口 +CODE_PLUGIN_DISABLED = "plugin_disabled" +CODE_INVALID_PARAMS = "invalid_params" +CODE_LEDGER_UNAVAILABLE = "ledger_unavailable" # 台账不可用 → 拒单,禁降级 +CODE_NOT_FOUND = "not_found" # query_order 查无此单 +CODE_UNSUPPORTED_METHOD = "unsupported_method" +CODE_INTERNAL_ERROR = "internal_error" +CODE_ABORTED = "aborted" # 本笔已被超时作废(代次机制) + +# --- error.class(C2 两层分类)--------------------------------------------- +# 第一层:结构性判定——由我方自己的控制流得出,可靠。 +CLS_BUSY = "busy" +CLS_CALL_TIMEOUT = "call_timeout" +CLS_UNKNOWN_OUTCOME = "unknown_outcome" +CLS_NOT_BOUND = "not_bound" +CLS_PLUGIN_DISABLED = "plugin_disabled" +CLS_READ_FAILED = "read_failed" +CLS_TABLE_MISMATCH = "table_mismatch" +CLS_INVALID_PARAMS = "invalid_params" +CLS_LEDGER_UNAVAILABLE = "ledger_unavailable" +CLS_NOT_FOUND = "not_found" +CLS_INTERNAL_ERROR = "internal_error" +CLS_ABORTED = "aborted" +# 第二层:柜台原文尽力映射——认不出即 unknown,绝不猜。 +CLS_INSUFFICIENT_FUNDS = "insufficient_funds" +CLS_PRICE_OUT_OF_LIMIT = "price_out_of_limit" +CLS_INVALID_QUANTITY = "invalid_quantity" +CLS_SUSPENDED = "suspended" +CLS_NO_PERMISSION = "no_permission" +CLS_BROKER_TIMEOUT = "broker_timeout" +CLS_UNKNOWN = "unknown" + +# 柜台原文关键词 → class。顺序即优先级(先匹配到的赢)。 +# 只登记高置信度词;拿不准的一律落到 unknown——消费侧对 unknown 的处置是 +# 「不可自动重试」,误判成可重试会真的重复下单。 +_BROKER_PATTERNS: tuple[tuple[tuple[str, ...], str], ...] = ( + (("资金不足", "可用资金不足", "余额不足", "购买力不足"), CLS_INSUFFICIENT_FUNDS), + (("超出涨跌幅", "价格超出", "涨跌停", "超过涨停", "低于跌停", "价格不在"), CLS_PRICE_OUT_OF_LIMIT), + (("数量必须", "委托数量", "最小交易单位", "数量不是", "股数", "整数倍"), CLS_INVALID_QUANTITY), + (("停牌", "暂停交易", "非交易时间", "不在交易时段"), CLS_SUSPENDED), + (("无权限", "未开通", "未签署", "权限不足", "不具备"), CLS_NO_PERMISSION), + (("柜台超时", "通讯超时", "网络超时", "请求超时", "服务器繁忙"), CLS_BROKER_TIMEOUT), +) + +# 不可自动重试的 class:消费侧据此机械分流(PROTOCOL.md 同步)。 +NON_RETRYABLE_CLASSES = frozenset({ + CLS_UNKNOWN, CLS_UNKNOWN_OUTCOME, CLS_INSUFFICIENT_FUNDS, CLS_NO_PERMISSION, + CLS_INVALID_QUANTITY, CLS_INVALID_PARAMS, CLS_LEDGER_UNAVAILABLE, +}) + + +def classify_broker_message(text: Optional[str]) -> str: + """柜台原文 → error.class。认不出来就是 unknown,这是有意的。""" + if not text: + return CLS_UNKNOWN + for keywords, cls in _BROKER_PATTERNS: + for kw in keywords: + if kw in text: + return cls + return CLS_UNKNOWN + + +# --- 信封构造 --------------------------------------------------------------- + +def ok(data: Any = None) -> dict[str, Any]: + return {"status": STATUS_SUCCEED, "code": CODE_OK, "data": data, + "error": None, "contract_version": CONTRACT_VERSION} + + +def fail(code: str, error_class: str, message: str, + broker_msg: Optional[str] = None, data: Any = None, + status: str = STATUS_FAILED) -> dict[str, Any]: + return {"status": status, "code": code, "data": data, + "error": {"class": error_class, "broker_msg": broker_msg, "message": message}, + "contract_version": CONTRACT_VERSION} + + +def busy(message: str) -> dict[str, Any]: + return fail(CODE_BUSY, CLS_BUSY, message, status=STATUS_BUSY) + + +def broker_rejected(broker_msg: str, message: Optional[str] = None, + data: Any = None) -> dict[str, Any]: + """柜台明确拒绝:class 由原文尽力映射,原文一律原样带回。""" + return fail(CODE_REJECTED, classify_broker_message(broker_msg), + message or "柜台拒绝了本次委托", broker_msg=broker_msg, data=data) + + +def submitted_unconfirmed(message: str, data: Any = None, + broker_msg: Optional[str] = None) -> dict[str, Any]: + """已点提交但结果不可知。调用方唯一安全动作=同 client_order_id 原样重发。""" + return fail(CODE_SUBMITTED_UNCONFIRMED, CLS_UNKNOWN_OUTCOME, message, + broker_msg=broker_msg, data=data) + + +def is_succeed(envelope: Any) -> bool: + return isinstance(envelope, dict) and envelope.get("status") == STATUS_SUCCEED + + +# --- C6 数值与单位规范化 ----------------------------------------------------- +# THS 一律给字符串,且用 "--" / "" / "-" 表示「没有这个值」。 +# 空占位符必须映射 null 而不是 0:0 是一个真实数字,把「没有」写成 0 会被 +# 下游当真值用(真钱 sizing 的输入)。 + +_NULLISH = frozenset({"", "-", "--", "---", "N/A", "n/a", "nan"}) + + +def _clean(value: Any) -> Optional[str]: + if value is None: + return None + s = str(value).strip().replace(",", "").replace("%", "") + if s in _NULLISH: + return None + return s + + +def money(value: Any) -> Optional[float]: + """金额(单位:元),取整到分。""" + s = _clean(value) + if s is None: + return None + try: + return round(float(s), 2) + except ValueError: + return None + + +def price(value: Any) -> Optional[float]: + """价格(单位:元),保留到厘——同花顺价格是三位小数。""" + s = _clean(value) + if s is None: + return None + try: + return round(float(s), 3) + except ValueError: + return None + + +def qty(value: Any) -> Optional[int]: + """数量(单位:股)。""" + s = _clean(value) + if s is None: + return None + try: + return int(float(s)) + except ValueError: + return None + + +def pct(value: Any) -> Optional[float]: + """百分比数值(键名一律以 _pct 结尾,不带 % 符号)。""" + s = _clean(value) + if s is None: + return None + try: + return round(float(s), 4) + except ValueError: + return None + + +def text(value: Any) -> Optional[str]: + return _clean(value) + + +def direction(value: Any) -> Optional[str]: + """操作列 → 方向枚举「买入」/「卖出」;认不出保留原文(不猜)。""" + s = _clean(value) + if s is None: + return None + if "买" in s: + return "买入" + if "卖" in s: + return "卖出" + return s diff --git a/src/trader/dispatcher.py b/src/trader/dispatcher.py index eec6ac0..0dc7ffb 100644 --- a/src/trader/dispatcher.py +++ b/src/trader/dispatcher.py @@ -1,10 +1,16 @@ -"""RPC 分派:call frame → backend method → reply frame""" +"""RPC 分派:call frame → backend method → reply frame + +契约 v2:backend 返回的已经是统一信封(见 contract.py),dispatcher 只负责 +①幂等台账(C5a)②查单(C5b)③busy/超时这两种「还没进 backend」的信封 ④装进 reply 帧。 +""" import asyncio import json import logging from pathlib import Path from typing import Any, Optional +from . import contract +from .order_ledger import LedgerUnavailable from .ths.win import WinThsBackend logger = logging.getLogger(__name__) @@ -16,13 +22,19 @@ LOCK_TIMEOUT_SECS = 5.0 # 会真实改变账户状态的方法:超时/busy 回执必须带「可能已提交,先核单」语义。 ORDER_METHODS = {"buy", "sell", "cancel"} +# 走 client_order_id 幂等台账的方法(C5a)。 +IDEMPOTENT_METHODS = {"buy", "sell", "cancel"} +# busy 是背压信号:告诉调用方等多久再来,别让它自己猜(G3)。 +BUSY_BACKOFF_HINT_SECS = 3 # Fallback tools schema in case the external JSON file cannot be found (e.g., in a packaged PyInstaller environment) FALLBACK_TOOLS_SCHEMA = { + "$schema": "https://json-schema.org/draft/2020-12", + "version": "1.0.0", "tools": [ { "name": "balance", - "description": "查询资金账户余额(包括资金余额、可用资金、可取资金、股票市值、总资产、当日盈亏等)。", + "description": "查询资金账户余额。data 为 number 字段:资金余额/冻结金额/可用金额/可取金额/股票市值/总资产/持仓盈亏/当日盈亏(单位元),当日盈亏比_pct(百分比数值)。缺值为 null(不是 0)。", "inputSchema": { "type": "object", "properties": {}, @@ -31,7 +43,7 @@ }, { "name": "position", - "description": "查询当前股票持仓。返回持仓列表,包含证券代码、证券名称、股票余额、可用余额、成本价、市价、盈亏等字段。", + "description": "查询当前股票持仓。每行:证券代码, 证券名称, 股票余额, 可用余额, 冻结数量(股), 参考成本价, 市价(元), market_value, 浮动盈亏(元), 盈亏比例_pct。缺值为 null。", "inputSchema": { "type": "object", "properties": {}, @@ -40,7 +52,7 @@ }, { "name": "orders_active", - "description": "查询当日未成交的委托单列表(可用于撤单),包含委托编号(entrust_no)、证券代码、证券名称、委托数量、委托价格、委托方向、委托状态等字段。", + "description": "查询**在飞**委托单(未报/已报/部成)。已成/已撤/废单不出现在本表(契约 v2 C3);状态识别不出的行按在飞保守返回。每行:client_order_id, entrust_no, 证券代码, 证券名称, 方向, 委托价, 委托数量, 已成数量, 成交均价, 状态, 柜台备注。数值为 number,缺值为 null(不是 0)。", "inputSchema": { "type": "object", "properties": {}, @@ -49,7 +61,7 @@ }, { "name": "orders_filled", - "description": "查询当日已成交的委托单历史记录,包含委托编号、成交编号、证券代码、证券名称、成交数量、成交均价、成交金额等字段。", + "description": "查询当日成交明细。每行:client_order_id, entrust_no, 成交编号, 成交时间(ISO8601,日期与时区来自受控端本机时钟,非柜台时间), 证券代码, 证券名称, 方向, 成交数量, 成交均价, 成交金额。数值为 number,缺值为 null。", "inputSchema": { "type": "object", "properties": {}, @@ -65,7 +77,12 @@ "date_range": { "type": "string", "description": "查询的时间跨度,可选值:近一周、近一月、近三月、近一年;默认近一年", - "enum": ["近一周", "近一月", "近三月", "近一年"], + "enum": [ + "近一周", + "近一月", + "近三月", + "近一年" + ], "default": "近一年" } }, @@ -101,10 +118,13 @@ }, "client_order_id": { "type": "string", - "description": "可选的客户端自定义订单 ID" + "description": "客户端订单 ID,**幂等键**:同一 id 重复提交只会下单一次,重发返回首次回执(首次结果未知时返回 unknown_outcome,仍不会产生第二次提交)。超时后的安全动作就是用同一 id 原样重发。该 id 不写入柜台,仅存于受控端台账,orders_active/orders_filled 尽力回显(回查不到合同编号的单与外部单为 null)。建议全局唯一并含账户维度。" } }, - "required": ["stock_no", "amount"], + "required": [ + "stock_no", + "amount" + ], "additionalProperties": False } }, @@ -128,10 +148,13 @@ }, "client_order_id": { "type": "string", - "description": "可选的客户端自定义订单 ID" + "description": "客户端订单 ID,**幂等键**:同一 id 重复提交只会下单一次,重发返回首次回执(首次结果未知时返回 unknown_outcome,仍不会产生第二次提交)。超时后的安全动作就是用同一 id 原样重发。该 id 不写入柜台,仅存于受控端台账,orders_active/orders_filled 尽力回显(回查不到合同编号的单与外部单为 null)。建议全局唯一并含账户维度。" } }, - "required": ["stock_no", "amount"], + "required": [ + "stock_no", + "amount" + ], "additionalProperties": False } }, @@ -144,9 +167,15 @@ "entrust_no": { "type": "string", "description": "要撤销的委托编号(从 orders_active 中获取)" + }, + "client_order_id": { + "type": "string", + "description": "客户端订单 ID,**幂等键**:同一 id 重复提交只会下单一次,重发返回首次回执(首次结果未知时返回 unknown_outcome,仍不会产生第二次提交)。超时后的安全动作就是用同一 id 原样重发。该 id 不写入柜台,仅存于受控端台账,orders_active/orders_filled 尽力回显(回查不到合同编号的单与外部单为 null)。建议全局唯一并含账户维度。" } }, - "required": ["entrust_no"], + "required": [ + "entrust_no" + ], "additionalProperties": False } }, @@ -163,11 +192,31 @@ "maximum": 9 } }, - "required": ["slot"], + "required": [ + "slot" + ], + "additionalProperties": False + } + }, + { + "name": "query_order", + "description": "按 client_order_id 查单(契约 v2 C5b)。返回 state(未报/已报/部成/已成/已撤/废单/未知)+首次回执快照+分辨率 resolution:by_entrust_no=按合同编号精确命中;heuristic=台账无合同编号时按代码/数量匹配,存在同参重复单歧义;unresolved=实表中无法唯一定位,state=未知需人工。与 buy/sell/cancel 的幂等(同 id 重发不重复下单)配对使用。", + "inputSchema": { + "type": "object", + "properties": { + "client_order_id": { + "type": "string", + "description": "下单时传入的 client_order_id" + } + }, + "required": [ + "client_order_id" + ], "additionalProperties": False } } - ] + ], + "contract_version": "2" } from . import config as _config @@ -207,9 +256,129 @@ def load_tools_schema() -> dict[str, Any]: "sell", "cancel", "switch_account", + "query_order", } +def _ledger_or_none(backend): + return getattr(backend, "ledger", None) + + +def _release_reservation(backend, coid: str) -> None: + led = _ledger_or_none(backend) + if led is not None: + try: + led.release(coid) + except Exception: + logger.warning("台账撤销登记失败 coid=%s", coid, exc_info=True) + + +def _record_brief(record: Optional[dict]) -> dict: + """台账条目的对外摘要(不回吐内部字段)。""" + r = record or {} + return {"state": r.get("state"), "entrust_no": r.get("entrust_no"), + "created_at": r.get("created_at")} + + +def _replay_receipt(coid: str, record: Optional[dict]) -> dict: + """同 id 重发:返回首次回执;首次尚未落定则回 unknown_outcome。 + + 无论哪条分支,**都不会产生第二次提交**——这就是 C5a 的全部承诺。 + 「首次结果本身就是未知」是合法态:最危险那一刻台账自己也不知道结果, + 契约不撒谎(需求方 v2 已把「unknown 从此不存在」改为「收窄至回查确认前」)。 + """ + record = record or {} + receipt = record.get("receipt") + if record.get("state") == "done" and isinstance(receipt, dict): + replayed = json.loads(json.dumps(receipt, ensure_ascii=False)) + if isinstance(replayed.get("data"), dict): + replayed["data"]["idempotent_replay"] = True + return replayed + return contract.submitted_unconfirmed( + f"client_order_id={coid} 的上一笔提交尚未落定回执,本次未产生第二次提交。" + "请调 query_order 核实,或稍后用同一 id 再次重发", + data={"submitted": True, "client_order_id": coid, "idempotent_replay": True, + "first_record": _record_brief(record)}) + + +async def _query_order(backend, client_order_id: Any) -> dict: + """C5b 按 client_order_id 查单:台账定位 + 实时委托/成交表核实。 + + 分辨率分三档,回执里明说是哪一档——消费侧据此决定信不信: + ``by_entrust_no``(台账有 entrust_no,实表精确命中)、 + ``heuristic``(entrust_no 未知,按代码/方向/数量/价格唯一匹配)、 + ``unresolved``(零命中或多命中 → 未知,需人工)。 + """ + if not client_order_id: + return contract.fail(contract.CODE_INVALID_PARAMS, contract.CLS_INVALID_PARAMS, + "query_order 缺少 client_order_id") + coid = str(client_order_id) + led = _ledger_or_none(backend) + if led is None: + return contract.fail(contract.CODE_LEDGER_UNAVAILABLE, contract.CLS_LEDGER_UNAVAILABLE, + "下单台账不可用,无法查单") + try: + record = await asyncio.to_thread(led.get, coid) + except LedgerUnavailable as e: + return contract.fail(contract.CODE_LEDGER_UNAVAILABLE, contract.CLS_LEDGER_UNAVAILABLE, + f"台账读取失败:{e}") + if record is None: + return contract.fail( + contract.CODE_NOT_FOUND, contract.CLS_NOT_FOUND, + f"台账中没有 client_order_id={coid}:" + "本受控端未提交过该 id,或已超出台账保留窗口") + + active = await backend.orders_active() + filled = await backend.orders_filled() + active_rows = (active.get("data") or []) if contract.is_succeed(active) else [] + filled_rows = (filled.get("data") or []) if contract.is_succeed(filled) else [] + tables_ok = contract.is_succeed(active) and contract.is_succeed(filled) + + entrust_no = record.get("entrust_no") + resolution, state, matched = "unresolved", "未知", [] + if entrust_no: + matched = [r for r in active_rows if r.get("entrust_no") == entrust_no] + if matched: + resolution, state = "by_entrust_no", matched[0].get("状态") or "未知" + else: + matched = [r for r in filled_rows if r.get("entrust_no") == entrust_no] + if matched: + resolution, state = "by_entrust_no", "已成" + else: + # entrust_no 未知(提交超时那批):按首次请求指纹启发式匹配。 + try: + fp = json.loads(record.get("fingerprint") or "{}") + except (TypeError, ValueError): + fp = {} + stock_no, amount = str(fp.get("stock_no") or ""), fp.get("amount") + + def _hit(rows, qty_key): + return [r for r in rows + if (r.get("证券代码") or "") == stock_no + and (amount is None or r.get(qty_key) == amount)] + + cand = _hit(active_rows, "委托数量") + if len(cand) == 1: + resolution, state, matched = "heuristic", cand[0].get("状态") or "未知", cand + elif not cand: + cand = _hit(filled_rows, "成交数量") + if len(cand) == 1: + resolution, state, matched = "heuristic", "已成", cand + + return contract.ok({ + "client_order_id": coid, + "state": state, # 未报/已报/部成/已成/已撤/废单/未知 + "resolution": resolution, + "entrust_no": entrust_no, + "ledger_state": record.get("state"), + "first_receipt": record.get("receipt"), + "matched_rows": matched, + "tables_readable": tables_ok, # False ⇒ state 的可信度仅限台账 + "note": ("state=未知 表示实表中无法唯一定位该单,需人工核实;" + "resolution=heuristic 表示按代码/数量匹配而非 id 关联,存在同参重复单歧义"), + }) + + async def handle_call( frame: dict[str, Any], backend: WinThsBackend, @@ -222,8 +391,11 @@ async def handle_call( reply = {"type": "reply", "id": frame_id} if method not in METHOD_WHITELIST: + msg = f"方法 '{method}' 不支持" reply["ok"] = False - reply["error"] = f"方法 '{method}' 不支持" + reply["result"] = contract.fail(contract.CODE_UNSUPPORTED_METHOD, + contract.CLS_INVALID_PARAMS, msg) + reply["error"] = msg return reply if method == "tools/list": @@ -246,12 +418,62 @@ async def handle_call( "sell", "cancel", "switch_account", + "query_order", } if method in trading_methods and not cfg.enable_ths_plugin: + msg = "同花顺实盘交易插件已被禁用,请在客户端界面中开启该插件模块!" reply["ok"] = False - reply["error"] = "同花顺实盘交易插件已被禁用,请在客户端界面中开启该插件模块!" + reply["result"] = contract.fail(contract.CODE_PLUGIN_DISABLED, + contract.CLS_PLUGIN_DISABLED, msg) + reply["error"] = msg return reply + # --- C5a 幂等:在**拿锁之前**查台账。重发直接返回首次回执,连排队都不用排, + # 更不会走到点提交那一步。台账不可用一律拒单(需求方拍板:禁静默降级)。 + reserved_coid: Optional[str] = None + 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": + result = _replay_receipt(coid, record) + reply["ok"] = contract.is_succeed(result) + reply["result"] = result + if not reply["ok"]: + reply["error"] = (result.get("error") or {}).get("message") or "重复提交" + logger.info("[RPC] 幂等命中 coid=%s state=%s,未产生第二次提交", + coid, (record or {}).get("state")) + return reply + reserved_coid = coid + # 串行化 THS 单窗口访问:order_watch 轮询与下单/查询共用 backend.win_lock。 # 拿锁带超时:持锁方若被弹窗/慢操作拖住,排队方不能无限饿死——回 busy # 让调用方稍后重试,并提醒先核实前序委托。 @@ -261,9 +483,15 @@ async def handle_call( await asyncio.wait_for(backend.win_lock.acquire(), LOCK_TIMEOUT_SECS) except asyncio.TimeoutError: msg = ("受控端正忙或被弹窗阻塞,本笔指令未执行。" - "请先调 orders_active/orders_filled 核实前序委托状态后再重试") + f"建议退避 {BUSY_BACKOFF_HINT_SECS}s 后重试;" + "下单类请先调 orders_active/orders_filled 或 query_order 核实前序委托") + result = contract.busy(msg) + result["data"] = {"submitted": False, + "retry_after_secs": BUSY_BACKOFF_HINT_SECS} + if reserved_coid: + _release_reservation(backend, reserved_coid) reply["ok"] = False - reply["result"] = {"code": 2, "status": "busy", "msg": msg} + reply["result"] = result reply["error"] = msg return reply try: @@ -280,7 +508,8 @@ 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")) + logger.info("[RPC] balance → status=%s code=%s", + (r or {}).get("status"), (r or {}).get("code")) return r if method == "position": return await backend.position() @@ -299,7 +528,7 @@ async def _invoke() -> Any: 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") + _eno = ((r or {}).get("data") or {}).get("entrust_no") if _eno: backend.agent_entrust_nos.add(str(_eno)) return r @@ -307,7 +536,10 @@ async def _invoke() -> Any: return await backend.cancel(params.get("entrust_no")) if method == "switch_account": return await backend.switch_account(params.get("slot")) - return {"code": 1, "error": "内部错误"} + if method == "query_order": + return await _query_order(backend, params.get("client_order_id")) + return contract.fail(contract.CODE_INTERNAL_ERROR, + contract.CLS_INTERNAL_ERROR, f"未实现的方法 {method}") try: # 受控端总超时(低于网关 30s):无论内部卡在哪,25s 内必有明确回执。 @@ -316,48 +548,73 @@ async def _invoke() -> Any: result = await asyncio.wait_for(_invoke(), CALL_TIMEOUT_SECS) except asyncio.TimeoutError: backend.degraded = True + # wait_for 只取消了等待协程——to_thread 起的工作线程取消不掉,它还在 + # 发全局按键,而下面 finally 马上要放 win_lock 让下一笔进场。作废代次, + # 让那个线程在下一个检查点(翻页/抓表/弹窗/提交)自己停手, + # 否则两个线程同击一个 xiadan 窗口 → 抓错表、抢弹窗。 + invalidate = getattr(backend, "invalidate_inflight", None) + if invalidate: + invalidate(f"{method} 超过 {CALL_TIMEOUT_SECS}s 未完成") 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} + result = contract.submitted_unconfirmed( + "受控端处理超时(疑似弹窗或客户端无响应),委托可能已提交。" + "安全动作=用同一 client_order_id 原样重发(幂等,不会重复下单)," + "或调 query_order/orders_active 核实;勿改单重下", + data={"submitted": True}) else: - result = {"code": 1, "status": "failed", - "msg": "受控端查询超时(疑似弹窗或客户端无响应),请稍后重试"} + result = contract.fail( + contract.CODE_CALL_TIMEOUT, contract.CLS_CALL_TIMEOUT, + "受控端查询超时(疑似弹窗或客户端无响应),请稍后重试") - if not isinstance(result, dict): - reply["ok"] = False - reply["error"] = "未知错误" - return reply + if not isinstance(result, dict) or "status" not in result: + result = contract.fail(contract.CODE_INTERNAL_ERROR, + contract.CLS_INTERNAL_ERROR, + f"受控端返回了非契约形态:{type(result).__name__}") - code = result.get("code") - if code == 0: - reply["ok"] = True - reply["result"] = result - else: - reply["ok"] = False - # 透传后端的 code/status/msg,让上层能区分"已提交未确认(code=2)"和真失败, - # 而不是把一切塌缩成"未知错误"。 - reply["result"] = result - if code == 2: - # 委托已提交但未能在委托列表回查确认(验证码/刷新延迟常见)。 - # 这不是下单失败——必须明确告知,避免上层重复下单造成双倍成交。 - reply["error"] = ( - result.get("msg") - or "委托可能已提交但未确认,请勿重复下单,需人工或查询确认状态" - ) - else: - reply["error"] = ( - result.get("error") or result.get("msg") or "未知错误" - ) + # 下单类:回填 client_order_id 并把首次回执落台账(幂等重发就靠它)。 + if reserved_coid: + if isinstance(result.get("data"), dict): + result["data"]["client_order_id"] = reserved_coid + elif result.get("data") is None: + result["data"] = {"client_order_id": reserved_coid} + entrust_no = (result.get("data") or {}).get("entrust_no") + try: + led = _ledger_or_none(backend) + if led is not None: + await asyncio.to_thread(led.complete, reserved_coid, 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}) + reserved_coid = None + + reply["result"] = result + reply["ok"] = contract.is_succeed(result) + if not reply["ok"]: + reply["error"] = ((result.get("error") or {}).get("message") + or f"{result.get('status')}/{result.get('code')}") except Exception as e: logger.error("处理 RPC '%s' 出错:%s", method, e) reply["ok"] = False + reply["result"] = contract.fail(contract.CODE_INTERNAL_ERROR, + contract.CLS_INTERNAL_ERROR, str(e)) reply["error"] = str(e) finally: if needs_window: backend.win_lock.release() + # 走到这里还留着预留说明本笔没能落定回执(异常/未知路径): + # 保留登记而不是删除——宁可让重发命中「上一笔结果未知」,也不能让它变成新单。 + if reserved_coid: + logger.warning("coid=%s 未落定回执,台账保留为 submitting(重发将回 unknown)", + reserved_coid) return reply diff --git a/src/trader/main.py b/src/trader/main.py index 2c5ba01..e75026d 100644 --- a/src/trader/main.py +++ b/src/trader/main.py @@ -15,22 +15,49 @@ import sys import threading import traceback +from datetime import datetime from pathlib import Path from typing import Optional # ---- 文件日志 + stderr/stdout 重定向 ---- # PyInstaller --windowed 模式 stdout/stderr 被吞掉,wine 下 print 全部消失。 -# 启动期就把所有输出写到本地配置目录下的 trader.log(每次启动覆盖)。 +# 启动期就把所有输出写到本地配置目录下的 trader.log。 # 这是 wine/CrossOver 用户的唯一诊断渠道——异常 traceback 也会写进去。 +# +# 追加 + 滚动,不再每次启动覆盖:2026-08-04 查 08-03 查询串线时,受控端凌晨重启一次, +# 'w' 模式把当天全部 RPC 日志(含每笔 call 的 id/method,正是定位串线归属的关键证据) +# 抹干净,回溯路径直接灭失。日志无限增长的原顾虑改由体积滚动解决。 + +LOG_MAX_BYTES = 5 * 1024 * 1024 # 单文件上限,超过则滚动 +LOG_KEEP = 5 # 保留 trader.log.1 .. .5(约覆盖最近数个交易日) + + +def _rotate_logs(log_file: Path) -> None: + """启动时按体积滚动:trader.log → .1 → .2 …,最老的丢弃。失败不阻断启动。""" + try: + if not log_file.exists() or log_file.stat().st_size < LOG_MAX_BYTES: + return + oldest = log_file.with_suffix(log_file.suffix + f".{LOG_KEEP}") + if oldest.exists(): + oldest.unlink() + for i in range(LOG_KEEP - 1, 0, -1): + src = log_file.with_suffix(log_file.suffix + f".{i}") + if src.exists(): + src.rename(log_file.with_suffix(log_file.suffix + f".{i + 1}")) + log_file.rename(log_file.with_suffix(log_file.suffix + ".1")) + except Exception: + pass # 滚动失败就继续往原文件追加——丢日志比不启动好 + def _setup_file_logging() -> Path: from . import config as _config # 延迟导入:本函数在模块顶层 import 之前就被调用 log_dir = _config.app_data_dir() # frozen → exe 同级 guling-trader-data/ log_file = log_dir / "trader.log" - # 'w' 每次启动新建——避免日志无限增长 - log_fh = open(log_file, "w", encoding="utf-8", buffering=1) + _rotate_logs(log_file) + log_fh = open(log_file, "a", encoding="utf-8", buffering=1) + log_fh.write(f"\n===== trader 启动 {datetime.now().isoformat(timespec='seconds')} =====\n") # 1) stdout / stderr 同时写到日志 + 原 sink(windowed 下原 sink 是 /dev/null,无副作用) class _Tee(io.TextIOBase): diff --git a/src/trader/order_ledger.py b/src/trader/order_ledger.py new file mode 100644 index 0000000..e715a9c --- /dev/null +++ b/src/trader/order_ledger.py @@ -0,0 +1,189 @@ +"""下单台账:client_order_id 幂等键 + entrust_no 关联(契约 v2 C4/C5a/C5b)。 + +为什么必须落盘:幂等要跨受控端重启才有意义——超时后消费侧的安全动作是「原 id 重发」, +若重启就失忆,重发会变成真的重复下单。 + +**台账不可用一律拒单,禁静默降级**(需求方拍板):读不到/写不进台账时无法保证幂等, +此时下单等于把重复下单的风险悄悄还给调用方,宁可失败。 + +三条语义(PROTOCOL.md 同步): + +* `reserve()` 在**点提交之前**写入。所以「已登记但结果未知」是正常态,不是异常态—— + 最危险那一刻(点了提交、回执没回来)台账自己也不知道结果,契约不撒谎。 +* 同 id 重发:返回首次记录的回执;首次仍在飞则回 submitted_unconfirmed。任一情况下 + **绝不产生第二次点击**。 +* 同 id 但参数不同:拒绝并大声报错(invalid_params)。这是调用方的 id 复用 bug, + 静默返回首次回执会让它以为新单下出去了。 +""" +from __future__ import annotations + +import json +import logging +import sqlite3 +import threading +import time +from pathlib import Path +from typing import Any, Optional + +logger = logging.getLogger(__name__) + +# 保留窗口:需求方要求 ≥5 交易日;14 自然日在任何长假下都能覆盖。 +RETENTION_DAYS = 14 + +STATE_SUBMITTING = "submitting" # 已登记、已(或即将)点提交,结果未知 +STATE_DONE = "done" # 首次回执已落定(成功/失败都算落定) + +_SCHEMA = """ +CREATE TABLE IF NOT EXISTS orders ( + client_order_id TEXT PRIMARY KEY, + method TEXT NOT NULL, + fingerprint TEXT NOT NULL, + state TEXT NOT NULL, + entrust_no TEXT, + receipt TEXT, + created_at REAL NOT NULL, + updated_at REAL NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_orders_entrust ON orders(entrust_no); +CREATE INDEX IF NOT EXISTS idx_orders_created ON orders(created_at); +""" + + +class LedgerUnavailable(RuntimeError): + """台账不可用(打不开/损坏/写失败)——调用方必须拒单,不得降级为无幂等下单。""" + + +def fingerprint(method: str, params: dict[str, Any]) -> str: + """请求指纹:同 id 不同参数要能认出来。""" + keys = ("stock_no", "amount", "price", "entrust_no") + payload = {k: params.get(k) for k in keys if params.get(k) is not None} + return json.dumps({"method": method, **payload}, sort_keys=True, ensure_ascii=False) + + +class OrderLedger: + def __init__(self, path: Path): + self.path = Path(path) + self._lock = threading.Lock() + self._init_db() + + # --- 底层 --------------------------------------------------------------- + + def _connect(self) -> sqlite3.Connection: + try: + conn = sqlite3.connect(self.path, timeout=5.0) + conn.row_factory = sqlite3.Row + return conn + except sqlite3.Error as e: + raise LedgerUnavailable(f"台账打不开({self.path}):{e}") from e + + def _init_db(self) -> None: + try: + with self._connect() as conn: + conn.executescript(_SCHEMA) + except sqlite3.Error as e: + raise LedgerUnavailable(f"台账初始化失败:{e}") from e + self.purge() + + def purge(self, retention_days: int = RETENTION_DAYS) -> int: + """清理过期条目。失败只记日志——清不掉不影响幂等正确性。""" + cutoff = time.time() - retention_days * 86400 + try: + with self._lock, self._connect() as conn: + cur = conn.execute("DELETE FROM orders WHERE created_at < ?", (cutoff,)) + return cur.rowcount or 0 + except sqlite3.Error: + logger.warning("台账清理失败(不影响下单)", exc_info=True) + return 0 + + # --- 幂等主路径 --------------------------------------------------------- + + def reserve(self, client_order_id: str, method: str, + params: dict[str, Any]) -> tuple[str, Optional[dict]]: + """在点提交之前登记。 + + 返回 ``(verdict, record)``: + + * ``("new", None)`` —— 首次,可以下单; + * ``("duplicate", record)`` —— 同 id 同参数重发,**不得再点提交**; + * ``("conflict", record)`` —— 同 id 不同参数,调用方 id 复用 bug。 + """ + fp = fingerprint(method, params) + now = time.time() + try: + with self._lock, self._connect() as conn: + try: + conn.execute( + "INSERT INTO orders (client_order_id, method, fingerprint, state," + " created_at, updated_at) VALUES (?,?,?,?,?,?)", + (client_order_id, method, fp, STATE_SUBMITTING, now, now)) + return "new", None + except sqlite3.IntegrityError: + row = conn.execute( + "SELECT * FROM orders WHERE client_order_id = ?", + (client_order_id,)).fetchone() + if row is None: # 并发删除,极罕见;当作不可用而非放行 + raise LedgerUnavailable("台账条目在登记过程中消失") + record = _row_to_dict(row) + verdict = "duplicate" if row["fingerprint"] == fp else "conflict" + return verdict, record + except sqlite3.Error as e: + raise LedgerUnavailable(f"台账登记失败:{e}") from e + + def complete(self, client_order_id: str, receipt: dict, + entrust_no: Optional[str] = None) -> None: + """落定首次回执。写失败抛 LedgerUnavailable——单已经下出去了,绝不能静默。""" + try: + with self._lock, self._connect() as conn: + conn.execute( + "UPDATE orders SET state=?, receipt=?, entrust_no=?, updated_at=?" + " WHERE client_order_id=?", + (STATE_DONE, json.dumps(receipt, ensure_ascii=False), + entrust_no, time.time(), client_order_id)) + except sqlite3.Error as e: + raise LedgerUnavailable(f"台账回写失败:{e}") from e + + def release(self, client_order_id: str) -> None: + """撤销登记(仅用于「确认没点提交」的前置失败,如参数校验不过)。""" + try: + with self._lock, self._connect() as conn: + conn.execute("DELETE FROM orders WHERE client_order_id=?", (client_order_id,)) + except sqlite3.Error: + logger.warning("台账撤销登记失败 coid=%s", client_order_id, exc_info=True) + + # --- 读路径 ------------------------------------------------------------- + + def get(self, client_order_id: str) -> Optional[dict]: + try: + with self._connect() as conn: + row = conn.execute("SELECT * FROM orders WHERE client_order_id=?", + (client_order_id,)).fetchone() + return _row_to_dict(row) if row else None + except sqlite3.Error as e: + 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), + 不能因为 join 不上就让查询整体失败。 + """ + 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() + 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 _row_to_dict(row: sqlite3.Row) -> dict: + d = dict(row) + if d.get("receipt"): + try: + d["receipt"] = json.loads(d["receipt"]) + except (TypeError, ValueError): + d["receipt"] = None + return d diff --git a/src/trader/order_watch.py b/src/trader/order_watch.py index 308f701..bf97d2f 100644 --- a/src/trader/order_watch.py +++ b/src/trader/order_watch.py @@ -12,7 +12,8 @@ from datetime import datetime, time as dtime from typing import Any, Optional -from . import config +from . import config, contract +from .ths.rows import ST_CANCELED, ST_FILLED, ST_PARTIAL, ST_REJECTED, is_in_flight logger = logging.getLogger(__name__) @@ -20,15 +21,18 @@ ACTIVE_INTERVAL_DEFAULT = 60 # 有未完成委托挂着时提速:1 分钟(为及时抓成交) FRAME_TYPE = "order_event" -# THS 真实表头(逐字) +# 契约 v2 规范化后的键(不再是 THS 原始表头)。 +# 注意数据源:order_watch 读的是 orders_active_all(含终态),不是对外的 +# orders_active——后者按 C3 过滤掉终态行,用它会把 filled/canceled 事件全丢掉。 COL_CODE = "证券代码" -COL_OP = "操作" +COL_OP = "方向" COL_ORDER_QTY = "委托数量" -COL_ORDER_PRICE = "委托价格" -COL_FILLED_QTY = "成交数量" +COL_ORDER_PRICE = "委托价" +COL_FILLED_QTY = "已成数量" COL_AVG_PRICE = "成交均价" -COL_ENTRUST_NO = "合同编号" -COL_NOTE = "备注" +COL_ENTRUST_NO = "entrust_no" +COL_STATE = "状态" +COL_NOTE = "柜台备注" _MORNING = (dtime(9, 30), dtime(11, 30)) _AFTERNOON = (dtime(13, 0), dtime(15, 0)) @@ -50,23 +54,24 @@ def _to_int(value: Any) -> int: def build_snapshot(active_result: Optional[dict]) -> dict[str, dict]: - """把 orders_active 返回解析为 {合同编号: order_state}。code!=0/空 → {}。""" + """把 orders_active_all 返回解析为 {entrust_no: order_state}。非 succeed/空 → {}。""" snap: dict[str, dict] = {} - if not active_result or active_result.get("code") != 0: + if not contract.is_succeed(active_result or {}): return snap - for row in active_result.get("data", []) or []: - eno = (row.get(COL_ENTRUST_NO) or "").strip() + for row in (active_result.get("data") or []): + eno = str(row.get(COL_ENTRUST_NO) or "").strip() if not eno: continue snap[eno] = { "entrust_no": eno, - "stock_no": (row.get(COL_CODE) or "").strip(), - "op": (row.get(COL_OP) or "").strip(), + "stock_no": row.get(COL_CODE) or "", + "op": row.get(COL_OP) or "", "order_qty": _to_int(row.get(COL_ORDER_QTY)), - "order_price": (row.get(COL_ORDER_PRICE) or "").strip(), + "order_price": row.get(COL_ORDER_PRICE), "filled_qty": _to_int(row.get(COL_FILLED_QTY)), - "avg_price": (row.get(COL_AVG_PRICE) or "").strip(), - "note": (row.get(COL_NOTE) or "").strip(), + "avg_price": row.get(COL_AVG_PRICE), + "state": row.get(COL_STATE) or "未知", + "note": row.get(COL_NOTE) or "", } return snap @@ -76,11 +81,12 @@ def _is_full(o: dict) -> bool: def _classify_new(o: dict) -> str: - if "已撤" in o["note"]: + # 状态取自契约枚举(由柜台备注结构化而来),不再在这里做二次文本匹配。 + if o.get("state") in (ST_CANCELED, ST_REJECTED): return "canceled" - if _is_full(o): + if o.get("state") == ST_FILLED or _is_full(o): return "filled" - if o["filled_qty"] > 0: + if o.get("state") == ST_PARTIAL or o["filled_qty"] > 0: return "partially_filled" return "placed" @@ -110,7 +116,8 @@ def diff_snapshots(prev: dict[str, dict], cur: dict[str, dict], if before is None: events.append(_make_event(_classify_new(o), o, agent_entrust_nos)) continue - if "已撤" in o["note"] and "已撤" not in before["note"]: + if (o.get("state") in (ST_CANCELED, ST_REJECTED) + and before.get("state") not in (ST_CANCELED, ST_REJECTED)): events.append(_make_event("canceled", o, agent_entrust_nos)) continue if o["filled_qty"] > before["filled_qty"]: @@ -120,12 +127,8 @@ def diff_snapshots(prev: dict[str, dict], cur: dict[str, dict], def _is_open(o: dict) -> bool: - """该委托是否仍未完成(可能继续成交)。""" - if "已撤" in o["note"] or "已成" in o["note"]: - return False - if o["order_qty"] > 0 and o["filled_qty"] >= o["order_qty"]: - return False - return True + """该委托是否仍未完成(可能继续成交)。与 orders_active 的在飞判据同源。""" + return is_in_flight(o.get("state") or "未知", o["order_qty"], o["filled_qty"]) def next_interval(snapshot: dict, idle_secs: int, active_secs: int) -> int: @@ -136,8 +139,9 @@ def next_interval(snapshot: dict, idle_secs: int, active_secs: int) -> int: async def _poll_once(backend, client, prev: Optional[dict], seq: int) -> tuple[Optional[dict], int, bool]: """单轮:取委托快照 → diff → 发帧。返回 (new_prev, new_seq, ok)。""" async with backend.win_lock: - active = await backend.orders_active() - if not active or active.get("code") != 0: + # 全量表(含终态):终态行正是 filled/canceled 事件的来源。 + active = await backend.orders_active_all() + if not contract.is_succeed(active or {}): return prev, seq, False # 未绑定/验证码/读失败 → 跳过本轮 cur = build_snapshot(active) if prev is None: diff --git a/src/trader/ths/dialogs.py b/src/trader/ths/dialogs.py index 2b2c046..298483b 100644 --- a/src/trader/ths/dialogs.py +++ b/src/trader/ths/dialogs.py @@ -203,6 +203,11 @@ def pump(self, budget: float = 5.0, settle: float = 0.3) -> PumpResult: quiet_since: Optional[float] = None handled: dict[int, float] = {} # hwnd → 上次处置时刻(防对同一弹窗连点) while time.time() < deadline: + # 代次检查点:dispatcher 超时放锁后,脱缰线程不能继续抢弹窗—— + # 下一笔调用的 dialog_cleanup 正在处置同一个框(2026-08-03 串线事故)。 + check = getattr(self.backend, "_abort_if_stale", None) + if check: + check("dialogs.pump") dialogs = self.scan() if not dialogs: now = time.time() diff --git a/src/trader/ths/rows.py b/src/trader/ths/rows.py new file mode 100644 index 0000000..d251e4f --- /dev/null +++ b/src/trader/ths/rows.py @@ -0,0 +1,187 @@ +"""THS 原始表 → 契约 v2 载荷(C3 行结构 / C6 类型与单位 / B2 时间)。 + +纯函数,不碰 Win32,可跨平台单测。规范化只做三件事:**键名钉死、类型转换、 +空占位符映射 null**。认不出来的值一律保留原文或 null,绝不猜测、绝不用 0 兜底。 +""" +from __future__ import annotations + +import re +from datetime import datetime +from typing import Any, Optional + +from ..contract import direction, money, pct, price, qty, text + +# --- 委托状态(C3 值域)----------------------------------------------------- +ST_PENDING = "未报" +ST_PLACED = "已报" +ST_PARTIAL = "部成" +ST_FILLED = "已成" +ST_CANCELED = "已撤" +ST_REJECTED = "废单" +ST_UNKNOWN = "未知" + +ORDER_STATES = (ST_PENDING, ST_PLACED, ST_PARTIAL, ST_FILLED, + ST_CANCELED, ST_REJECTED, ST_UNKNOWN) + +# 终态:不再可能继续成交,orders_active 不返回这些行。 +TERMINAL_STATES = frozenset({ST_FILLED, ST_CANCELED, ST_REJECTED}) + +# 柜台备注原文 → 状态。**认不出即 ST_UNKNOWN,且 unknown 按「在飞」保守返回**: +# 宁可多给消费侧一行让它看见,也不能把一张活着的挂单藏起来——那正是孤儿挂单 +# 架空止损哨兵的失效路径(2026-08-03 事故分析结论)。 +_STATE_PATTERNS: tuple[tuple[str, str], ...] = ( + ("已撤", ST_CANCELED), ("部撤", ST_CANCELED), ("撤单", ST_CANCELED), + ("废单", ST_REJECTED), ("无效", ST_REJECTED), ("拒绝", ST_REJECTED), + ("部成", ST_PARTIAL), ("部分成交", ST_PARTIAL), + ("已成", ST_FILLED), ("全部成交", ST_FILLED), ("成交", ST_FILLED), + ("已报", ST_PLACED), ("已申报", ST_PLACED), + ("未报", ST_PENDING), ("待报", ST_PENDING), +) + + +def classify_order_state(note: Any) -> str: + s = text(note) + if not s: + return ST_UNKNOWN + for kw, state in _STATE_PATTERNS: + if kw in s: + return state + return ST_UNKNOWN + + +def is_in_flight(state: str, order_qty: Optional[int], filled_qty: Optional[int]) -> bool: + """是否仍在飞。未知态一律算在飞(保守)。""" + if state in TERMINAL_STATES: + return False + if order_qty and filled_qty is not None and filled_qty >= order_qty: + return False + return True + + +# --- B2 成交时间 ------------------------------------------------------------- +# THS 成交表只给 "HH:MM:SS"(无日期)。补齐的日期与时区**来自受控端本机时钟**, +# 不是柜台时间——契约里写明,消费侧对账时按此理解。 + +_TIME_ONLY = re.compile(r"^\d{1,2}:\d{2}(:\d{2})?$") +_DATE_TIME = re.compile(r"^(\d{4})[-/]?(\d{2})[-/]?(\d{2})[ T]+(\d{1,2}:\d{2}(:\d{2})?)$") + + +def to_iso_time(value: Any, now: Optional[datetime] = None) -> Optional[str]: + """成交时间 → 带时区偏移的 ISO 8601。认不出的格式原样返回。""" + s = text(value) + if not s: + return None + now = now or datetime.now().astimezone() + tz = now.tzinfo + m = _DATE_TIME.match(s) + if m: + y, mo, d, hms = int(m.group(1)), int(m.group(2)), int(m.group(3)), m.group(4) + parts = [int(x) for x in hms.split(":")] + while len(parts) < 3: + parts.append(0) + return datetime(y, mo, d, *parts, tzinfo=tz).isoformat() + if _TIME_ONLY.match(s): + parts = [int(x) for x in s.split(":")] + while len(parts) < 3: + parts.append(0) + return datetime(now.year, now.month, now.day, *parts, tzinfo=tz).isoformat() + return s + + +# --- 各表行规范化 ------------------------------------------------------------ + +def normalize_balance(raw: dict[str, Any]) -> dict[str, Any]: + """资金面板:全部转 number(元),带 % 的键更名为 _pct。""" + return { + "资金余额": money(raw.get("资金余额")), + "冻结金额": money(raw.get("冻结金额")), + "可用金额": money(raw.get("可用金额")), + "可取金额": money(raw.get("可取金额")), + "股票市值": money(raw.get("股票市值")), + "总资产": money(raw.get("总资产")), + "持仓盈亏": money(raw.get("持仓盈亏")), + "当日盈亏": money(raw.get("当日盈亏")), + "当日盈亏比_pct": pct(raw.get("当日盈亏比")), + } + + +def normalize_position_row(row: dict[str, Any]) -> dict[str, Any]: + return { + "证券代码": text(row.get("证券代码")), + "证券名称": text(row.get("证券名称")), + "股票余额": qty(row.get("股票余额")), + "可用余额": qty(row.get("可用余额")), + "冻结数量": qty(row.get("冻结数量")), + "参考成本价": price(row.get("参考成本价")), + "市价": price(row.get("市价")), + "market_value": money(row.get("最新市值") or row.get("市值")), + "浮动盈亏": money(row.get("浮动盈亏") or row.get("盈亏")), + "盈亏比例_pct": pct(row.get("盈亏比例") or row.get("盈亏比(%)")), + } + + +def normalize_active_row(row: dict[str, Any], + coid_by_entrust: Optional[dict[str, str]] = None) -> dict[str, Any]: + """委托行 → C3 钉死结构。client_order_id 由台账 join,join 不上即 null。""" + entrust_no = text(row.get("合同编号") or row.get("委托编号")) + order_qty = qty(row.get("委托数量")) + filled_qty = qty(row.get("成交数量")) + state = classify_order_state(row.get("备注") or row.get("状态") or row.get("委托状态")) + return { + "client_order_id": (coid_by_entrust or {}).get(entrust_no or ""), + "entrust_no": entrust_no, + "证券代码": text(row.get("证券代码")), + "证券名称": text(row.get("证券名称")), + "方向": direction(row.get("操作") or row.get("买卖标志")), + "委托价": price(row.get("委托价格") or row.get("委托价")), + "委托数量": order_qty, + "已成数量": filled_qty, + "成交均价": price(row.get("成交均价")), + "状态": state, + "柜台备注": text(row.get("备注")), + } + + +def normalize_filled_row(row: dict[str, Any], + coid_by_entrust: Optional[dict[str, str]] = None, + now: Optional[datetime] = None) -> dict[str, Any]: + entrust_no = text(row.get("合同编号") or row.get("委托编号")) + return { + "client_order_id": (coid_by_entrust or {}).get(entrust_no or ""), + "entrust_no": entrust_no, + "成交编号": text(row.get("成交编号")), + "成交时间": to_iso_time(row.get("成交时间"), now), + "证券代码": text(row.get("证券代码")), + "证券名称": text(row.get("证券名称")), + "方向": direction(row.get("操作")), + "成交数量": qty(row.get("成交数量")), + "成交均价": price(row.get("成交均价")), + "成交金额": money(row.get("成交金额")), + } + + +def normalize_settlement_row(row: dict[str, Any], + now: Optional[datetime] = None) -> dict[str, Any]: + """交割单:列因券商而异,钉死已知列,未知列原样保留(低频复盘工具,宁可多带)。""" + known = { + "成交日期": to_iso_time(row.get("成交日期") or row.get("日期"), now), + "证券代码": text(row.get("证券代码")), + "证券名称": text(row.get("证券名称")), + "方向": direction(row.get("操作")), + "成交数量": qty(row.get("成交数量") or row.get("数量")), + "成交均价": price(row.get("成交均价") or row.get("均价")), + "成交金额": money(row.get("成交金额") or row.get("金额")), + "发生金额": money(row.get("发生金额")), + "手续费": money(row.get("手续费")), + "印花税": money(row.get("印花税")), + } + extras = {k: text(v) for k, v in row.items() if k not in _SETTLEMENT_MAPPED} + if extras: + known["其它列"] = extras + return known + + +_SETTLEMENT_MAPPED = frozenset({ + "成交日期", "日期", "证券代码", "证券名称", "操作", "成交数量", "数量", + "成交均价", "均价", "成交金额", "金额", "发生金额", "手续费", "印花税", +}) diff --git a/src/trader/ths/table_guard.py b/src/trader/ths/table_guard.py new file mode 100644 index 0000000..684bb5b --- /dev/null +++ b/src/trader/ths/table_guard.py @@ -0,0 +1,64 @@ +"""请求-响应配对校验:抓回来的表必须是本次请求的那张表。 + +2026-08-03 串线事故:消费侧调 balance 收到成交明细表/持仓表(status=succeed)。 +受控端侧的结构成因是——翻页快捷键是**全局按键**(`win32api.keybd_event` 发给当时的 +前台窗口),没落到 xiadan 时页面根本没切,grid 里还是上一次查询的表,Ctrl+C 原样抓走; +而 `read_table_text` 的剪贴板序号校验只能保证「不是剪贴板里的陈旧残留」,保证不了 +「这是本次请求的那一页」。三个 grid 查询过去只要非空就 `code=0` 出门。 + +这里只做一件事:**禁 succeed 携错表出门**。判据是表头特征列,两条同时成立才放行: + +1. 命中自身特征列(该列在本表必有); +2. 未命中他表独有特征列(他表有、本表没有的列)。 + +第 2 条是关键——只查第 1 条的话,未知第四张表混进来照样漏。列名以真机 +`parse_table` 解析结果为准,broker 换皮导致列名变了就只改这张表。 +""" +from __future__ import annotations + +from typing import Iterable, Optional + +# 查询 kind → 特征列。取「该表必有」的列,宽松匹配(子串命中即可), +# 容忍 broker 在列名上加前后缀。 +TABLE_MARKERS: dict[str, tuple[str, ...]] = { + # 持仓表:操作/证券代码/证券名称/股票余额/可用余额/冻结数量/参考成本价/市价 + "position": ("股票余额", "参考成本价", "冻结数量"), + # 委托表:证券代码/操作/委托数量/委托价格/成交数量/成交均价/合同编号/备注 + "active_orders": ("委托数量", "委托价格", "委托状态"), + # 成交明细表:成交时间/证券代码/证券名称/操作/成交数量/成交均价/成交金额/合同编号 + "filled_orders": ("成交时间", "成交编号"), + # 交割单:另有 _do_settlement 的自校验,这里登记是为了给上面三张表提供「他表证据」 + "settlement": ("发生金额", "印花税", "成交日期", "成交编号"), +} + + +def _hit(markers: Iterable[str], columns: Iterable[str]) -> list[str]: + cols = list(columns) + return [m for m in markers if any(m in c for c in cols)] + + +def check_table(kind: str, columns: Iterable[str]) -> Optional[str]: + """校验表头归属。返回 None=是本次请求的表;否则返回拒收原因(进日志与回执)。 + + 未登记的 kind 一律放行——本函数只负责它认识的表,不当通用闸门。 + """ + own = TABLE_MARKERS.get(kind) + if not own: + return None + cols = [str(c) for c in columns] + if not cols: + return "表头为空" + + foreign_markers = { + m + for k, ms in TABLE_MARKERS.items() + if k != kind + for m in ms + if m not in own + } + foreign = _hit(sorted(foreign_markers), cols) + if foreign: + return f"命中他表特征列 {foreign}(抓到的不是本次请求的表)" + if not _hit(own, cols): + return f"未命中本表特征列 {list(own)}" + return None diff --git a/src/trader/ths/win.py b/src/trader/ths/win.py index a3c0e6b..f279e53 100644 --- a/src/trader/ths/win.py +++ b/src/trader/ths/win.py @@ -21,6 +21,7 @@ import asyncio import ctypes from ctypes import wintypes +import functools import logging import os import platform @@ -44,12 +45,6 @@ from .const import ( BALANCE_CONTROL_ID_GROUP, - FILLED_COL_AMOUNT, - FILLED_COL_CODE, - FILLED_COL_DEAL_NO, - FILLED_COL_OP, - FILLED_COL_PRICE, - FILLED_COL_QTY, MARKET_AMOUNT_ID, MARKET_CODE_ID, MARKET_STRATEGY, @@ -58,6 +53,17 @@ MARKET_TREE_PARENT, VK_CODE, ) +from .table_guard import check_table +from .rows import ( + normalize_active_row, + normalize_balance, + normalize_filled_row, + normalize_position_row, + normalize_settlement_row, + is_in_flight, +) +from .. import contract +from ..contract import CLS_ABORTED, CLS_NOT_BOUND, CLS_READ_FAILED, CLS_TABLE_MISMATCH logger = logging.getLogger(__name__) @@ -70,9 +76,8 @@ def _match_market_fill(before, after, stock_no, op_keyword, requested_amount): 成交 → 回执带回真实成交数量与按金额加权的成交均价。 """ def _key(r): - return r.get(FILLED_COL_DEAL_NO, "").strip() or ( - r.get(FILLED_COL_CODE, ""), r.get(FILLED_COL_QTY, ""), - r.get(FILLED_COL_PRICE, ""), r.get(FILLED_COL_AMOUNT, "")) + return (r.get("成交编号") or "") or ( + r.get("证券代码"), r.get("成交数量"), r.get("成交均价"), r.get("成交金额")) seen = {_key(r) for r in before} filled_qty = 0 @@ -80,28 +85,30 @@ def _key(r): for r in after: if _key(r) in seen: continue - if r.get(FILLED_COL_CODE, "").strip() != str(stock_no): + if (r.get("证券代码") or "") != str(stock_no): continue - if op_keyword not in r.get(FILLED_COL_OP, ""): + if op_keyword not in (r.get("方向") or ""): continue - try: - qty = int(float(r.get(FILLED_COL_QTY, "0") or 0)) - amt = float(r.get(FILLED_COL_AMOUNT, "0") or 0) - except ValueError: + q, a = r.get("成交数量"), r.get("成交金额") + if q is None or a is None: continue - filled_qty += qty - filled_amt += amt + filled_qty += int(q) + filled_amt += float(a) + payload = {"stock_no": str(stock_no), "方向": op_keyword, + "requested_amount": int(requested_amount)} if filled_qty <= 0: - return {"code": 2, "status": "unknown", "stock_no": str(stock_no), - "op": op_keyword, "requested_amount": int(requested_amount), - "filled_amount": 0} - - avg = round(filled_amt / filled_qty, 3) - status = "filled" if filled_qty >= int(requested_amount) else "partially_filled" - return {"code": 0, "status": status, "stock_no": str(stock_no), - "op": op_keyword, "requested_amount": int(requested_amount), - "filled_amount": filled_qty, "avg_price": avg} + payload["filled_amount"] = 0 + return contract.submitted_unconfirmed( + "已提交但成交表尚未出现本次成交(可能非连续竞价时段/涨跌停被拒/尚未成交)。" + "请用同一 client_order_id 重发查询或调 query_order 核实,勿改单重下", + data=payload) + + payload["filled_amount"] = filled_qty + payload["成交均价"] = round(filled_amt / filled_qty, 3) + payload["成交金额"] = round(filled_amt, 2) + payload["fill_state"] = "filled" if filled_qty >= int(requested_amount) else "partially_filled" + return contract.ok(payload) # PyInstaller bundled Tesseract 路径绑定(仅 onefile 模式激活) if hasattr(sys, "_MEIPASS"): @@ -270,6 +277,17 @@ def get_text(hwnd): _PHANTOM_VALUES = frozenset({"", "0", "0.0", "0.00", "0.000", "-", "--"}) +def table_columns(text): + """取 THS 剪贴板表格的表头列名(与 parse_table 同一套切分约定)。 + + 单独一个函数是因为**空表也要能校验归属**:今天无挂单/无成交时 parse_table + 返回 [],表头却仍在——归属校验只能看表头,不能看行。 + """ + if not text: + return [] + return [c for c in text.split("\t\r\n")[0].split("\t") if c.strip()] + + def parse_table(text): """Parse THS clipboard table. Drops two kinds of noise rows: - completely blank lines (trailing \\t\\r\\n separator artefact) @@ -395,6 +413,23 @@ def snapshot(self) -> dict: return out +class StaleCallAborted(RuntimeError): + """本线程所属的调用已被作废(dispatcher 超时后放锁),必须立刻停手。""" + + +def guarded(fn): + """工作线程入口装饰器:登记调用代次,本笔被作废时在检查点中止。 + + 装在同步实现上(而非 to_thread 调用点)——RPC 异步壳的形状保持不变, + 代次跟着方法走,内部相互调用(下单→查成交表)自动继承同一代次。 + """ + @functools.wraps(fn) + def wrapper(self, *args, **kwargs): + return self._run_guarded(fn.__get__(self, type(self)), *args, **kwargs) + + return wrapper + + class WinThsBackend: def __init__(self): self.hwnd_main = None @@ -406,15 +441,72 @@ def __init__(self): self.state = ThsState() # dispatcher 侧调用超时后置位;下一次调用进入前先跑 dialog_cleanup 自愈。 self.degraded = False + # 调用代次:dispatcher 超时后 +1,作废所有在飞的工作线程(见 _abort_if_stale)。 + self._gen = 0 + self._gen_lock = threading.Lock() + self._tls = threading.local() + # 下单台账(幂等 + client_order_id 回显);懒加载,见 ledger 属性。 + self._ledger = None + + # --- 调用代次:治「超时线程脱缰」--------------------------------------- + # dispatcher 的 25s 总超时用 asyncio.wait_for 包 asyncio.to_thread,超时只取消 + # 等待协程——**线程取消不掉**,它还在发全局按键;而 finally 已经放了 win_lock, + # 下一笔立刻进场 → 两个线程同击一个 xiadan 窗口(页面被别人切走 = 抓错表; + # 弹窗被两边抢 = 验证码/确认框错点)。 + # 代次机制让脱缰线程在下一个检查点自己退出:工作线程进场时记下当时的代次, + # 超时时 dispatcher 把代次 +1,线程在每个 UI 动作前对一次,不一致就抛 + # StaleCallAborted 退出。检查点覆盖翻页(switch_to_normal/refresh)、抓表 + # (read_table_text)、弹窗(input_ocr / dialogs.pump / dialog_cleanup)与下单提交。 + + def invalidate_inflight(self, reason: str = "") -> int: + """作废当前在飞的工作线程(dispatcher 超时时调用)。返回新代次。""" + with self._gen_lock: + self._gen += 1 + gen = self._gen + logger.warning("调用代次 → %s,在飞线程已作废:%s", gen, reason or "(未注明)") + return gen + + def _run_guarded(self, fn, *args, **kwargs): + """在工作线程里带代次运行 fn;被作废则中止并返回 failed(结果已无人接收)。 + + 可重入:内层已登记代次时直接执行(如 _submit_market_trade 内部调 + get_filled_orders),中止异常一路抛到最外层那次统一收口。 + """ + if getattr(self._tls, "gen", None) is not None: + return fn(*args, **kwargs) + with self._gen_lock: + self._tls.gen = self._gen + try: + return 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}") + finally: + self._tls.gen = None + + def _abort_if_stale(self, where: str) -> None: + """代次检查点。非受管线程(UI/测试直调)不拦。""" + mine = getattr(self._tls, "gen", None) + if mine is None: + return + with self._gen_lock: + current = self._gen + if mine != current: + err = StaleCallAborted( + f"代次 {mine} 已被 {current} 取代,在 {where} 处放弃,避免与新调用同击一窗") + err.where = where + raise err def _pump_dialogs(self): """提交动作后的弹窗「发现-处置-存证」循环(见 ths/dialogs.py)。""" + self._abort_if_stale("pump_dialogs") from .dialogs import DialogSentry return DialogSentry(self).pump() def dialog_cleanup(self): """degraded 自愈入口:清掉残留弹窗并留存证(dispatcher 在超时后的 下一次调用前执行)。返回 PumpResult,内容进日志。""" + self._abort_if_stale("dialog_cleanup") from .dialogs import DialogSentry result = DialogSentry(self).cleanup() if result.dialogs: @@ -449,7 +541,8 @@ def _ensure_bound(self) -> dict[str, Any] | None: # bind 失败 logger.error("✗ 未检测到 xiadan 窗口(window_title 为空或窗口未运行)") - return {"code": 1, "error": "未检测到 xiadan 窗口(请确保同花顺已打开并登录)"} + return contract.fail(contract.CODE_NOT_BOUND, CLS_NOT_BOUND, + "未检测到 xiadan 窗口(请确保同花顺已打开并登录)") def bind_client(self): # Try exact match first for backward compat, then prefix match. @@ -662,6 +755,7 @@ def cancel_sell(self): def cancel_last(self): return self._bulk_cancel("last") + @guarded def get_balance(self): # 多账户登录时每个账户各挂一套同 ID 资金控件,只有当前账户的可见; # 不按可见性过滤会读到其他账户隐藏面板的数字(2026-07-14 双账户 @@ -679,29 +773,88 @@ def get_balance(self): if ctrl > 0: data[key] = get_text(ctrl) if data: - self.state.update("balance", data) - return {"code": 0, "status": "succeed", "data": data} + normalized = normalize_balance(data) + self.state.update("balance", normalized) + return contract.ok(normalized) time.sleep(sleep_time) - return {"code": 1, "status": "failed", - "msg": "未找到可见的资金面板控件(面板未加载完或客户端异常)," - "已放弃读取——不回退读隐藏面板(多账户下可能是其他账户的数字)," - "请稍后重试"} + return contract.fail( + contract.CODE_READ_FAILED, CLS_READ_FAILED, + "未找到可见的资金面板控件(面板未加载完或客户端异常)," + "已放弃读取——不回退读隐藏面板(多账户下可能是其他账户的数字),请稍后重试") + + # 抓表重试上限:翻页键没落到 xiadan 时抓到的是上一张表,重抓一次通常就对了; + # 三次仍不对说明面板真的没切过去,明确失败 —— 绝不 succeed 携错表出门。 + _GRID_ATTEMPTS = 3 + + @property + def ledger(self): + """下单台账(懒加载)。拿不到就返回 None——回显是增强字段,不阻断查询。""" + if self._ledger is None: + try: + from ..config import app_data_dir + from ..order_ledger import OrderLedger + self._ledger = OrderLedger(app_data_dir() / "orders.db") + except Exception: + logger.warning("下单台账不可用,client_order_id 本次不回显", exc_info=True) + return None + return self._ledger - def get_position(self): - for retry in range(retry_time): - self.switch_to_normal() - hot_key(["F1"]) - hot_key(["F6"]) - self.refresh() + def _coid_map(self) -> dict: + led = self.ledger + if led is None: + return {} + try: + return led.coid_by_entrust() + except Exception: + logger.warning("台账 join 失败,本次不回显 client_order_id", exc_info=True) + return {} + + def _grab_grid(self, kind: str, goto, label: str, normalize=None): + """翻页→抓表→**校验表头归属**→解析。错表即重抓,仍不对则显式 failed。 + + 2026-08-03 串线事故的正面修复:翻页快捷键是全局按键,没落到 xiadan 时 + grid 里还是上一次查询的表,Ctrl+C 原样抓走,过去非空即 code=0 出门。 + """ + got_columns: list[str] = [] + reason = "" + for attempt in range(1, self._GRID_ATTEMPTS + 1): + goto() hwnd = self.get_right_hwnd() ctrl = self._find_grid(hwnd) - data = self.read_table_text(ctrl) + data = self.read_table_text(ctrl) if ctrl else None if data: + # 表头取自原始文本而非解析结果:空表(今天无挂单/无成交)是合法 + # 结果,它照样有表头,必须能通过校验并以 data=[] 正常返回。 + got_columns = table_columns(data) + reason = check_table(kind, got_columns) or "" parsed = parse_table(data) - self.state.update("position", parsed) - return {"code": 0, "status": "succeed", "data": parsed} + if not reason: + rows = normalize(parsed) if normalize else parsed + self.state.update(kind, rows) + return contract.ok(rows) + logger.warning("%s 抓到错表(第 %d/%d 次):%s cols=%r", + label, attempt, self._GRID_ATTEMPTS, reason, got_columns) time.sleep(sleep_time) - return {"code": 1, "status": "failed", "msg": "读取数据失败(可能验证码弹窗或刷新超时),请稍后重试"} + if reason: + return contract.fail( + contract.CODE_TABLE_MISMATCH, CLS_TABLE_MISMATCH, + f"{label}:抓到的不是本次请求的表({reason})," + f"重抓 {self._GRID_ATTEMPTS} 次仍不符,已拒绝返回错表,请稍后重试", + data={"got_columns": got_columns}) + return contract.fail(contract.CODE_READ_FAILED, CLS_READ_FAILED, + f"{label}:读取数据失败(可能验证码弹窗或刷新超时),请稍后重试") + + @guarded + def get_position(self): + def goto(): + self.switch_to_normal() + hot_key(["F1"]) + hot_key(["F6"]) + self.refresh() + + return self._grab_grid( + "position", goto, "持仓查询", + normalize=lambda rows: [normalize_position_row(r) for r in rows]) def get_gupiao(self): for retry in range(retry_time): @@ -718,37 +871,61 @@ def get_gupiao(self): time.sleep(sleep_time) return {"code": 1, "status": "failed", "msg": "读取数据失败(可能验证码弹窗或刷新超时),请稍后重试"} + @guarded def get_active_orders(self): - for retry in range(retry_time): + # 最险的一条:错表被消费侧读成「无挂单」→ 孤儿单存活、止损哨兵被架空。 + def goto(): self.switch_to_normal() _activate_window(self.hwnd_main) hot_key(["F1"]) hot_key(["F8"]) self.refresh() - hwnd = self.get_right_hwnd() - ctrl = self._find_grid(hwnd) - data = self.read_table_text(ctrl) - if data: - parsed = parse_table(data) - self.state.update("active_orders", parsed) - return {"code": 0, "status": "succeed", "data": parsed} - time.sleep(sleep_time) - return {"code": 1, "status": "failed", "msg": "读取数据失败(可能验证码弹窗或刷新超时),请稍后重试"} + # C3:只返回在飞单。终态(已成/已撤/废单/全部成交)不出现在本表; + # **状态识别不出来的一律按在飞返回**——宁可多给一行让消费侧看见,也不能 + # 把一张活着的挂单藏起来(孤儿单架空止损哨兵是最险的失效模式)。 + return self._grab_active(goto, include_terminal=False) + + def get_active_orders_all(self): + """委托表全量(含终态),**内部用**:order_watch 靠终态行 diff 出 + filled/canceled 事件,用过滤后的表会把这些事件全丢掉。 + 对外 RPC 的 orders_active 只给在飞单(C3)。""" + def goto(): + self.switch_to_normal() + _activate_window(self.hwnd_main) + hot_key(["F1"]) + hot_key(["F8"]) + self.refresh() + + return self._grab_active(goto, include_terminal=True) + + def _grab_active(self, goto, include_terminal: bool): + coid_map = self._coid_map() + + def normalize(rows): + out = [] + for raw in rows: + row = normalize_active_row(raw, coid_map) + if include_terminal or is_in_flight( + row["状态"], row["委托数量"], row["已成数量"]): + out.append(row) + return out + + return self._grab_grid("active_orders", goto, "委托查询", normalize=normalize) + + @guarded def get_filled_orders(self): - self.switch_to_normal() - _activate_window(self.hwnd_main) - hot_key(["F2"]) - hot_key(["F7"]) - self.refresh() - hwnd = self.get_right_hwnd() - ctrl = self._find_grid(hwnd) - data = self.read_table_text(ctrl) - if data: - parsed = parse_table(data) - self.state.update("filled_orders", parsed) - return {"code": 0, "status": "succeed", "data": parsed} - return {"code": 1, "status": "failed", "msg": "读取数据失败(可能验证码弹窗或刷新超时),请稍后重试"} + def goto(): + self.switch_to_normal() + _activate_window(self.hwnd_main) + hot_key(["F2"]) + hot_key(["F7"]) + self.refresh() + + coid_map = self._coid_map() + return self._grab_grid( + "filled_orders", goto, "成交查询", + normalize=lambda rows: [normalize_filled_row(r, coid_map) for r in rows]) # --- 自选股(新版专有)------------------------------------------------ # 新版 xiadan 的自选股是内嵌 CEF(Chromium) 渲染的网页,没有原生表格控件、无 CDP @@ -808,13 +985,14 @@ def _ocr_leftmost_codes(self, img) -> list[str]: out.append(t) return out + @guarded def get_watchlist(self): """读自选股代码(截图+OCR 代码列)。仅第一屏(顶部)——新增出现在顶部,足够检测 新增;全量需滚屏(CEF 暂不支持)。旧版无此菜单会返回错误。""" self.switch_to_normal() if not self._select_tree_node_by_text("自选股"): - return {"code": 1, "status": "failed", - "msg": "未找到自选股菜单(旧版 xiadan 无此菜单,请用新版)"} + return contract.fail(contract.CODE_READ_FAILED, CLS_READ_FAILED, + "未找到自选股菜单(旧版 xiadan 无此菜单,请用新版)") time.sleep(1.0) # 等内嵌 CEF 渲染出自选股(0.2s 太短) try: # 截整个窗口:PrintWindow(PW_RENDERFULLCONTENT) 会把内嵌 CEF 的自选股一并截到; @@ -824,12 +1002,14 @@ def get_watchlist(self): codes = self._ocr_leftmost_codes(img) except Exception as e: logger.exception("get_watchlist OCR failed") - return {"code": 1, "status": "failed", "msg": f"自选股截图/OCR 失败: {e}"} + return contract.fail(contract.CODE_READ_FAILED, CLS_READ_FAILED, + f"自选股截图/OCR 失败: {e}") if not codes: - return {"code": 1, "status": "failed", "msg": "OCR 未识别到自选股代码(面板可能未切到自选 tab)"} + return contract.fail(contract.CODE_READ_FAILED, CLS_READ_FAILED, + "OCR 未识别到自选股代码(面板可能未切到自选 tab)") self.state.update("watchlist", codes) - return {"code": 0, "status": "succeed", "count": len(codes), - "partial": True, "data": codes} # partial: 仅顶部第一屏 + # partial=True:仅顶部第一屏(CEF 不支持滚屏),契约里写明非全量。 + return contract.ok({"count": len(codes), "partial": True, "codes": codes}) # --- 交割单(低频,一次性拉一年做分析)---------------------------------- def _select_tree_node_by_text(self, target: str, fallback_token: str = "") -> bool: @@ -1189,6 +1369,7 @@ def _goto_settlement_panel(self) -> None: else: logger.warning("settlement: 按标签导航「交割单」失败(树文字读取或节点缺失)") + @guarded def _do_settlement(self, date_range: str = "近一年"): """读取交割单(默认近一年)。低频功能,一次性尽量多拿。""" try: @@ -1221,29 +1402,31 @@ def _do_settlement(self, date_range: str = "近一年"): break time.sleep(refresh_sleep_time) if not rows: - return {"code": 1, "status": "failed", - "msg": "交割单读取为空(大查询可能仍在超时,请稍后重试或改用更小时段)"} + return contract.fail( + contract.CODE_READ_FAILED, CLS_READ_FAILED, + "交割单读取为空(大查询可能仍在超时,请稍后重试或改用更小时段)") # 列名校验:确认确实是交割单面板,避免把资金股票/持仓数据误当交割单返回。 cols = set(rows[0].keys()) if rows else set() is_settlement = any(m in c for m in self._SETTLEMENT_MARKER_COLS for c in cols) if rows and not is_settlement: logger.warning("settlement: 面板列名不像交割单,cols=%r", list(cols)) - return {"code": 1, "status": "failed", - "msg": "未能切到交割单面板(读到的是其它面板),请重试或人工确认", - "got_columns": list(cols)} - self.state.update("settlement", rows) - return { - "code": 0, - "status": "succeed", + return contract.fail( + contract.CODE_TABLE_MISMATCH, CLS_TABLE_MISMATCH, + "未能切到交割单面板(读到的是其它面板),请重试或人工确认", + data={"got_columns": list(cols)}) + normalized = [normalize_settlement_row(r) for r in rows] + self.state.update("settlement", normalized) + return contract.ok({ "date_range": date_range, "range_applied": ranged, # False = 用了面板默认时段,需人工确认范围 - "count": len(rows), - "data": rows, - } + "count": len(normalized), + "rows": normalized, + }) except Exception as e: logger.exception("settlement failed") - return {"code": 1, "status": "failed", "msg": f"交割单读取异常: {e}"} + 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 @@ -1263,29 +1446,27 @@ def _lookup_entrust_no(self, stock_no, op_keyword, amount, price, timeout=8.0): last_seen_rows = 0 while time.time() < deadline: result = self.get_active_orders() - if result.get("code") == 0: - rows = result.get("data", []) + if contract.is_succeed(result): + # 契约 v2:行已规范化(数值为 number、方向/状态为枚举、id 键为 entrust_no)。 + rows = result.get("data") or [] last_seen_rows = len(rows) candidates = [] for r in rows: - if r.get("证券代码", "").strip() != str(stock_no): - continue - if op_keyword not in r.get("操作", ""): + if (r.get("证券代码") or "") != str(stock_no): continue - if r.get("委托数量", "").strip() != target_amount: + if op_keyword not in (r.get("方向") or ""): continue - if target_price is not None and r.get("委托价格", "").strip() != target_price: + if r.get("委托数量") != int(target_amount): continue - # Skip already-cancelled phantom rows. - if "已撤" in r.get("备注", ""): + 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("合同编号", "0") or 0), + key=lambda r: int(r.get("entrust_no") or 0), reverse=True, ) - eno = candidates[0].get("合同编号", "").strip() + 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", @@ -1326,41 +1507,43 @@ def _submit_trade(self, panel_key, op_keyword, stock_no, amount, price): # 交给 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 pump.attach_to({ - "code": 0, - "status": "succeed", + return contract.ok(pump.attach_to({ "entrust_no": entrust_no, "stock_no": str(stock_no), - "amount": int(amount), - "price": float(price) if price is not None else None, - "op": op_keyword, - }) + "方向": op_keyword, + "委托数量": int(amount), + "委托价": float(price) if price is not None else None, + "submitted": True, + })) 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 表中匹配到对应订单,请自行确认状态", - }) - + # 回查无此单 + 有弹窗文本 ⇒ 大概率被拒/废单:原文原样带回 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})) + + @guarded def _do_sell(self, stock_no, amount, price): # price is None ⇒ 真·市价委托(五档即成剩撤);有值 ⇒ F2 限价挂单(原逻辑)。 if price is None: return self._submit_market_trade("卖出", stock_no, amount) return self._submit_trade("F2", "卖出", stock_no, amount, price) + @guarded def _do_buy(self, stock_no, amount, price): if price is None: return self._submit_market_trade("买入", stock_no, amount) @@ -1406,17 +1589,29 @@ def _submit_market_trade(self, op_keyword, stock_no, amount): 拒(回执查不到成交时返回 unknown 并提示可能非交易时段,不当成功)。""" strat = MARKET_STRATEGY.get(op_keyword) if not strat: - return {"code": 1, "status": "failed", "msg": f"未知方向 {op_keyword!r}"} + 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() - before = pre.get("data", []) if pre.get("code") == 0 else [] + 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 [] if not self._select_tree_child(MARKET_TREE_PARENT, op_keyword): - return {"code": 1, "status": "failed", "msg": "未能导航到市价委托面板"} + return contract.fail(contract.CODE_READ_FAILED, contract.CLS_READ_FAILED, + "未能导航到市价委托面板", data={"submitted": False}) time.sleep(sleep_time) hwnd = self.get_right_hwnd() @@ -1430,16 +1625,19 @@ def _submit_market_trade(self, op_keyword, stock_no, amount): 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 {"code": 1, "status": "failed", "msg": "未找到委托策略下拉框"} + 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 {"code": 1, "status": "failed", - "msg": "委托策略未能设为五档即成剩撤,已中止(避免下错单)"} + 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: @@ -1453,30 +1651,37 @@ def _submit_market_trade(self, op_keyword, stock_no, amount): deadline = time.time() + 8.0 while time.time() < deadline: post = self.get_filled_orders() - if post.get("code") == 0: - r = _match_market_fill(before, post.get("data", []), + if contract.is_succeed(post): + r = _match_market_fill(before, post.get("data") or [], stock_no, op_keyword, amount) - if r["code"] == 0: - return pump.attach_to(r) + if contract.is_succeed(r): + r["data"] = pump.attach_to(r["data"]) + return r time.sleep(0.3) logger.warning("market submit unconfirmed stock=%s op=%s amount=%s dialogs=%s", stock_no, op_keyword, amount, pump.dialogs) + data = pump.attach_to({"stock_no": str(stock_no), "方向": op_keyword, + "requested_amount": int(amount), "filled_amount": 0, + "submitted": True}) 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}) - + # 有柜台原文 ⇒ 大概率是明确拒绝,走 broker_rejected 让 class 可分流。 + return contract.broker_rejected( + ";".join(pump.texts), + message="已提交但未在成交表确认成交,客户端有提示,请核对成交与委托", + data=data) + return contract.submitted_unconfirmed( + "已提交但未在成交表确认成交(可能非连续竞价时段/涨跌停被拒/无成交)。" + "安全动作=用同一 client_order_id 原样重发(幂等),或调 query_order 核实", + data=data) + + @guarded def _do_cancel(self, entrust_no): try: return self._cancel_inner(entrust_no) except Exception as e: logger.exception("cancel(%s) unhandled exception", entrust_no) - return {"code": 1, "status": "failed", "msg": f"cancel error: {e}"} + return contract.fail(contract.CODE_INTERNAL_ERROR, contract.CLS_INTERNAL_ERROR, + f"cancel error: {e}") def _cancel_inner(self, entrust_no): self.switch_to_normal() @@ -1484,16 +1689,20 @@ def _cancel_inner(self, entrust_no): self.refresh() hwnd = self.get_right_hwnd() if not hwnd: - return {"code": 1, "status": "failed", "msg": "right pane not found"} + return contract.fail(contract.CODE_READ_FAILED, contract.CLS_READ_FAILED, + "撤单:未找到右侧面板") ctrl = self._find_grid(hwnd) if not ctrl: - return {"code": 1, "status": "failed", "msg": "table control 0x417 not found in F3 panel"} + return contract.fail(contract.CODE_READ_FAILED, contract.CLS_READ_FAILED, + "撤单:F3 面板未找到委托表控件") data = self.read_table_text(ctrl) if not data: - return {"code": 1, "status": "failed", "msg": "clipboard empty after copy"} + return contract.fail(contract.CODE_READ_FAILED, contract.CLS_READ_FAILED, + "撤单:拷贝委托表未落定(可能验证码弹窗)") entrusts = parse_table(data) if not entrusts: - return {"code": 1, "status": "failed", "msg": "F3 table parsed empty"} + return contract.fail(contract.CODE_READ_FAILED, contract.CLS_READ_FAILED, + "撤单:F3 委托表解析为空") # F3 may show 委托编号 or 合同编号 depending on THS version/panel state. # _lookup_entrust_no returns 合同编号 from F1+F8; cancel must match either. id_col = None @@ -1503,18 +1712,21 @@ def _cancel_inner(self, entrust_no): break if not id_col: cols = list(entrusts[0].keys()) - return { - "code": 1, - "status": "failed", - "msg": f"F3 table has neither 委托编号 nor 合同编号, columns: {cols}", - } + return contract.fail( + contract.CODE_TABLE_MISMATCH, contract.CLS_TABLE_MISMATCH, + f"撤单:F3 表既无委托编号也无合同编号,实得列 {cols}", + data={"got_columns": cols}) find = None for i, entrust in enumerate(entrusts): if str(entrust[id_col]) == str(entrust_no): find = i break if find is None: - return {"code": 1, "status": "failed", "msg": f"没找到指定订单 {entrust_no}"} + return contract.fail(contract.CODE_NOT_FOUND, contract.CLS_NOT_FOUND, + f"撤单:委托表中没找到指定订单 {entrust_no}(可能已成/已撤)") + # 撤单是按行号算坐标的盲点击:本笔若已被作废,页面早被下一笔切走, + # 这两下点击会落到未知控件上 —— 提交类动作前必须对代次。 + self._abort_if_stale("cancel_click") left, top, right, bottom = win32gui.GetWindowRect(ctrl) x = 50 + left y = 30 + 16 * find + top @@ -1528,7 +1740,7 @@ def _cancel_inner(self, entrust_no): # 双击委托行后可能弹「撤单确认」——结构化处置(取代两次盲 Enter), # 弹窗内容带回回执。 pump = self._pump_dialogs() - return pump.attach_to({"code": 0, "status": "succeed"}) + return contract.ok(pump.attach_to({"entrust_no": str(entrust_no), "submitted": True})) def get_result(self, cid=0x3EC): tid, pid = win32process.GetWindowThreadProcessId(self.hwnd_main) @@ -1570,6 +1782,7 @@ def handler(hwnd, results): return {"code": 1, "status": "failed", "msg": text} def refresh(self): + self._abort_if_stale("refresh") hot_key(["F5"]) time.sleep(refresh_sleep_time) @@ -1579,6 +1792,9 @@ def active_mian_window(self): time.sleep(sleep_time) def switch_to_normal(self): + # 翻页/抓表链路的第一个动作 —— 代次检查放这里,脱缰线程在发出任何 + # 全局按键之前就退出。 + self._abort_if_stale("switch_to_normal") tabs = self.get_left_bottom_tabs() left, top, right, bottom = win32gui.GetWindowRect(tabs) x = left + 10 @@ -1618,6 +1834,7 @@ def read_table_text(self, hwnd, timeout: float = 2.0): 仍未变化 = 拷贝没落定(窗口没焦点 / 被验证码挡),返回 None 让调用方重试—— **绝不返回上一次遗留的陈旧表格**。读完立刻清空,剪贴板不留数据。 """ + self._abort_if_stale("read_table_text") user32 = ctypes.windll.user32 self._empty_clipboard() seq0 = user32.GetClipboardSequenceNumber() # 清空后取基线,之后变化=本次拷贝 @@ -1702,6 +1919,9 @@ def input_ocr(self): ) ocr_config = f"--psm 7 -c tessedit_char_whitelist={whitelist}" for attempt in range(1, max_retries + 1): + # 每轮都对代次:验证码流程最长可跑十几秒,脱缰线程绝不能在这里 + # 继续点弹窗——那正是下一笔调用要处置的同一个框。 + self._abort_if_stale(f"input_ocr#{attempt}") captcha_static = self.get_ocr_hwnd() if not captcha_static: return @@ -1889,6 +2109,13 @@ async def orders_active(self) -> dict[str, Any]: return bound_err return await asyncio.to_thread(self.get_active_orders) + async def orders_active_all(self) -> dict[str, Any]: + """内部用(order_watch):含终态的委托全量表。""" + bound_err = self._ensure_bound() + if bound_err: + return bound_err + return await asyncio.to_thread(self.get_active_orders_all) + async def orders_filled(self) -> dict[str, Any]: bound_err = self._ensure_bound() if bound_err: @@ -1945,16 +2172,17 @@ async def switch_account(self, slot: Any) -> dict[str, Any]: try: slot = int(slot) except (TypeError, ValueError): - return {"code": 1, "status": "failed", - "msg": f"slot 参数无效:{slot!r},须为 1-9 的整数"} + return contract.fail(contract.CODE_INVALID_PARAMS, contract.CLS_INVALID_PARAMS, + f"slot 参数无效:{slot!r},须为 1-9 的整数") if not 1 <= slot <= 9: - return {"code": 1, "status": "failed", - "msg": f"slot 超出范围:{slot},须为 1-9 的整数"} + return contract.fail(contract.CODE_INVALID_PARAMS, contract.CLS_INVALID_PARAMS, + f"slot 超出范围:{slot},须为 1-9 的整数") bound_err = self._ensure_bound() if bound_err: return bound_err return await asyncio.to_thread(self.do_switch_account, slot) + @guarded def do_switch_account(self, slot: int): """向 xiadan 发送 Alt+N,切换多账户登录下的当前活跃资金账户。 @@ -1966,15 +2194,11 @@ def do_switch_account(self, slot: int): hot_key(["alt", str(slot)]) # 切换会触发资金/持仓面板重载,稍等再放行后续操作。 time.sleep(sleep_time * 2) - return { - "code": 0, - "status": "succeed", - "data": { - "slot": slot, - "msg": ( - f"已向同花顺窗口发送 Alt+{slot}(盲切,未核验结果)。" - "后续所有查询/下单都作用于切换后的当前账户," - "请先用 balance/position 核对账户身份再继续。" - ), - }, - } + return contract.ok({ + "slot": slot, + "msg": ( + f"已向同花顺窗口发送 Alt+{slot}(盲切,未核验结果)。" + "后续所有查询/下单都作用于切换后的当前账户," + "请先用 balance/position 核对账户身份再继续。" + ), + }) diff --git a/src/trader/watchlist_watch.py b/src/trader/watchlist_watch.py index ff98135..f50f794 100644 --- a/src/trader/watchlist_watch.py +++ b/src/trader/watchlist_watch.py @@ -76,10 +76,10 @@ async def watchlist_watch_task(state, client) -> None: async with backend.win_lock: res = await backend.watchlist() - if not res or res.get("code") != 0: + if not res or res.get("status") != "succeed": logger.info("watchlist_watch 跳过:读取失败 %s", (res or {}).get("msg")) continue - cur = list(res.get("data") or []) + cur = list((res.get("data") or {}).get("codes") or []) if prev is None: prev = cur logger.info("watchlist_watch 基线建立:顶部 %d 只", len(cur)) diff --git a/src/trader/ws_client.py b/src/trader/ws_client.py index 9a9a0a2..54bc952 100644 --- a/src/trader/ws_client.py +++ b/src/trader/ws_client.py @@ -118,12 +118,13 @@ def _format_rpc_log( if result is None: return prefix - if isinstance(result, dict) and result.get("code") == 0: + if isinstance(result, dict) and result.get("status") == "succeed": + payload = result.get("data") if isinstance(result.get("data"), dict) else {} if method == "balance": - avail = result.get("available_cash") or result.get("available") + avail = payload.get("可用金额") tail = f"可用 {avail}" if avail is not None else "OK" elif method in ("buy", "sell"): - oid = result.get("entrust_no") or result.get("order_id") + oid = payload.get("entrust_no") tail = f"委托号 {oid}" if oid else "OK" elif method == "cancel": tail = "已撤单" @@ -255,7 +256,7 @@ async def _main_loop(self, ws: "ClientConnection") -> None: # type: ignore async for raw_msg in ws: try: frame = json.loads(raw_msg) - await self._handle_frame(frame) + await self._handle_frame(frame, ws) except SessionRejectedException: raise except Exception as e: @@ -267,8 +268,8 @@ async def _main_loop(self, ws: "ClientConnection") -> None: # type: ignore except Exception as e: logger.error("主循环出错:%s", e) - async def _handle_frame(self, frame: dict[str, Any]) -> None: - """处理接收到的帧""" + async def _handle_frame(self, frame: dict[str, Any], origin_ws: Any = None) -> None: + """处理接收到的帧。origin_ws=收到该帧的那条连接(用于回执归属校验)。""" frame_type = frame.get("type") if frame_type == "pair_pending": @@ -324,12 +325,18 @@ async def _handle_frame(self, frame: dict[str, Any]) -> None: # 连用于核单的 orders_active/orders_filled 都进不来(2026-07-13 事故: # 一笔卡死瘫痪整个受控端)。执行顺序不受影响:交易/查询本就由 # backend.win_lock(FIFO)串行。 - task = asyncio.create_task(self._process_call(frame)) + task = asyncio.create_task(self._process_call(frame, origin_ws)) 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 中运行)。""" + async def _process_call(self, frame: dict[str, Any], origin_ws: Any = None) -> None: + """执行一个 call 帧并回发 reply(在独立 task 中运行)。 + + origin_ws=收到该帧的连接。一笔 RPC 最长可跑 25s,其间完全可能断线重连; + 若发送时 self.ws 已换成新连接,这条回执就是**跨会话错投**——它的 id 属于 + 旧会话,发到新连接上归属无从保证(能否配到别的请求头上取决于网关的 id + 策略,不能靠对端兜底)。宁可丢弃并留痕:调用方那边本就已超时。 + """ rpc_id = frame.get("id") method = frame.get("method") params = frame.get("params", {}) @@ -352,6 +359,11 @@ async def _process_call(self, frame: dict[str, Any]) -> None: 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 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)) diff --git a/tests/test_balance_visible.py b/tests/test_balance_visible.py index 8b30010..9491610 100644 --- a/tests/test_balance_visible.py +++ b/tests/test_balance_visible.py @@ -35,8 +35,10 @@ def find_ctrl(root, cid, cls=None, visible=False): return VISIBLE if visible else HIDDEN result = _stubbed_backend(monkeypatch, find_ctrl).get_balance() - assert result["code"] == 0 - assert set(result["data"].values()) == {"1.23"} + assert result["status"] == "succeed" + # 契约 v2:数值一律 number(元),键名钉死 + assert result["data"]["总资产"] == 1.23 + assert result["data"]["可用金额"] == 1.23 def test_fails_loudly_when_no_visible_ctrl(monkeypatch): @@ -46,7 +48,8 @@ def find_ctrl(root, cid, cls=None, visible=False): return 0 if visible else HIDDEN result = _stubbed_backend(monkeypatch, find_ctrl).get_balance() - assert result["code"] == 1 - assert "不回退" in result["msg"] + assert result["status"] == "failed" + assert result["code"] == "read_failed" + assert "不回退" in result["error"]["message"] # 隐藏副本的数字绝不能出现在任何返回里 assert "34915.47" not in str(result) diff --git a/tests/test_contract_docs_sync.py b/tests/test_contract_docs_sync.py new file mode 100644 index 0000000..610e9f1 --- /dev/null +++ b/tests/test_contract_docs_sync.py @@ -0,0 +1,76 @@ +"""C7 契约即测试:规范件(PROTOCOL.md / tools_schema.json)与实现不许漂移。 + +冻结的前提是漂移可检测——否则「很久不改」等于「坏了很久没人知道」。 +""" +import json +from pathlib import Path + +import pytest + +from trader import contract +from trader.dispatcher import FALLBACK_TOOLS_SCHEMA, METHOD_WHITELIST +from trader.ths import rows + +ROOT = Path(__file__).resolve().parents[1] +PROTOCOL = (ROOT / "docs/PROTOCOL.md").read_text("utf-8") +SCHEMA = json.loads((ROOT / "docs/tools_schema.json").read_text("utf-8")) + +CODES = [v for k, v in vars(contract).items() if k.startswith("CODE_")] +CLASSES = [v for k, v in vars(contract).items() if k.startswith("CLS_")] + + +@pytest.mark.parametrize("code", CODES) +def test_every_code_is_documented(code): + assert f"`{code}`" in PROTOCOL, f"code={code} 未写进 PROTOCOL.md" + + +@pytest.mark.parametrize("cls", CLASSES) +def test_every_error_class_is_documented(cls): + assert f"`{cls}`" in PROTOCOL, f"error.class={cls} 未写进 PROTOCOL.md" + + +@pytest.mark.parametrize("state", rows.ORDER_STATES) +def test_every_order_state_is_documented(state): + assert state in PROTOCOL, f"委托状态 {state} 未写进 PROTOCOL.md" + + +def test_contract_version_is_consistent(): + assert SCHEMA["contract_version"] == contract.CONTRACT_VERSION + assert f'"{contract.CONTRACT_VERSION}"' in PROTOCOL + # 网关侧同一常量(Go)——三处不同步就打红 + gateway_hub = ROOT.parent.parent.parent / "guling-mcp-gateway/gateway/hub.go" + if gateway_hub.exists(): + assert f'ContractVersion = "{contract.CONTRACT_VERSION}"' in gateway_hub.read_text("utf-8") + + +def test_schema_tools_match_method_whitelist(): + schema_names = {t["name"] for t in SCHEMA["tools"]} + exposed = METHOD_WHITELIST - {"tools/list"} + assert schema_names == exposed, "tools_schema.json 与 METHOD_WHITELIST 不一致" + + +def test_fallback_schema_matches_disk_verbatim(): + """打包环境用 FALLBACK,必须与磁盘规范件逐字一致。""" + assert FALLBACK_TOOLS_SCHEMA == SCHEMA + + +def test_query_order_is_exposed(): + assert "query_order" in {t["name"] for t in SCHEMA["tools"]} + + +@pytest.mark.parametrize("name", ["buy", "sell", "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"] + assert "幂等" in desc and "重发" in desc + + +def test_protocol_states_failed_is_not_not_submitted(): + """最容易被误读的一条语义必须白纸黑字在协议里。""" + assert "不等于**「未提交」" in PROTOCOL or "不等于「未提交」" in PROTOCOL + assert "禁止改单重下" in PROTOCOL + + +def test_protocol_pins_empty_table_semantics(): + assert "空表是成功" in PROTOCOL + assert "绝不返回空数组冒充" in PROTOCOL diff --git a/tests/test_contract_envelope.py b/tests/test_contract_envelope.py new file mode 100644 index 0000000..db848b4 --- /dev/null +++ b/tests/test_contract_envelope.py @@ -0,0 +1,109 @@ +"""契约 v2 信封与类型规范(C1/C2/C6)——冻结件的直接回归哨。 + +冻结的前提是漂移可检测:这些断言就是「漂移探测器」,改动信封形状必然打红。 +""" +import json + +import pytest + +from trader import contract +from trader.ths import rows + +ALL_TOOLS_ENVELOPES = [ + contract.ok({"any": 1}), + contract.ok([]), + contract.fail(contract.CODE_READ_FAILED, contract.CLS_READ_FAILED, "读不到"), + contract.busy("忙"), + contract.broker_rejected("可用资金不足"), + contract.submitted_unconfirmed("不可知"), +] + + +@pytest.mark.parametrize("env", ALL_TOOLS_ENVELOPES) +def test_envelope_shape_is_uniform(env): + """C1:所有回执同形,无例外形。""" + assert set(env) == {"status", "code", "data", "error", "contract_version"} + assert env["status"] in ("succeed", "failed", "busy") + assert isinstance(env["code"], str) and env["code"] + assert env["contract_version"] == "2" + if env["status"] == "succeed": + assert env["error"] is None + else: + assert set(env["error"]) == {"class", "broker_msg", "message"} + assert isinstance(env["error"]["class"], str) + + +@pytest.mark.parametrize("env", ALL_TOOLS_ENVELOPES) +def test_envelope_is_json_serializable(env): + json.dumps(env, ensure_ascii=False) + + +def test_failed_does_not_mean_not_submitted(): + """最容易踩的语义:submitted_unconfirmed 是 failed,但**不代表没提交**。""" + env = contract.submitted_unconfirmed("超时", data={"submitted": True}) + assert env["status"] == "failed" + assert env["code"] == "submitted_unconfirmed" + assert env["error"]["class"] == "unknown_outcome" + assert env["data"]["submitted"] is True + assert contract.CLS_UNKNOWN_OUTCOME in contract.NON_RETRYABLE_CLASSES + + +# --- C2 两层错误分类 --------------------------------------------------------- + +@pytest.mark.parametrize("text,expected", [ + ("可用资金不足,无法委托", contract.CLS_INSUFFICIENT_FUNDS), + ("委托价格超出涨跌幅限制", contract.CLS_PRICE_OUT_OF_LIMIT), + ("委托数量必须是100的整数倍", contract.CLS_INVALID_QUANTITY), + ("该证券今日停牌", contract.CLS_SUSPENDED), + ("未开通科创板交易权限", contract.CLS_NO_PERMISSION), + ("柜台超时,请稍后重试", contract.CLS_BROKER_TIMEOUT), +]) +def test_broker_message_classification(text, expected): + assert contract.classify_broker_message(text) == expected + + +@pytest.mark.parametrize("text", ["", None, "系统提示:请联系客户经理", "XJ-2049"]) +def test_unrecognized_broker_message_is_unknown(text): + """认不出就必须是 unknown——误判「可重试」会真的重复下单。""" + assert contract.classify_broker_message(text) == contract.CLS_UNKNOWN + assert contract.CLS_UNKNOWN in contract.NON_RETRYABLE_CLASSES + + +def test_broker_rejected_keeps_raw_text(): + env = contract.broker_rejected("可用资金不足,无法委托") + assert env["error"]["broker_msg"] == "可用资金不足,无法委托" + assert env["error"]["class"] == contract.CLS_INSUFFICIENT_FUNDS + + +# --- C6 类型与单位 ----------------------------------------------------------- + +@pytest.mark.parametrize("raw", ["--", "", "-", "N/A", None]) +def test_placeholder_maps_to_null_never_zero(raw): + """空占位符一律 null。映射成 0 会被下游当真值用(真钱 sizing 的输入)。""" + assert contract.money(raw) is None + assert contract.qty(raw) is None + assert contract.price(raw) is None + assert contract.pct(raw) is None + + +def test_number_parsing_units_and_rounding(): + assert contract.money("1,234.567") == 1234.57 # 金额取整到分 + assert contract.price("35.1234") == 35.123 # 价格到厘 + assert contract.qty("500") == 500 + assert contract.qty("500.0") == 500 + assert contract.pct("-3.25%") == -3.25 + + +def test_direction_enum_falls_back_to_raw(): + assert contract.direction("证券买入") == "买入" + assert contract.direction("卖出") == "卖出" + assert contract.direction("融券回购") == "融券回购" # 认不出保留原文,不猜 + + +def test_balance_keys_are_pinned_and_pct_renamed(): + out = rows.normalize_balance({"总资产": "1,000.00", "当日盈亏比": "1.23%", + "可用金额": "--"}) + assert out["总资产"] == 1000.0 + assert out["当日盈亏比_pct"] == 1.23 + assert "当日盈亏比" not in out # 带 % 的键名已更名 + assert out["可用金额"] is None # 不是 0 diff --git a/tests/test_dispatcher_envelope.py b/tests/test_dispatcher_envelope.py index 3c58f8b..549fe77 100644 --- a/tests/test_dispatcher_envelope.py +++ b/tests/test_dispatcher_envelope.py @@ -4,14 +4,14 @@ - Bug 4:reply 帧曾被 ws_client 双层包裹 → 外层永远 ok:true,掩盖真实失败。 这里锁定 dispatcher 只产出"单层"reply 帧(含 id/ok/result|error),ws_client 直接转发即可。 -- Bug 1:非 code:0 的结果曾一律塌缩成"未知错误"。这里锁定 code/status/msg 被透传, - 且 code:2(已提交未确认)给出明确不要重复下单的语义。 +- Bug 1:失败结果曾一律塌缩成"未知错误"。这里锁定契约 v2 信封被原样透传, + 且 submitted_unconfirmed(已提交未确认)给出明确不要重复下单的语义。 均为同步测试,用 asyncio.run 驱动 async handle_call,避免依赖 pytest-asyncio。 """ import asyncio -from trader import dispatcher +from trader import contract, dispatcher class FakeBackend: @@ -66,56 +66,66 @@ def _call(frame, result): def test_success_is_single_layer_with_id_echoed(): """code:0 → ok:true,result 就是后端原始 dict(不再多嵌一层 reply 帧)。""" frame = {"type": "call", "id": "abc-123", "method": "balance", "params": {}} - reply, _ = _call(frame, {"code": 0, "status": "succeed", "data": {"可用": "295.38"}}) + reply, _ = _call(frame, contract.ok({"可用金额": 295.38})) assert reply["type"] == "reply" assert reply["id"] == "abc-123" # id 必须回显(旧实现内层 id=null) assert reply["ok"] is True # result 直接是后端 dict,而不是 {"type":"reply", ...} 这样的再包一层。 - assert reply["result"]["code"] == 0 - assert reply["result"]["data"]["可用"] == "295.38" + assert reply["result"]["status"] == "succeed" + assert reply["result"]["code"] == "ok" + assert reply["result"]["contract_version"] == "2" + assert reply["result"]["data"]["可用金额"] == 295.38 assert reply["result"].get("type") != "reply" -def test_submitted_but_unconfirmed_code2_is_not_unknown_error(): - """code:2(已提交未确认)必须给出明确文案 + 透传 result,绝不能塌成'未知错误'。""" +def test_submitted_unconfirmed_is_not_unknown_error(): + """已提交未确认必须给出明确文案 + 透传信封,绝不能塌成'未知错误'。""" frame = {"type": "call", "id": "id2", "method": "sell", "params": {"stock_no": "300459", "amount": 100}} - result = {"code": 2, "status": "unknown", - "msg": "已提交但未能在 orders/active 表中匹配到对应订单,请自行确认状态"} + result = contract.submitted_unconfirmed("已提交但未能在委托表中匹配到对应订单", + data={"submitted": True}) reply, _ = _call(frame, result) assert reply["ok"] is False - assert reply["error"] == result["msg"] # 用 msg,不是 "未知错误" + assert reply["error"] == result["error"]["message"] assert "未知错误" not in reply["error"] - assert reply["result"]["code"] == 2 # 透传,供 agent 区分"已提交"vs"被拒" + # 供调用方区分「已提交」vs「被拒」:code + class 都是机器枚举 + assert reply["result"]["code"] == "submitted_unconfirmed" + assert reply["result"]["error"]["class"] == "unknown_outcome" def test_failed_query_propagates_msg_not_unknown_error(): """读列表失败(code:1 带 msg)应透传 msg,不再是裸的'未知错误'。""" frame = {"type": "call", "id": "id3", "method": "orders_active", "params": {}} - result = {"code": 1, "status": "failed", "msg": "读取数据失败(可能验证码弹窗或刷新超时),请稍后重试"} + result = contract.fail(contract.CODE_READ_FAILED, contract.CLS_READ_FAILED, + "读取数据失败(可能验证码弹窗或刷新超时),请稍后重试") reply, _ = _call(frame, result) assert reply["ok"] is False - assert reply["error"] == result["msg"] + assert reply["error"] == result["error"]["message"] assert reply["result"]["status"] == "failed" + assert reply["result"]["code"] == "read_failed" -def test_failed_without_detail_falls_back_to_unknown(): - """既无 error 又无 msg 时才允许回退到'未知错误'。""" +def test_non_contract_shape_is_rejected_loudly(): + """后端若返回非契约形态(老信封/裸 dict),dispatcher 必须转成 internal_error 信封, + 绝不放行——冻结后消费侧按契约解析,放行等于把解析崩溃推给对端。""" frame = {"type": "call", "id": "id4", "method": "position", "params": {}} reply, _ = _call(frame, {"code": 1}) assert reply["ok"] is False - assert reply["error"] == "未知错误" + assert reply["result"]["code"] == "internal_error" + assert reply["result"]["contract_version"] == "2" -def test_explicit_error_key_is_preferred(): +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}} - reply, _ = _call(frame, {"code": 1, "error": "可用资金不足"}) + reply, _ = _call(frame, contract.broker_rejected("可用资金不足,无法委托")) assert reply["ok"] is False - assert reply["error"] == "可用资金不足" + assert reply["result"]["error"]["class"] == "insufficient_funds" + assert reply["result"]["error"]["broker_msg"] == "可用资金不足,无法委托" def test_method_not_whitelisted(): diff --git a/tests/test_dispatcher_lock.py b/tests/test_dispatcher_lock.py index b3ccceb..1258ec4 100644 --- a/tests/test_dispatcher_lock.py +++ b/tests/test_dispatcher_lock.py @@ -1,7 +1,7 @@ """dispatcher 锁 + agent 下单登记回归。沿用 asyncio.run 同步驱动约定。""" import asyncio -from trader import dispatcher +from trader import contract, dispatcher class LockFakeBackend: @@ -20,13 +20,13 @@ async def _hold(self, result): return result async def orders_active(self): - return await self._hold({"code": 0, "status": "succeed", "data": []}) + return await self._hold(contract.ok([])) async def buy(self, stock_no, amount, price, client_order_id): - return await self._hold({"code": 0, "entrust_no": "777"}) + return await self._hold(contract.ok({"entrust_no": "777"})) async def sell(self, stock_no, amount, price, client_order_id): - return await self._hold({"code": 0, "entrust_no": "888"}) + return await self._hold(contract.ok({"entrust_no": "888"})) def test_window_methods_serialized_by_lock(): diff --git a/tests/test_dispatcher_timeout.py b/tests/test_dispatcher_timeout.py index 556f7a7..337c286 100644 --- a/tests/test_dispatcher_timeout.py +++ b/tests/test_dispatcher_timeout.py @@ -24,7 +24,8 @@ async def sell(self, *a, **k): await asyncio.sleep(3600) async def orders_active(self): - return {"code": 0, "status": "succeed", "data": []} + from trader import contract + return contract.ok([]) def dialog_cleanup(self): # dispatcher 经 asyncio.to_thread 调用(同步) self.cleanup_calls += 1 @@ -41,11 +42,11 @@ def test_order_timeout_returns_unknown_not_bare_error(monkeypatch): 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 reply["result"]["code"] == "submitted_unconfirmed" + assert reply["result"]["error"]["class"] == "unknown_outcome" # 核单指引必须在错误文本里,防调用方凭报错补单 - assert "orders_filled" in reply["error"] - assert "勿直接重复下单" in reply["error"] + assert "query_order" in reply["error"] or "orders_active" in reply["error"] + assert "勿改单重下" in reply["error"] assert backend.degraded is True @@ -58,7 +59,7 @@ async def orders_active(self): reply = _call(SlowQueryBackend(), "orders_active") assert reply["ok"] is False - assert reply["result"]["code"] == 1 # 查询超时是普通失败,不是「可能已提交」 + assert reply["result"]["code"] == "call_timeout" # 查询超时是普通失败,不是「可能已提交」 def test_lock_busy_instead_of_starvation(monkeypatch): @@ -74,6 +75,7 @@ async def drive(): reply = asyncio.run(drive()) assert reply["ok"] is False assert reply["result"]["status"] == "busy" + assert reply["result"]["code"] == "busy" assert "orders_active" in reply["error"] # 下单类 busy 也要带核单提醒 diff --git a/tests/test_grid_query_guard.py b/tests/test_grid_query_guard.py new file mode 100644 index 0000000..47ed7bd --- /dev/null +++ b/tests/test_grid_query_guard.py @@ -0,0 +1,107 @@ +"""三张 grid 表的请求-响应配对校验(禁 succeed 携错表出门)。 + +2026-08-03 串线事故的正面修复:翻页快捷键是全局按键,没落到 xiadan 时 grid 里 +还是上一次查询的表,Ctrl+C 原样抓走;过去非空即 code=0。这里锁定四条: +① 正表照常返回;② 空表(今天无挂单/无成交)仍是成功、data=[]; +③ 错表拒收且带 got_columns;④ 首次抓错、重抓抓对 → 返回对的那张。 +Win32 层全部打桩,可跨平台跑。 +""" +import pytest + +from trader.ths import win as w +from trader.ths.win import WinThsBackend + +POSITION_TABLE = ( + "操作\t证券代码\t证券名称\t股票余额\t可用余额\t冻结数量\t参考成本价\t市价\t\r\n" + "卖出\t600000\t浦发银行\t1000\t1000\t0\t8.100\t8.230\t\r\n" +) +ACTIVE_TABLE = ( + "证券代码\t操作\t委托数量\t委托价格\t成交数量\t成交均价\t合同编号\t备注\t\r\n" + "300458\t买入\t500\t35.100\t0\t0.000\t123456\t已报\t\r\n" +) +FILLED_TABLE = ( + "成交时间\t证券代码\t证券名称\t操作\t成交数量\t成交均价\t成交金额\t合同编号\t\r\n" + "10:43:02\t300458\t全志科技\t买入\t500\t35.100\t17550.00\t123456\t\r\n" +) +EMPTY_ACTIVE_TABLE = ( # 无挂单时 THS 照样拷出表头 + 空占位行 + "证券代码\t操作\t委托数量\t委托价格\t成交数量\t成交均价\t合同编号\t备注\t\r\n" + "\t\t\t\t\t\t\t\t\r\n" +) + + +def _backend(monkeypatch, texts): + """texts=每次 read_table_text 依次返回的剪贴板文本。""" + b = WinThsBackend() + b.hwnd_main = 1 + seq = list(texts) + monkeypatch.setattr(b, "switch_to_normal", lambda: None) + monkeypatch.setattr(b, "refresh", lambda: None) + monkeypatch.setattr(b, "get_right_hwnd", lambda: 999) + monkeypatch.setattr(b, "_find_grid", lambda hwnd: 888) + monkeypatch.setattr(b, "read_table_text", lambda ctrl: seq.pop(0) if seq else None) + monkeypatch.setattr(w, "hot_key", lambda keys: None) + monkeypatch.setattr(w, "_activate_window", lambda hwnd: None) + monkeypatch.setattr(w, "sleep_time", 0) + return b + + +@pytest.mark.parametrize("method,table", [ + ("get_position", POSITION_TABLE), + ("get_active_orders", ACTIVE_TABLE), + ("get_filled_orders", FILLED_TABLE), +]) +def test_correct_table_returns_succeed(monkeypatch, method, table): + b = _backend(monkeypatch, [table]) + r = getattr(b, method)() + assert r["status"] == "succeed" + assert r["contract_version"] == "2" + assert len(r["data"]) == 1 + + +def test_empty_table_is_still_success(monkeypatch): + """空委托表=「今天真的没挂单」,必须 code=0 data=[]——不能被校验误杀, + 否则消费侧拿不到数据,与错表一样致盲。""" + b = _backend(monkeypatch, [EMPTY_ACTIVE_TABLE]) + r = b.get_active_orders() + assert r["status"] == "succeed" + assert r["data"] == [] + + +@pytest.mark.parametrize("method,wrong", [ + ("get_position", FILLED_TABLE), + ("get_active_orders", FILLED_TABLE), # 最险:错表会被读成「无挂单」 + ("get_filled_orders", POSITION_TABLE), +]) +def test_wrong_table_never_returns_succeed(monkeypatch, method, wrong): + b = _backend(monkeypatch, [wrong] * WinThsBackend._GRID_ATTEMPTS) + r = getattr(b, method)() + assert r["status"] == "failed" + assert r["code"] == "table_mismatch" + assert r["error"]["class"] == "table_mismatch" + assert "不是本次请求的表" in r["error"]["message"] + assert r["data"]["got_columns"] # 诊断用:实得表头进回执 + assert "rows" not in r["data"] # 错表的行绝不出门 + + +def test_retry_recovers_when_page_finally_switches(monkeypatch): + """首次翻页键没落到 xiadan(抓到上一张表),重抓一次就对了。""" + b = _backend(monkeypatch, [FILLED_TABLE, ACTIVE_TABLE]) + r = b.get_active_orders() + assert r["status"] == "succeed" + assert r["data"][0]["entrust_no"] == "123456" + + +def test_clipboard_failure_keeps_old_message(monkeypatch): + """抓不到文本(验证码/拷贝没落定)仍是原来的读取失败语义,不误报错表。""" + b = _backend(monkeypatch, []) + r = b.get_position() + assert r["status"] == "failed" + assert r["code"] == "read_failed" + assert "读取数据失败" in r["error"]["message"] + + +def test_wrong_table_is_not_written_into_state(monkeypatch): + """last-known 内存态也不许被错表污染。""" + b = _backend(monkeypatch, [FILLED_TABLE] * WinThsBackend._GRID_ATTEMPTS) + b.get_active_orders() + assert b.state.get("active_orders") is None diff --git a/tests/test_idempotency.py b/tests/test_idempotency.py new file mode 100644 index 0000000..aea403c --- /dev/null +++ b/tests/test_idempotency.py @@ -0,0 +1,185 @@ +"""C5a 幂等 + C5b 查单:台账语义与 dispatcher 闸门。 + +核心承诺(也是唯一承诺):**同 client_order_id 重发绝不产生第二次提交**。 +返回的可能是首次成功回执,也可能是「首次结果未知」——后者是合法态,不是 bug: +最危险那一刻(点了提交、回执没回来)台账自己也不知道结果。 +""" +import asyncio + +import pytest + +from trader import contract, dispatcher +from trader.order_ledger import LedgerUnavailable, OrderLedger + + +@pytest.fixture() +def ledger(tmp_path): + return OrderLedger(tmp_path / "orders.db") + + +BUY_PARAMS = {"stock_no": "600000", "amount": 100, "price": 8.1} + + +# --- 台账本身 --------------------------------------------------------------- + +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 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}) + assert verdict == "conflict", "同 id 换参数必须拒绝,不能静默返回首次回执" + + +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"} + + +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) + assert verdict == "duplicate" + + +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) + + +# --- dispatcher 闸门 --------------------------------------------------------- + +class OrderBackend: + def __init__(self, ledger, result=None): + self.win_lock = asyncio.Lock() + self.agent_entrust_nos: set[str] = set() + self.degraded = False + self.ledger = ledger + self.submits = 0 + self._result = result or contract.ok({"entrust_no": "777"}) + + async def buy(self, stock_no, amount, price, client_order_id): + self.submits += 1 + return self._result + + async def orders_active(self): + return contract.ok([]) + + async def orders_filled(self): + 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, + "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") + + assert backend.submits == 1, "同 coid 重发绝不能产生第二次提交" + assert first["result"]["data"]["entrust_no"] == "777" + assert second["result"]["data"]["entrust_no"] == "777" # 返回首次回执 + assert second["result"]["data"]["idempotent_replay"] is True + + +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") + assert backend.submits == 1 + assert second["result"]["code"] == "submitted_unconfirmed" + assert second["result"]["error"]["class"] == "unknown_outcome" + + +def test_same_coid_different_params_rejected(ledger): + backend = OrderBackend(ledger) + _buy(backend, "gl-3", amount=100) + other = _buy(backend, "gl-3", amount=200) + assert backend.submits == 1 + assert other["result"]["code"] == "invalid_params" + assert other["result"]["data"]["submitted"] is False + + +def test_ledger_unavailable_rejects_order(tmp_path): + """台账不可用一律拒单,禁静默降级为无幂等下单。""" + class NoLedgerBackend(OrderBackend): + ledger = None + + backend = NoLedgerBackend.__new__(NoLedgerBackend) + OrderBackend.__init__(backend, None) + reply = _buy(backend, "gl-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 仍可下单(不享受幂等)——不强制,但契约里写明后果。""" + backend = OrderBackend(ledger) + frame = {"type": "call", "id": "x", "method": "buy", + "params": {"stock_no": "600000", "amount": 100, "price": 8.1}} + reply = asyncio.run(dispatcher.handle_call(frame, backend)) + assert reply["ok"] is True + assert backend.submits == 1 + + +# --- 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") + + class B(OrderBackend): + async def orders_active(self): + return contract.ok([{"entrust_no": "777", "证券代码": "600000", + "委托数量": 100, "状态": "已报"}]) + + reply = asyncio.run(dispatcher.handle_call( + {"type": "call", "id": "q", "method": "query_order", + "params": {"client_order_id": "gl-5"}}, B(ledger))) + data = reply["result"]["data"] + assert data["state"] == "已报" + assert data["resolution"] == "by_entrust_no" + + +def test_query_order_unresolved_when_ambiguous(ledger): + """entrust_no 未知 + 实表有两笔同参单 → 不猜,报未知(需人工)。""" + ledger.reserve("gl-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, "状态": "已报"}, + ]) + + reply = asyncio.run(dispatcher.handle_call( + {"type": "call", "id": "q", "method": "query_order", + "params": {"client_order_id": "gl-6"}}, B(ledger))) + data = reply["result"]["data"] + assert data["state"] == "未知" + assert data["resolution"] == "unresolved" + + +def test_query_order_unknown_coid_is_not_found(ledger): + reply = asyncio.run(dispatcher.handle_call( + {"type": "call", "id": "q", "method": "query_order", + "params": {"client_order_id": "never-seen"}}, OrderBackend(ledger))) + assert reply["result"]["code"] == "not_found" diff --git a/tests/test_market_baseline.py b/tests/test_market_baseline.py new file mode 100644 index 0000000..1e8c816 --- /dev/null +++ b/tests/test_market_baseline.py @@ -0,0 +1,44 @@ +"""市价单回执基线:读不到成交表就不许下单。 + +市价单的成交量/均价靠下单前后的成交表差分得出(认「after 里 before 没有的行」)。 +基线拿不到时若以空基线继续,当日同股同向的历史成交会被算成本次成交——污染的是 +真钱 sizing 的输入,而市价单发出去无法回收。所以:基线失败=硬失败、绝不提交。 +""" +from trader.ths import win as w +from trader.ths.win import WinThsBackend + + +def _backend(monkeypatch, pre_result): + b = WinThsBackend() + b.hwnd_main = 1 + 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(w, "_activate_window", lambda hwnd: None) + monkeypatch.setattr(w, "sleep_time", 0) + return b, calls + + +def test_market_order_aborts_when_baseline_unreadable(monkeypatch): + from trader import contract + b, calls = _backend(monkeypatch, contract.fail( + contract.CODE_READ_FAILED, contract.CLS_READ_FAILED, "读取数据失败")) + r = b._submit_market_trade("买入", "300458", 500) + assert r["status"] == "failed" + assert r["data"]["submitted"] is False + assert "基线" in r["error"]["message"] + assert calls == [], "基线失败后绝不能继续走到下单面板" + + +def test_wrong_table_baseline_also_aborts(monkeypatch): + """表头校验拦下的错表同样算基线失败——错表当基线比没有基线更糟。""" + from trader import contract + b, calls = _backend(monkeypatch, contract.fail( + contract.CODE_TABLE_MISMATCH, contract.CLS_TABLE_MISMATCH, + "成交查询:抓到的不是本次请求的表(命中他表特征列 ['股票余额'])", + data={"got_columns": ["股票余额"]})) + r = b._submit_market_trade("卖出", "300458", 500) + assert r["status"] == "failed" + assert calls == [] diff --git a/tests/test_market_fill.py b/tests/test_market_fill.py index 55cd192..851c220 100644 --- a/tests/test_market_fill.py +++ b/tests/test_market_fill.py @@ -2,49 +2,65 @@ 五档即成剩撤下完不留 orders_active,且可能部分成交 → 回执必须查成交表(orders_filled) 拿真实成交量/均价。这里只测前后差分 + 汇总逻辑,不触碰 Win32。 +契约 v2:输入是**规范化后**的成交行(number/方向枚举),输出是统一信封。 """ from trader.ths.win import _match_market_fill def _row(code, op, qty, price, amt, sn): - return {"证券代码": code, "操作": op, "成交数量": qty, + """规范化后的成交行(normalize_filled_row 的产物形状)。""" + return {"证券代码": code, "方向": op, "成交数量": qty, "成交均价": price, "成交金额": amt, "成交编号": sn} def test_full_fill_single_row(): before = [] - after = [_row("600000", "证券买入", "100", "12.340", "1234.00", "A1")] + after = [_row("600000", "买入", 100, 12.34, 1234.00, "A1")] r = _match_market_fill(before, after, "600000", "买入", 100) - assert r["status"] == "filled" - assert r["filled_amount"] == 100 - assert r["avg_price"] == 12.34 - assert r["op"] == "买入" + assert r["status"] == "succeed" + assert r["data"]["fill_state"] == "filled" + assert r["data"]["filled_amount"] == 100 + assert r["data"]["成交均价"] == 12.34 + assert r["data"]["方向"] == "买入" def test_partial_fill_multi_row_weighted_avg(): # 请求 300,两笔成交共 200 → 部分成交;均价按金额/数量加权。 - before = [_row("600000", "证券买入", "999", "9.999", "9989.00", "OLD")] + before = [_row("600000", "买入", 999, 9.999, 9989.00, "OLD")] after = [ - _row("600000", "证券买入", "999", "9.999", "9989.00", "OLD"), - _row("600000", "证券买入", "100", "12.000", "1200.00", "A1"), - _row("600000", "证券买入", "100", "12.500", "1250.00", "A2"), + _row("600000", "买入", 999, 9.999, 9989.00, "OLD"), + _row("600000", "买入", 100, 12.000, 1200.00, "A1"), + _row("600000", "买入", 100, 12.500, 1250.00, "A2"), ] r = _match_market_fill(before, after, "600000", "买入", 300) - assert r["status"] == "partially_filled" - assert r["filled_amount"] == 200 - assert r["avg_price"] == 12.25 # (1200+1250)/200 + assert r["status"] == "succeed" + assert r["data"]["fill_state"] == "partially_filled" + assert r["data"]["filled_amount"] == 200 + assert r["data"]["成交均价"] == 12.25 # (1200+1250)/200 -def test_no_match_returns_unknown(): +def test_no_match_returns_unknown_outcome(): + """成交表里找不到本次成交 ⇒ 结果不可知,绝不当成功也绝不当明确失败。""" before = [] - after = [_row("000001", "证券买入", "100", "10.000", "1000.00", "X1")] + after = [_row("000001", "买入", 100, 10.000, 1000.00, "X1")] r = _match_market_fill(before, after, "600000", "买入", 100) - assert r["status"] == "unknown" - assert r["filled_amount"] == 0 + assert r["status"] == "failed" + assert r["code"] == "submitted_unconfirmed" + assert r["error"]["class"] == "unknown_outcome" + assert r["data"]["filled_amount"] == 0 def test_ignores_opposite_op_same_code(): before = [] - after = [_row("600000", "证券卖出", "100", "12.000", "1200.00", "S1")] + after = [_row("600000", "卖出", 100, 12.000, 1200.00, "S1")] r = _match_market_fill(before, after, "600000", "买入", 100) - assert r["status"] == "unknown" + assert r["code"] == "submitted_unconfirmed" + + +def test_null_numeric_row_is_skipped_not_counted_as_zero(): + """缺值是 null 不是 0:一行数量/金额读不到时跳过,不能把它算成 0 股成交。""" + before = [] + after = [_row("600000", "买入", None, None, None, "A1"), + _row("600000", "买入", 100, 12.00, 1200.00, "A2")] + r = _match_market_fill(before, after, "600000", "买入", 100) + assert r["data"]["filled_amount"] == 100 diff --git a/tests/test_order_watch.py b/tests/test_order_watch.py index 546bcf4..0ddfadf 100644 --- a/tests/test_order_watch.py +++ b/tests/test_order_watch.py @@ -21,42 +21,48 @@ def test_in_trading_session_weekend_is_false(): def _active(rows): - return {"code": 0, "status": "succeed", "data": rows} + """orders_active_all 的契约 v2 信封(行已规范化:number + 方向/状态枚举)。""" + return {"status": "succeed", "code": "ok", "data": rows, + "error": None, "contract_version": "2"} + + +def _row(eno, qty, filled, state, code="600519", op="买入", price=1700.0, avg=None): + """规范化后的委托行(normalize_active_row 的产物形状)。""" + return {"client_order_id": None, "entrust_no": eno, "证券代码": code, + "证券名称": "贵州茅台", "方向": op, "委托价": price, "委托数量": qty, + "已成数量": filled, "成交均价": avg, "状态": state, "柜台备注": state} def test_build_snapshot_parses_real_headers(): - snap = order_watch.build_snapshot(_active([ - { - "证券代码": "600519", "操作": "买入", "委托数量": "100", - "委托价格": "1700.000", "成交数量": "0", "成交均价": "", - "合同编号": "12345", "备注": "已报", - }, - ])) + snap = order_watch.build_snapshot(_active([_row("12345", 100, 0, "已报")])) assert set(snap) == {"12345"} o = snap["12345"] assert o["stock_no"] == "600519" assert o["op"] == "买入" assert o["order_qty"] == 100 - assert o["order_price"] == "1700.000" + assert o["order_price"] == 1700.0 assert o["filled_qty"] == 0 - assert o["note"] == "已报" + assert o["state"] == "已报" def test_build_snapshot_skips_rows_without_entrust_no(): - snap = order_watch.build_snapshot(_active([{"证券代码": "600519", "合同编号": ""}])) + snap = order_watch.build_snapshot(_active([{"证券代码": "600519", "entrust_no": ""}])) assert snap == {} def test_build_snapshot_empty_on_error_code(): - assert order_watch.build_snapshot({"code": 1, "msg": "读取失败"}) == {} + assert order_watch.build_snapshot( + {"status": "failed", "code": "read_failed", "data": None, + "error": {"class": "read_failed", "broker_msg": None, "message": "读取失败"}, + "contract_version": "2"}) == {} assert order_watch.build_snapshot(None) == {} -def _order(eno, qty, filled, note, code="600519", op="买入", price="1700.000", avg=""): +def _order(eno, qty, filled, state, code="600519", op="买入", price=1700.0, avg=None): return { "entrust_no": eno, "stock_no": code, "op": op, "order_qty": qty, "order_price": price, - "filled_qty": filled, "avg_price": avg, "note": note, + "filled_qty": filled, "avg_price": avg, "state": state, "note": state, } @@ -75,13 +81,13 @@ def test_new_order_emits_placed(): def test_placed_then_partial_then_full(): s0 = {"1": _order("1", 100, 0, "已报")} - s1 = {"1": _order("1", 100, 60, "部成", avg="1699.500")} - s2 = {"1": _order("1", 100, 100, "已成", avg="1699.800")} + s1 = {"1": _order("1", 100, 60, "部成", avg=1699.5)} + s2 = {"1": _order("1", 100, 100, "已成", avg=1699.8)} e1 = order_watch.diff_snapshots(s0, s1, set()) assert [e["event"] for e in e1] == ["partially_filled"] assert e1[0]["filled_qty"] == 60 - assert e1[0]["avg_price"] == "1699.500" + assert e1[0]["avg_price"] == 1699.5 e2 = order_watch.diff_snapshots(s1, s2, set()) assert [e["event"] for e in e2] == ["filled"] @@ -123,7 +129,8 @@ def __init__(self, scripted): self._scripted = list(scripted) # 每次 orders_active 返回下一项 self._i = 0 - async def orders_active(self): + async def orders_active_all(self): + """order_watch 读全量表(含终态)——终态行正是 filled/canceled 事件的来源。""" item = self._scripted[min(self._i, len(self._scripted) - 1)] self._i += 1 return item @@ -139,10 +146,7 @@ async def send_frame(self, frame): def test_first_round_builds_baseline_no_emit(): - backend = WatchFakeBackend([_active([ - {"证券代码": "600519", "操作": "买入", "委托数量": "100", "委托价格": "1700.000", - "成交数量": "0", "成交均价": "", "合同编号": "1", "备注": "已报"}, - ])]) + backend = WatchFakeBackend([_active([_row("1", 100, 0, "已报")])]) client = WatchFakeClient(backend) async def drive(): @@ -156,10 +160,8 @@ async def drive(): def test_second_round_emits_fill_with_seq_and_ts(): - r0 = _active([{"证券代码": "600519", "操作": "买入", "委托数量": "100", "委托价格": "1700.000", - "成交数量": "0", "成交均价": "", "合同编号": "1", "备注": "已报"}]) - r1 = _active([{"证券代码": "600519", "操作": "买入", "委托数量": "100", "委托价格": "1700.000", - "成交数量": "100", "成交均价": "1699.800", "合同编号": "1", "备注": "已成"}]) + r0 = _active([_row("1", 100, 0, "已报")]) + r1 = _active([_row("1", 100, 100, "已成", avg=1699.8)]) backend = WatchFakeBackend([r0, r1]) client = WatchFakeClient(backend) @@ -178,7 +180,10 @@ async def drive(): def test_read_failure_skips_round(): - backend = WatchFakeBackend([{"code": 1, "msg": "验证码弹窗"}]) + backend = WatchFakeBackend([ + {"status": "failed", "code": "read_failed", "data": None, + "error": {"class": "read_failed", "broker_msg": None, "message": "验证码弹窗"}, + "contract_version": "2"}]) client = WatchFakeClient(backend) async def drive(): @@ -214,10 +219,8 @@ def test_next_interval_idle_when_empty(): def test_send_frame_failure_does_not_advance_baseline(): """Regression: if send_frame raises, baseline should not advance; next round retries.""" - r0 = _active([{"证券代码": "600519", "操作": "买入", "委托数量": "100", "委托价格": "1700.000", - "成交数量": "0", "成交均价": "", "合同编号": "1", "备注": "已报"}]) - r1 = _active([{"证券代码": "600519", "操作": "买入", "委托数量": "100", "委托价格": "1700.000", - "成交数量": "100", "成交均价": "1699.800", "合同编号": "1", "备注": "已成"}]) + 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 diff --git a/tests/test_rows_contract.py b/tests/test_rows_contract.py new file mode 100644 index 0000000..aee97ab --- /dev/null +++ b/tests/test_rows_contract.py @@ -0,0 +1,82 @@ +"""行规范化契约(C3 行结构 / B2 时间 / 在飞判据)。""" +from datetime import datetime, timedelta, timezone + +import pytest + +from trader.ths import rows + +TZ8 = timezone(timedelta(hours=8)) + +ACTIVE_RAW = { + "证券代码": "300458", "证券名称": "全志科技", "操作": "买入", + "委托数量": "500", "委托价格": "35.100", "成交数量": "0", + "成交均价": "--", "合同编号": "123456", "备注": "已报", +} + + +def test_active_row_has_pinned_c3_keys(): + row = rows.normalize_active_row(ACTIVE_RAW) + for key in ("client_order_id", "entrust_no", "证券代码", "证券名称", "方向", + "委托价", "委托数量", "已成数量", "状态"): + assert key in row, key + assert row["委托数量"] == 500 # number,不是字符串 + assert row["委托价"] == 35.1 + assert row["成交均价"] is None # "--" → null + assert row["方向"] == "买入" + assert row["状态"] == "已报" + + +def test_client_order_id_joined_from_ledger_else_null(): + """C4:coid 由台账 join,join 不上就是 null(外部单/回查失败的单)。""" + assert rows.normalize_active_row(ACTIVE_RAW, {"123456": "gl-1-7"})["client_order_id"] == "gl-1-7" + assert rows.normalize_active_row(ACTIVE_RAW, {})["client_order_id"] is None + + +@pytest.mark.parametrize("note,expected", [ + ("已报", rows.ST_PLACED), ("未报", rows.ST_PENDING), ("部成", rows.ST_PARTIAL), + ("已成", rows.ST_FILLED), ("已撤", rows.ST_CANCELED), ("废单", rows.ST_REJECTED), + ("场外撤单中", rows.ST_CANCELED), ("", rows.ST_UNKNOWN), ("XJ状态", rows.ST_UNKNOWN), +]) +def test_order_state_classification(note, expected): + assert rows.classify_order_state(note) == expected + + +def test_unknown_state_counts_as_in_flight(): + """未识别态按在飞返回——宁可多给一行,也不能把活单藏起来。""" + assert rows.is_in_flight(rows.ST_UNKNOWN, 500, 0) is True + assert rows.is_in_flight(rows.ST_PLACED, 500, 0) is True + assert rows.is_in_flight(rows.ST_PARTIAL, 500, 100) is True + assert rows.is_in_flight(rows.ST_FILLED, 500, 500) is False + assert rows.is_in_flight(rows.ST_CANCELED, 500, 0) is False + # 数量已满也算终态(柜台备注滞后时的兜底) + assert rows.is_in_flight(rows.ST_PLACED, 500, 500) is False + + +def test_unknown_state_with_null_qty_still_in_flight(): + """数量读不到(null)时绝不能推断成「已完成」。""" + assert rows.is_in_flight(rows.ST_UNKNOWN, None, None) is True + + +def test_filled_time_is_iso_with_local_clock_date(): + now = datetime(2026, 8, 4, 15, 30, tzinfo=TZ8) + row = rows.normalize_filled_row({"成交时间": "10:43:02", "成交数量": "500", + "成交金额": "17,550.00", "成交均价": "35.100", + "操作": "买入", "合同编号": "1"}, now=now) + # B2:日期与时区来自本机时钟(成交表只给 HH:MM:SS) + assert row["成交时间"] == "2026-08-04T10:43:02+08:00" + assert row["成交数量"] == 500 + assert row["成交金额"] == 17550.0 + + +def test_filled_time_unparsable_is_kept_verbatim(): + assert rows.to_iso_time("盘后固定价") == "盘后固定价" + assert rows.to_iso_time("--") is None + + +def test_position_row_types(): + row = rows.normalize_position_row({"证券代码": "600000", "股票余额": "1000", + "可用余额": "1000", "冻结数量": "0", + "参考成本价": "8.100", "市价": "8.230"}) + assert row["股票余额"] == 1000 and isinstance(row["股票余额"], int) + assert row["参考成本价"] == 8.1 + assert row["冻结数量"] == 0 # 真实的 0 仍是 0,只有占位符才是 null diff --git a/tests/test_stale_generation.py b/tests/test_stale_generation.py new file mode 100644 index 0000000..d18318d --- /dev/null +++ b/tests/test_stale_generation.py @@ -0,0 +1,124 @@ +"""调用代次:超时后脱缰的工作线程必须在下一个检查点停手。 + +2026-08-03 串线事故的第二条根因:dispatcher 的 25s 总超时用 asyncio.wait_for 包 +asyncio.to_thread——**线程取消不掉**,它还在发全局按键,而 finally 已放 win_lock +让下一笔进场,两个线程同击一个 xiadan 窗口(页面被切走=抓错表,弹窗被抢=错点)。 +检查点覆盖翻页/抓表/弹窗/提交四类动作。 +""" +import asyncio + +import pytest + +from trader import dispatcher +from trader.ths import win as w +from trader.ths.win import StaleCallAborted, WinThsBackend + + +def _backend(monkeypatch): + b = WinThsBackend() + b.hwnd_main = 1 + monkeypatch.setattr(w, "hot_key", lambda keys: None) + monkeypatch.setattr(w, "_activate_window", lambda hwnd: None) + monkeypatch.setattr(w, "sleep_time", 0) + return b + + +def test_guarded_call_aborts_after_invalidation(monkeypatch): + b = _backend(monkeypatch) + + def work(): + b._abort_if_stale("before") # 本笔仍有效 → 放行 + b.invalidate_inflight("模拟 dispatcher 超时") + b._abort_if_stale("after") # 已被作废 → 必须抛 + return {"code": 0, "status": "succeed"} + + result = b._run_guarded(work) + assert result["status"] == "failed" + assert result["code"] == "aborted" + assert "作废" in result["error"]["message"] + + +@pytest.mark.parametrize("action", [ + lambda b: b.switch_to_normal(), # 翻页链路第一个动作(发全局按键之前) + lambda b: b.refresh(), # F5 + lambda b: b.read_table_text(1), # Ctrl+C 抓表 + lambda b: b.dialog_cleanup(), # degraded 自愈:与下一笔抢同一个弹窗 + lambda b: b._pump_dialogs(), # 提交后的弹窗处置 +]) +def test_every_checkpoint_stops_a_stale_thread(monkeypatch, action): + b = _backend(monkeypatch) + raised = {} + + def work(): + b.invalidate_inflight("模拟超时") + try: + action(b) + except StaleCallAborted as e: + raised["where"] = e.where + raise + return {"code": 0} + + b._run_guarded(work) + assert raised, "检查点没拦住脱缰线程" + + +def test_stale_query_stops_before_regrabbing(monkeypatch): + """抓表期间被判超时 → 不再重抓第二轮,也不再敲一轮翻页键给别人的页面。 + + (switch_to_normal 依赖真 Win32,这里打桩;真正拦住第二轮的是 refresh 里的 + 检查点——每个检查点自身的拦截能力由上面的参数化用例逐个覆盖。) + """ + b = _backend(monkeypatch) + reads = [] + monkeypatch.setattr(b, "switch_to_normal", lambda: None) + monkeypatch.setattr(b, "get_right_hwnd", lambda: 999) + monkeypatch.setattr(b, "_find_grid", lambda hwnd: 888) + + def read(ctrl): + reads.append(ctrl) + b.invalidate_inflight("抓表期间超时") # 模拟 dispatcher 此刻判超时放锁 + return "成交时间\t证券代码\t成交金额\t\r\n" # 且抓到的还是别人的表 → 本会触发重抓 + + monkeypatch.setattr(b, "read_table_text", read) + + r = b.get_active_orders() + assert r["status"] == "failed" + assert len(reads) == 1, "本笔已作废,绝不能再抓第二轮" + + +def test_unmanaged_thread_is_not_blocked(monkeypatch): + """UI 直调 / 单测直调不带代次,作废动作不能把它们一起打死。""" + b = _backend(monkeypatch) + b.invalidate_inflight("与本线程无关") + b._abort_if_stale("ui") # 不抛即通过 + + +class RecordingBackend: + """dispatcher 侧替身:只关心超时时有没有作废在飞线程。""" + + def __init__(self): + self.win_lock = asyncio.Lock() + self.agent_entrust_nos: set[str] = set() + self.degraded = False + self.invalidated: list[str] = [] + + async def orders_active(self): + await asyncio.sleep(3600) + + def dialog_cleanup(self): + pass + + def invalidate_inflight(self, reason=""): + self.invalidated.append(reason) + return len(self.invalidated) + + +def test_dispatcher_invalidates_inflight_on_timeout(monkeypatch): + monkeypatch.setattr(dispatcher, "CALL_TIMEOUT_SECS", 0.05) + backend = RecordingBackend() + frame = {"type": "call", "id": "t1", "method": "orders_active", "params": {}} + reply = asyncio.run(dispatcher.handle_call(frame, backend)) + assert reply["ok"] is False + assert backend.invalidated, "超时后必须作废在飞线程,否则它继续操作窗口" + assert "orders_active" in backend.invalidated[0] + assert backend.degraded is True diff --git a/tests/test_switch_account.py b/tests/test_switch_account.py index 7979932..3aa6e03 100644 --- a/tests/test_switch_account.py +++ b/tests/test_switch_account.py @@ -6,6 +6,8 @@ """ import asyncio +from trader import contract + from trader.ths.win import WinThsBackend @@ -16,15 +18,15 @@ def _switch(slot): def test_rejects_non_integer_slot(): for bad in (None, "abc", [1], {}): result = _switch(bad) - assert result["code"] == 1 - assert "slot 参数无效" in result["msg"] + assert result["code"] == "invalid_params" + assert "slot 参数无效" in result["error"]["message"] def test_rejects_out_of_range_slot(): for bad in (0, -1, 10, 99): result = _switch(bad) - assert result["code"] == 1 - assert "slot 超出范围" in result["msg"] + assert result["code"] == "invalid_params" + assert "slot 超出范围" in result["error"]["message"] def test_valid_slot_passes_gate_and_reaches_bind(monkeypatch): @@ -44,8 +46,8 @@ def test_coerced_int_slot_forwarded_to_do_switch(monkeypatch): seen = [] monkeypatch.setattr( backend, "do_switch_account", - lambda slot: (seen.append(slot) or {"code": 0, "data": {"slot": slot}}), + lambda slot: (seen.append(slot) or contract.ok({"slot": slot})), ) result = asyncio.run(backend.switch_account("2")) - assert result["code"] == 0 + assert result["status"] == "succeed" assert seen == [2] diff --git a/tests/test_table_guard.py b/tests/test_table_guard.py new file mode 100644 index 0000000..6da0027 --- /dev/null +++ b/tests/test_table_guard.py @@ -0,0 +1,50 @@ +"""表头归属校验(table_guard)——2026-08-03 查询串线事故的判据回归。 + +事故形态:请求 A 收到表 B,且 status=succeed。这里锁定判据本身:真表放行、 +他表拒收、空表头拒收。表头逐字取自事故当天 alerts.log 记录的实得键集。 +""" +from trader.ths.table_guard import check_table + +POSITION_COLS = ["操作", "证券代码", "证券名称", "股票余额", "可用余额", + "冻结数量", "参考成本价", "市价"] +ACTIVE_COLS = ["证券代码", "操作", "委托数量", "委托价格", "成交数量", + "成交均价", "合同编号", "备注"] +FILLED_COLS = ["成交时间", "证券代码", "证券名称", "操作", "成交数量", + "成交均价", "成交金额", "合同编号"] +SETTLEMENT_COLS = ["成交日期", "证券代码", "证券名称", "操作", "成交数量", + "成交均价", "成交金额", "发生金额", "手续费", "印花税"] + + +def test_each_table_accepts_itself(): + assert check_table("position", POSITION_COLS) is None + assert check_table("active_orders", ACTIVE_COLS) is None + assert check_table("filled_orders", FILLED_COLS) is None + assert check_table("settlement", SETTLEMENT_COLS) is None + + +def test_rejects_the_two_tables_seen_in_the_incident(): + """08-03 实得:请求方要资金/持仓,拿到成交明细表或持仓表。""" + assert check_table("position", FILLED_COLS) # 要持仓拿到成交表 + assert check_table("active_orders", FILLED_COLS) # 最险:错表=「无挂单」 + assert check_table("filled_orders", POSITION_COLS) + assert check_table("active_orders", POSITION_COLS) + + +def test_reject_reason_names_the_foreign_columns(): + reason = check_table("active_orders", FILLED_COLS) + assert "他表特征列" in reason + assert "成交时间" in reason + + +def test_settlement_not_accepted_as_filled_orders(): + """交割单与成交表共享「成交编号」——靠交割单独有列区分,不能混过。""" + assert check_table("filled_orders", SETTLEMENT_COLS) + + +def test_empty_header_rejected(): + assert check_table("position", []) == "表头为空" + + +def test_unknown_kind_passes_through(): + """本函数只管它登记过的表,不当通用闸门(如自选股 OCR 结果)。""" + assert check_table("watchlist", ["随便什么"]) is None diff --git a/tests/test_ws_reply_routing.py b/tests/test_ws_reply_routing.py new file mode 100644 index 0000000..62d996f --- /dev/null +++ b/tests/test_ws_reply_routing.py @@ -0,0 +1,67 @@ +"""回执归属:执行期间断线重连的话,旧回执必须丢弃而不是发到新连接上。 + +一笔 RPC 最长跑 25s,其间完全可能重连。旧回执的 id 属于旧会话,发到新连接上 +归属无从保证(能否被网关配到别的请求头上取决于对端 id 策略,不能靠对端兜底)。 +""" +import asyncio +import json + +from trader import ws_client + + +class FakeWs: + def __init__(self, name): + self.name = name + self.sent: list[dict] = [] + + async def send(self, raw): + self.sent.append(json.loads(raw)) + + +class Backend: + 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": []} + + +async def _drive(reconnect: bool): + backend = Backend() + client = ws_client.WsClient(backend=backend) + old = FakeWs("old") + client.ws = old + + frame = {"type": "call", "id": "rpc-1", "method": "orders_active", "params": {}} + await client._handle_frame(frame, old) + await asyncio.wait_for(backend.started.wait(), timeout=1.0) + + new = FakeWs("new") + if reconnect: + client.ws = new # 执行期间断线重连 + backend.release.set() + for _ in range(100): # 等后台 task 收尾 + if old.sent or new.sent: + break + await asyncio.sleep(0.01) + await asyncio.sleep(0.02) + return old, new + + +def test_reply_dropped_when_connection_changed(): + old, new = asyncio.run(_drive(reconnect=True)) + assert new.sent == [], "旧会话的回执绝不能发到新连接上" + assert old.sent == [] + + +def test_reply_sent_normally_on_same_connection(): + old, new = asyncio.run(_drive(reconnect=False)) + assert len(old.sent) == 1 + assert old.sent[0]["id"] == "rpc-1" + assert old.sent[0]["ok"] is True