From 3b36f22aa3c0a76ed166999399cccc64148bc386 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 06:02:43 +0000 Subject: [PATCH] =?UTF-8?q?feat!:=20qmtcli=20=E6=94=B9=E4=B8=BA=E5=8F=AA?= =?UTF-8?q?=E8=AF=BB=EF=BC=8C=E5=8E=BB=E6=8E=89=E4=B8=8B=E5=8D=95=E4=B8=8E?= =?UTF-8?q?=E6=92=A4=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent 能直接用 qmtcli buy/sell 下单:2026-10-19 OMP 没有逐笔授权就挂过一笔 000963 测试卖单(已撤,未成交)。下单要走带逐笔人工确认的网关,不在这个工具里。 - 删除 buy/sell/cancel(CLI、rpc、server、capabilities、examples)和 QMTGateway.submit_order/cancel_order。 - trade-call 只放行 query_* / smt_query_*;order_stock、cancel_order_stock、 fund_transfer 等改变账户的方法一律拒绝(READ_ONLY_TRADER_PREFIXES)。 - MCP 去掉已不存在的 places_order/cancels_order 排除项。 - README、对齐矩阵、AGENTS.md、CHANGELOG 同步。 Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_011r885P59QAG4ZeFjXF7W4G --- AGENTS.md | 6 ++- CHANGELOG.md | 4 ++ README.en.md | 40 ++++++++-------- README.md | 32 ++++++------- docs/xttrader-alignment.md | 14 +++--- examples/agent_tool.md | 8 ++-- examples/order.json | 9 ---- src/qmtcli/cli.py | 90 ++++++++---------------------------- src/qmtcli/gateway.py | 40 +++++----------- src/qmtcli/mcp_server.py | 18 ++------ tests/test_agent_protocol.py | 12 ++--- tests/test_boundaries.py | 57 ++++++++++++++++++++++- tests/test_cli_help.py | 2 +- tests/test_mcp_server.py | 10 +--- tests/test_qmt_gateway.py | 30 ------------ 15 files changed, 150 insertions(+), 222 deletions(-) delete mode 100644 examples/order.json diff --git a/AGENTS.md b/AGENTS.md index b30079a..6752ec8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -42,8 +42,10 @@ Use `uv run ...` unless the package is installed into the active global Python. - Do not store credentials or account secrets. - Do not auto-download, vendor, or install broker QMT. - Do not place real orders in tests. -- Keep `data-call` and `trade-call` blocking `_`-prefixed methods. -- Keep order validation: positive price and positive A-share volume multiple of 100. +- qmtcli is read-only: do not add order placement, cancellation, or fund transfer (no `buy`/`sell`/ + `cancel`). Orders go through a gateway with per-order human confirmation outside this tool. +- Keep `data-call` blocking `_`-prefixed methods and `trade-call` limited to `query_*` / + `smt_query_*` (`READ_ONLY_TRADER_PREFIXES` in `gateway.py`). - Keep `server` as stdin/stdout JSONL, not TCP/HTTP. ## Common Agent Tasks diff --git a/CHANGELOG.md b/CHANGELOG.md index bdbf168..a51ab36 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ begin. ## Unreleased +- **Breaking:** qmtcli is now read-only. Removed `buy`, `sell`, and `cancel` from the CLI, `rpc`, + `server`, and `capabilities`, and `trade-call` now only accepts `query_*` / `smt_query_*` + methods. An agent could place an order through these without per-order approval; orders now go + through a gateway with per-order human confirmation outside this tool. - Removed a host-dependent offline test that could discover a real QMT SDK and initialize its trader; deterministic fake-SDK and error-envelope coverage remains. - Updated locked urllib3 to 2.8.0 and certifi to 2026.10.19 for transport security diff --git a/README.en.md b/README.en.md index 8bd8528..5f52152 100644 --- a/README.en.md +++ b/README.en.md @@ -24,7 +24,7 @@ handling NaN, and guessing which field encoding to trust are your problem too. `qmtcli` exists to do that work once: it starts a small local process, takes JSON in, hands JSON back, and talks to the QMT client you already have logged in. Everything the official QMT/XtQuant -API exposes — diagnostics, market data, account queries, and guarded stock order placement (see the +API exposes — diagnostics, market data, and account queries (see the official [QMT beginner guide](https://dict.thinktrader.net/freshman/rookie.html)) — is reachable through this one stable stdin/stdout JSON contract. @@ -37,8 +37,8 @@ copy, see [Install](#install)). QMT must already be installed and logged in loca ## Who This Is For -- Anyone who wants an LLM agent (Claude, GPT, a local model) to check quotes, read positions, or - place guarded orders directly — `qmtcli mcp` wires it into MCP in one command, with tools +- Anyone who wants an LLM agent (Claude, GPT, a local model) to check quotes or read positions + directly — `qmtcli mcp` wires it into MCP in one command, with tools generated from the capability list instead of a hand-maintained second registry. - Teams building a quant system in Rust, Go, or Node.js who don't want to stand up a separate Python sidecar just to talk to QMT. @@ -95,7 +95,7 @@ contract instead: - `capabilities`, `schema`, and `examples` for discovery; - `rpc` for one request over stdin/stdout; - `server` for newline-delimited JSON requests; -- explicit labels for read-only calls, escape hatches, order placement, and cancellation; +- explicit labels for read-only calls and escape hatches; - tests that fake `xtquant`, so CI does not need a broker install. Any runtime that can start a local process and exchange JSON can use it — Python, Rust, Go, @@ -127,9 +127,10 @@ Node.js, shell scripts, or an LLM agent, no language-specific SDK or binding req - Trader query commands: position statistics, credit/margin queries, IPO quota and data, account infos/status, and ordinary-account fund/position queries — see [Trade Query Commands](#trade-query-commands). -- Fixed-price A-share `buy`, `sell`, and `cancel`. +- **Read-only**: `qmtcli` never places or cancels orders or moves funds. Orders go through a gateway + with per-order human confirmation, outside this tool. - Generic `data-call` for public `xtquant.xtdata` methods. -- Generic `trade-call` for public `XtQuantTrader` methods. +- Generic `trade-call` for `XtQuantTrader` query methods (`query_*` / `smt_query_*`). - Optional standalone xtdatacenter (xtdc) data mode via `--xtdc-token` (experimental) — see [Standalone Data Mode](#standalone-data-mode-xtdc-experimental). - Agent/script protocol commands: `capabilities`, `schema`, `examples`, `rpc`, `server`. @@ -276,9 +277,8 @@ Tool names are `qmt_` with dashes replaced by underscores, for example `qm CLI has — every trade-query/account tool takes optional `path`/`account`/`account_type` arguments per call instead. -Excluded by design: `buy`, `sell`, `cancel` (order placement/cancellation) and `trade_call` (an -unguarded escape hatch onto the full `XtQuantTrader` surface) — use the CLI directly for guarded -trading. `watch` and the `subscribe`/`subscribe_whole`/`unsubscribe` trio are also excluded (CLI- +Excluded by design: `trade_call` (an escape hatch onto caller-selected `XtQuantTrader` query +methods; the named queries cover agent use). `watch` and the `subscribe`/`subscribe_whole`/`unsubscribe` trio are also excluded (CLI- only / server-only streaming, not a fit for one-shot tool calls). Everything else — `status`, `doctor`, `data_call`, `fields`, `download`, every named data command, and every trade query (`asset`, `positions`, `orders`, `trades`, ...) — is available. @@ -383,7 +383,7 @@ qmtcli sector reset MySector 600519.SH qmtcli sector remove MySector ``` -Account and order commands require `--account`: +Account commands require `--account`: ```powershell qmtcli --account ACCOUNT_ID asset @@ -391,9 +391,6 @@ qmtcli --account ACCOUNT_ID positions qmtcli --account ACCOUNT_ID orders qmtcli --account ACCOUNT_ID orders --cancelable-only qmtcli --account ACCOUNT_ID trades -qmtcli --account ACCOUNT_ID buy 600519.SH 100 1500.00 -qmtcli --account ACCOUNT_ID sell 600519.SH 100 1500.00 -qmtcli --account ACCOUNT_ID cancel ORDER_ID ``` ## Trade Query Commands @@ -433,13 +430,15 @@ qmtcli data-call get_stock_list_in_sector --args "[\"沪深A股\"]" qmtcli data-call get_cb_info --args "[\"123001.SZ\"]" ``` -`trade-call` calls any public method on `XtQuantTrader` after connecting the account. By default, +`trade-call` calls an `XtQuantTrader` query method after connecting the account; only `query_*` and +`smt_query_*` are allowed, and account-changing methods such as `order_stock`, +`cancel_order_stock`, and `fund_transfer` are refused. By default, the `StockAccount` object is prepended to positional arguments: ```powershell qmtcli --account ACCOUNT_ID trade-call query_stock_orders -qmtcli --account ACCOUNT_ID trade-call some_method --args "[1,2]" --kwargs "{\"flag\":true}" -qmtcli --account ACCOUNT_ID trade-call method_without_account --no-account +qmtcli --account ACCOUNT_ID trade-call query_stock_order --args "[12345]" +qmtcli --account ACCOUNT_ID trade-call query_account_infos --no-account ``` Private method names beginning with `_` are blocked. @@ -469,7 +468,7 @@ yet wired to a specific xtdatacenter call. `qmtcli doctor` reports whether the ```json {"command":"status"} {"command":"data_call","method":"get_stock_list_in_sector","args":["沪深A股"]} -{"command":"buy","account":"ACCOUNT_ID","symbol":"600519.SH","volume":100,"price":1500.0} +{"command":"positions","account":"ACCOUNT_ID"} ``` For JSONL `server`, one input line produces one output line. Request `id` is echoed. @@ -479,12 +478,11 @@ For JSONL `server`, one input line produces one output line. Request `id` is ech - Local only: `server` reads stdin and writes stdout; it does not open a socket. - No broker software download or auto-install. - No credential storage. -- No order retry loop. -- `capabilities` marks order placement and cancel actions as dangerous, marks `download` as +- No order placement, cancellation, or fund transfer: there is no `buy`/`sell`/`cancel`, and + `trade-call` only allows query methods. +- `capabilities` marks `download` as `downloads_data` since it writes into the local QMT cache, and marks `sector` as `mutates_local_data` since it creates/modifies local custom sector definitions. -- A-share order volume must be a positive multiple of 100. -- Order price must be positive. - Private `xtdata` / `XtQuantTrader` method names are blocked. - QMT account permissions, risk checks, and final execution remain controlled by QMT and the broker. diff --git a/README.md b/README.md index a86bec9..f4b050e 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ 给 QMT / XtQuant 装一层稳定的接口,让任何语言、任何 Agent 都能安全稳定的调用它。 -- **一句话**:让 AI 用 JSON 跟 QMT 聊 A 股——行情账户随便查,下单有护栏。 +- **一句话**:让 AI 用 JSON 跟 QMT 聊 A 股——行情账户随便查,不下单。 - **适合谁**:想把行情、账户、交易能力接进自己的脚本或 Agent,又不想再啃 xtquant 的人。 - **不适合谁**:不碰 A 股程序化交易,或者只需要一个现成看盘软件的人。 @@ -38,7 +38,7 @@ QMT 自带的那份,见[安装](#安装))。使用前需要先在本机安 ## 适合谁 -- 想让 LLM Agent(Claude、GPT、本地模型)直接查行情、看持仓、下单的人——`qmtcli ` 一条 +- 想让 LLM Agent(Claude、GPT、本地模型)直接查行情、看持仓的人——`qmtcli ` 一条 命令接进 ,工具从能力清单自动生成,不用手写第二套注册表。 - 想用 Rust、Go、Node.js 写量化系统,又不想专门为了调 QMT 起一个 Python 服务的人。 - 想要一份稳定的 JSON 链接,而不是每次券商升级 QMT 就得重新猜 `xtquant` 参数和字段变化的人。 @@ -91,7 +91,7 @@ QMT 把 `xtquant` 放在券商客户端安装目录里。直接在交易机上 - `capabilities`、`schema`、`examples` 用于能力发现; - `rpc` 用于 stdin/stdout 单请求; - `server` 用于逐行 JSON 请求; -- 对只读调用、escape hatch、下单、撤单做明确标记; +- 对只读调用、escape hatch 做明确标记; - 测试里 fake `xtquant`,CI 不需要真实券商环境。 只要一个运行时能启动本地进程并交换 JSON,就能使用它——Python、Rust、Go、Node.js、Shell 脚本, @@ -121,9 +121,9 @@ QMT 把 `xtquant` 放在券商客户端安装目录里。直接在交易机上 - 账户查询:资产、持仓、委托(支持 `--cancelable-only`)、成交。 - 交易查询命令:持仓统计、信用/融资融券查询、新股额度与数据、账号信息/状态、普通柜台资金/持仓 查询——见[交易查询命令](#交易查询命令)。 -- 固定价 A 股 `buy`、`sell`、`cancel`。 +- **只读**:`qmtcli` 不下单、不撤单、不划资金。下单走带逐笔人工确认的网关,不在这个工具里。 - `data-call` 调用公开的 `xtquant.xtdata` 方法。 -- `trade-call` 调用公开的 `XtQuantTrader` 方法。 +- `trade-call` 调用 `XtQuantTrader` 的查询方法(`query_*` / `smt_query_*`)。 - 可选的独立 xtdatacenter(xtdc)数据模式,通过 `--xtdc-token` 开启(实验性)——见 [独立数据模式](#独立数据模式xtdc实验性)。 - Agent / 脚本协议命令:`capabilities`、`schema`、`examples`、`rpc`、`server`。 @@ -264,8 +264,8 @@ claude mcp add qmt -- uv run --directory D:/projects/qmtcli --extra mcp qmtcli m `qmt_account_infos`。这里没有像 CLI 那样常驻的 `--account`/`--path`——每个交易查询/账户类工具 都在每次调用时接受可选的 `path`/`account`/`account_type` 参数。 -以下按设计被排除:`buy`、`sell`、`cancel`(下单/撤单)和 `trade_call`(直接暴露完整 -`XtQuantTrader` 方法面的非受控 escape hatch)——需要下单交易请直接使用 CLI。`watch` 以及 +以下按设计被排除:`trade_call`(由调用方任选 `XtQuantTrader` 查询方法的 escape hatch, +具名查询已覆盖 Agent 的用途)。`watch` 以及 `subscribe`/`subscribe_whole`/`unsubscribe` 三件套也被排除(分别是仅 CLI、仅 server 的流式推送, 不适合一次性的工具调用)。其余的——`status`、`doctor`、`data_call`、`fields`、`download`、所有 行情命令,以及所有交易查询(`asset`、`positions`、`orders`、`trades` 等)——都可以使用。 @@ -377,9 +377,6 @@ qmtcli --account ACCOUNT_ID positions qmtcli --account ACCOUNT_ID orders qmtcli --account ACCOUNT_ID orders --cancelable-only qmtcli --account ACCOUNT_ID trades -qmtcli --account ACCOUNT_ID buy 600519.SH 100 1500.00 -qmtcli --account ACCOUNT_ID sell 600519.SH 100 1500.00 -qmtcli --account ACCOUNT_ID cancel ORDER_ID ``` ## 交易查询命令 @@ -417,13 +414,14 @@ qmtcli data-call get_stock_list_in_sector --args "[\"沪深A股\"]" qmtcli data-call get_cb_info --args "[\"123001.SZ\"]" ``` -`trade-call` 在连接账户后调用公开的 `XtQuantTrader` 方法。默认会把 `StockAccount` 对象 +`trade-call` 在连接账户后调用 `XtQuantTrader` 的查询方法,只放行 `query_*` 和 `smt_query_*`; +`order_stock`、`cancel_order_stock`、`fund_transfer` 等会改变账户的方法一律拒绝。默认会把 `StockAccount` 对象 放在位置参数最前面: ```powershell qmtcli --account ACCOUNT_ID trade-call query_stock_orders -qmtcli --account ACCOUNT_ID trade-call some_method --args "[1,2]" --kwargs "{\"flag\":true}" -qmtcli --account ACCOUNT_ID trade-call method_without_account --no-account +qmtcli --account ACCOUNT_ID trade-call query_stock_order --args "[12345]" +qmtcli --account ACCOUNT_ID trade-call query_account_infos --no-account ``` `_` 开头的私有方法名会被阻止。 @@ -451,7 +449,7 @@ xtdatacenter 调用上。`qmtcli doctor` 会用 `xtdc_available` 字段报告 `x ```json {"command":"status"} {"command":"data_call","method":"get_stock_list_in_sector","args":["沪深A股"]} -{"command":"buy","account":"ACCOUNT_ID","symbol":"600519.SH","volume":100,"price":1500.0} +{"command":"positions","account":"ACCOUNT_ID"} ``` `server` 模式下一行输入对应一行输出。请求里的 `id` 会被带回。 @@ -461,11 +459,9 @@ xtdatacenter 调用上。`qmtcli doctor` 会用 `xtdc_available` 字段报告 `x - 本地使用:`server` 只读 stdin、写 stdout,不打开 socket。 - 不下载或自动安装券商软件。 - 不保存账号密码或登录凭据。 -- 不自动重试下单。 -- `capabilities` 会标记下单和撤单能力为危险动作;`download` 因为写入本地 QMT 缓存,被标记为 +- 不下单、不撤单、不划资金:没有 `buy`/`sell`/`cancel`,`trade-call` 只放行查询方法。 +- `capabilities` 中 `download` 因为写入本地 QMT 缓存,被标记为 `downloads_data`;`sector` 因为创建/修改本地自定义板块,被标记为 `mutates_local_data`。 -- A 股委托数量必须是正的 100 倍数。 -- 委托价格必须大于 0。 - 阻止调用 `_` 开头的 `xtdata` / `XtQuantTrader` 方法。 - 账户权限、风控检查和最终成交仍由 QMT 和券商控制。 diff --git a/docs/xttrader-alignment.md b/docs/xttrader-alignment.md index fa47e84..eb390d7 100644 --- a/docs/xttrader-alignment.md +++ b/docs/xttrader-alignment.md @@ -6,17 +6,17 @@ Checked: 2026-07-07, against `xtquant` 250516.1.1. This table maps every function documented on the xttrader doc page to its `qmtcli` CLI surface. Where a function is not exposed as a named command, it can still be called through the generic -`trade-call` (read/query or mutating) escape hatch, at the caller's own risk — no argument -validation beyond blocking private (`_`-prefixed) method names. +`trade-call` escape hatch — but only query methods (`query_*` / `smt_query_*`). qmtcli is read-only: +every order, cancel, and fund-transfer method is refused, listed below as "refused". | XtQuantTrader function | doc section (Chinese) | CLI command | notes | | --- | --- | --- | --- | | `XtQuantTrader`, `register_callback`, `start`, `connect`, `stop`, `run_forever`, `set_relaxed_response_order_enabled` | 创建API实例 / 注册回调类 / 准备API环境 / 创建连接 / 停止运行 / 阻塞当前线程进入等待状态 / 开启主动请求接口的专用线程 | not exposed | internal gateway lifecycle; `QMTGateway.connect()` creates the `XtQuantTrader` instance and calls `start()`/`connect()` itself, once per CLI invocation | | `subscribe`, `unsubscribe` | 订阅账号信息 / 反订阅账号信息 | internal, auto on connect | `QMTGateway.connect()` calls `trader.subscribe(account)` automatically whenever an account id is present (skipped for the account-less queries below); not a standalone command | -| `order_stock` | 股票同步报单 | `buy` / `sell` | fixed-price A-share orders | -| `order_stock_async`, `cancel_order_stock_async`, `cancel_order_stock_sysid`, `cancel_order_stock_sysid_async` | 股票异步报单 / 股票异步撤单 / 股票同步撤单(按 sysid) / 股票异步撤单(按 sysid) | `trade-call` | escape hatch only; the async variants are unsuitable for a one-shot CLI process, and the sysid-keyed cancel is an alternate identifier scheme not wired to the named `cancel` command | -| `cancel_order_stock` | 股票同步撤单 | `cancel` | | -| `fund_transfer`, `sync_transaction_from_external`, `smt_negotiate_order_async` | 资金划拨 / 外部交易数据录入 / 库存券约券申请 | `trade-call` | mutating; at the caller's own risk, no extra validation beyond blocking `_`-prefixed names | +| `order_stock` | 股票同步报单 | refused | qmtcli never places orders | +| `order_stock_async`, `cancel_order_stock_async`, `cancel_order_stock_sysid`, `cancel_order_stock_sysid_async` | 股票异步报单 / 股票异步撤单 / 股票同步撤单(按 sysid) / 股票异步撤单(按 sysid) | refused | qmtcli never places or cancels orders | +| `cancel_order_stock` | 股票同步撤单 | refused | qmtcli never cancels orders | +| `fund_transfer`, `sync_transaction_from_external`, `smt_negotiate_order_async` | 资金划拨 / 外部交易数据录入 / 库存券约券申请 | refused | mutating; not a query method | | `query_stock_asset` | 资产查询 | `asset` | | | `query_stock_orders` | 委托查询 | `orders` (`--cancelable-only`) | `cancelable_only` defaults to false, matching the SDK default | | `query_stock_trades` | 成交查询 | `trades` | | @@ -33,7 +33,7 @@ validation beyond blocking private (`_`-prefixed) method names. | `query_account_status` | 账号状态查询 | `account-status` | connects to QMT session; no account id needed — the method itself takes no account argument | | `query_com_fund` | 普通柜台资金查询 | `com-fund` | | | `query_com_position` | 普通柜台持仓查询 | `com-position` | | -| `export_data`, `query_data` | 通用数据导出 / 通用数据查询 | `trade-call` | generic export/query surface; not individually wired to a named command | +| `export_data`, `query_data` | 通用数据导出 / 通用数据查询 | `trade-call` (`query_data` only) | `export_data` writes files and is refused; `query_data` is reachable as a query method | | `smt_query_quoter`, `smt_query_compact` | 券源行情查询 / 约券合约查询 | `trade-call` | read-only securities-lending (约券) queries; documented on the doc page but not independently wired to a named command in this wave | | `on_disconnected`, `on_account_status`, `on_stock_order`, `on_stock_trade`, `on_order_error`, `on_cancel_error`, `on_order_stock_async_response`, `on_smt_appointment_async_response` | 连接状态回调 / 账号状态信息推送 / 委托信息推送 / 成交信息推送 / 下单失败信息推送 / 撤单失败信息推送 / 异步下单回报推送 / 约券相关异步接口的回报推送 | not exposed | trader push callbacks delivered through `register_callback`; server mode's streaming support (`subscribe`/`subscribe_whole`/`unsubscribe`) covers `xtdata` quote subscriptions only, not these `XtQuantTrader` callbacks | diff --git a/examples/agent_tool.md b/examples/agent_tool.md index 38bd308..fb254f7 100644 --- a/examples/agent_tool.md +++ b/examples/agent_tool.md @@ -54,10 +54,10 @@ Send one request per line: Treat these as requiring explicit user approval in any agent policy: -- `buy` - places an order -- `sell` - places an order -- `cancel` - cancels an order -- `trade_call` - arbitrary public `XtQuantTrader` method +- `trade_call` - caller-selected `XtQuantTrader` query method (`query_*` / `smt_query_*` only) + +qmtcli has no order placement or cancellation. Orders go through a gateway with per-order human +confirmation, outside this tool. `data_call` is an escape hatch for public `xtquant.xtdata` methods. It is usually read-only, but agents should still inspect the requested method before calling it. diff --git a/examples/order.json b/examples/order.json deleted file mode 100644 index 55c9218..0000000 --- a/examples/order.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "command": "buy", - "path": "D:\\DFZQxtqmt_client_real_win64\\userdata_mini", - "account": "ACCOUNT_ID", - "account_type": "STOCK", - "symbol": "600519.SH", - "volume": 100, - "price": 1500.0 -} diff --git a/src/qmtcli/cli.py b/src/qmtcli/cli.py index bed979f..389578d 100644 --- a/src/qmtcli/cli.py +++ b/src/qmtcli/cli.py @@ -122,6 +122,10 @@ def _dispatch_fields_command(kind: str | None) -> Any: AGENT_CAPABILITIES: dict[str, Any] = { "protocol": "qmtcli.agent.v1", + "orders": ( + "qmtcli is read-only: it never submits or cancels broker orders. Orders go through a " + "gateway with per-order human confirmation outside this tool." + ), "transports": ["cli", "rpc", "server", "mcp"], "commands": [ { @@ -538,31 +542,10 @@ def _dispatch_fields_command(kind: str | None) -> Any: "danger": "safe", "note": "connects to QMT session; no account id needed", }, - { - "name": "buy", - "description": "Submit a fixed-price stock buy order.", - "requires_account": True, - "danger": "places_order", - "inputs": {"symbol": "required", "volume": "positive multiple of 100", "price": "positive"}, - }, - { - "name": "sell", - "description": "Submit a fixed-price stock sell order.", - "requires_account": True, - "danger": "places_order", - "inputs": {"symbol": "required", "volume": "positive multiple of 100", "price": "positive"}, - }, - { - "name": "cancel", - "description": "Cancel an order by QMT order id.", - "requires_account": True, - "danger": "cancels_order", - "inputs": {"order_id": "required"}, - }, { "name": "trade_call", "cli_command": "trade-call", - "description": "Call a public XtQuantTrader method.", + "description": "Call a public read-only XtQuantTrader query method (query_* / smt_query_*).", "requires_account": True, "danger": "escape_hatch", "inputs": { @@ -638,8 +621,7 @@ def _dispatch_fields_command(kind: str | None) -> Any: "danger": "safe", "transports": ["cli"], "note": ( - "stdio MCP server; excludes order placement/cancel and trade_call by design -- " - "use the CLI for guarded trading. Tools are generated from this capability list " + "stdio MCP server; excludes trade_call by design. Tools are generated from this capability list " "(qmt_, dashes become underscores) and every tool call is dispatched " "through the same _handle_rpc_request machinery as rpc/server, so there is no " "second command registry. Requires the optional mcp extra: pip install " @@ -651,6 +633,10 @@ def _dispatch_fields_command(kind: str | None) -> Any: AGENT_SCHEMA: dict[str, Any] = { "protocol": "qmtcli.agent.v1", + "orders": ( + "qmtcli is read-only: it never submits or cancels broker orders. Orders go through a " + "gateway with per-order human confirmation outside this tool." + ), "request": { "id": "optional string or number; echoed when present", "command": "required string", @@ -683,8 +669,8 @@ def _dispatch_fields_command(kind: str | None) -> Any: "qmt_sector_stocks, full-tick -> qmt_full_tick" ), "exclusion_policy": ( - "Excludes danger places_order/cancels_order (buy, sell, cancel), trade_call (an " - "unguarded escape hatch onto the full XtQuantTrader surface), and any capability " + "Excludes trade_call (an escape hatch onto caller-selected XtQuantTrader query " + "methods), and any capability " "restricted to a transports list that does not include rpc (the " "subscribe/subscribe_whole/unsubscribe trio, server-only; watch, CLI-only). Every " "other command -- status, doctor, data_call, fields, download, all named data " @@ -708,8 +694,6 @@ def _dispatch_fields_command(kind: str | None) -> Any: "escape_hatch": "calls a public SDK method selected by the caller", "downloads_data": "downloads data into the local QMT cache", "mutates_local_data": "creates or modifies local custom sector definitions", - "places_order": "submits a broker order", - "cancels_order": "cancels a broker order", }, } @@ -750,15 +734,6 @@ def _dispatch_fields_command(kind: str | None) -> Any: "command": "ipo_data", "path": "D:\\DFZQxtqmt_client_real_win64\\userdata_mini", }, - "buy": { - "command": "buy", - "path": "D:\\DFZQxtqmt_client_real_win64\\userdata_mini", - "account": "ACCOUNT_ID", - "account_type": "STOCK", - "symbol": "600519.SH", - "volume": 100, - "price": 1500.0, - }, } @@ -1239,31 +1214,19 @@ def build_parser() -> argparse.ArgumentParser: trade_call = subparsers.add_parser( "trade-call", - help="call an arbitrary public XtQuantTrader method", + help="call a read-only XtQuantTrader query method", description=( - "Call an arbitrary public XtQuantTrader method after connecting --account.\n" + "Call a read-only XtQuantTrader query method (query_* / smt_query_*) after connecting\n" + "--account. Order placement/cancellation methods are refused: qmtcli never trades.\n" "By default the StockAccount object is prepended to args; pass --no-account to skip it.\n" "Private methods beginning with '_' are blocked." ), ) - trade_call.add_argument("method", help="Public XtQuantTrader method name") + trade_call.add_argument("method", help="XtQuantTrader query method name") trade_call.add_argument("--args", default="[]", help="JSON positional args array; default: []") trade_call.add_argument("--kwargs", default="{}", help="JSON keyword args object; default: {}") trade_call.add_argument("--no-account", action="store_true", help="Do not prepend StockAccount") - buy = subparsers.add_parser("buy", help="submit a fixed-price stock buy order") - buy.add_argument("symbol", help="Symbol such as 600519.SH") - buy.add_argument("volume", type=int, help="A-share volume; must be a positive multiple of 100") - buy.add_argument("price", type=float, help="Limit price; must be positive") - - sell = subparsers.add_parser("sell", help="submit a fixed-price stock sell order") - sell.add_argument("symbol", help="Symbol such as 600519.SH") - sell.add_argument("volume", type=int, help="A-share volume; must be a positive multiple of 100") - sell.add_argument("price", type=float, help="Limit price; must be positive") - - cancel = subparsers.add_parser("cancel", help="cancel an order by QMT order id") - cancel.add_argument("order_id", help="QMT order id") - watch = subparsers.add_parser( "watch", help=( @@ -1294,8 +1257,8 @@ def build_parser() -> argparse.ArgumentParser: "Read one JSON request from stdin and write one JSON response.\n" "Supported commands include status, doctor, data_call, fields, every named data\n" "command (calendar, bars, sector-stocks, download, ...), asset, positions, orders,\n" - "trades, the named trader queries (credit-detail, ipo-data, ...), buy, sell, cancel,\n" - "and trade_call. subscribe/subscribe_whole/unsubscribe are rejected here; they\n" + "trades, the named trader queries (credit-detail, ipo-data, ...) and trade_call\n" + "(query methods only). subscribe/subscribe_whole/unsubscribe are rejected here; they\n" "require server mode." ), ) @@ -1316,7 +1279,7 @@ def build_parser() -> argparse.ArgumentParser: "Run a stdio MCP (Model Context Protocol) server. Tools are generated from\n" "AGENT_CAPABILITIES (qmt_, dashes become underscores) and every tool call is\n" "dispatched through the same rpc machinery as 'rpc'/'server'.\n" - "Excludes buy/sell/cancel and trade_call by design; use the CLI for guarded trading.\n" + "Excludes trade_call by design.\n" "Requires the optional mcp extra: pip install 'qmtcli[mcp]'." ), ) @@ -1424,12 +1387,6 @@ def _run_command(args: argparse.Namespace) -> int: elif args.command in TRADE_QUERY_COMMANDS: spec = TRADE_QUERY_COMMANDS[args.command] _print_json(gateway.call_trader(spec["method"], with_account=spec["with_account"])) - elif args.command == "buy": - _print_json(gateway.submit_order(args.symbol, "BUY", args.volume, args.price)) - elif args.command == "sell": - _print_json(gateway.submit_order(args.symbol, "SELL", args.volume, args.price)) - elif args.command == "cancel": - _print_json(gateway.cancel_order(args.order_id)) elif args.command == "trade-call": _print_json( gateway.call_trader( @@ -1827,9 +1784,6 @@ def _handle_rpc_data(request: dict[str, Any], allow_subscribe: bool = False) -> "positions", "orders", "trades", - "buy", - "sell", - "cancel", "trade_call", } if command not in account_commands: @@ -1851,12 +1805,6 @@ def _handle_rpc_data(request: dict[str, Any], allow_subscribe: bool = False) -> return gateway.query_orders(cancelable_only=request.get("cancelable_only", False)) if command == "trades": return gateway.query_trades() - if command == "buy": - return gateway.submit_order(request["symbol"], "BUY", request["volume"], request["price"]) - if command == "sell": - return gateway.submit_order(request["symbol"], "SELL", request["volume"], request["price"]) - if command == "cancel": - return gateway.cancel_order(request["order_id"]) if command == "trade_call": return gateway.call_trader( request["method"], diff --git a/src/qmtcli/gateway.py b/src/qmtcli/gateway.py index bd1a64e..00cbf79 100644 --- a/src/qmtcli/gateway.py +++ b/src/qmtcli/gateway.py @@ -16,6 +16,11 @@ from typing import Any +# qmtcli never trades: only these XtQuantTrader method families are callable. Orders go through a +# gateway with per-order human confirmation outside this tool (order_stock, cancel_order_stock, +# fund_transfer, ... are refused). +READ_ONLY_TRADER_PREFIXES = ("query_", "smt_query_") + QMT_ROOT_CANDIDATES = ( Path("D:/DFZQxtqmt_client_real_win64"), Path("D:/DFZQxtqmt_client_test_win64"), @@ -328,37 +333,14 @@ def call_trader(self, method: str, *args: Any, with_account: bool = True, **kwar else: trader = self._require_trader() account = None + if not method.startswith(READ_ONLY_TRADER_PREFIXES): + raise ValueError( + f"XtQuantTrader method {method!r} refused: qmtcli is read-only, only " + f"{'/'.join(p + '*' for p in READ_ONLY_TRADER_PREFIXES)} methods are allowed" + ) fn = getattr(trader, method, None) - if fn is None or method.startswith("_"): + if fn is None: raise ValueError(f"unknown XtQuantTrader method: {method}") if with_account: return fn(account, *args, **kwargs) return fn(*args, **kwargs) - - def cancel_order(self, order_id: int | str) -> Any: - trader, account = self._require_connected() - return trader.cancel_order_stock(account, int(order_id)) - - def submit_order(self, symbol: str, side: str, volume: int, price: float) -> Any: - trader, account = self._require_connected() - if volume <= 0 or volume % 100 != 0: - raise ValueError("A-share order volume must be a positive multiple of 100") - if price <= 0: - raise ValueError("price must be positive") - - side = side.upper() - if side not in {"BUY", "SELL"}: - raise ValueError("side must be BUY or SELL") - - constants = self.xtconstant - order_type = constants.STOCK_BUY if side == "BUY" else constants.STOCK_SELL - return trader.order_stock( - account, - symbol, - order_type, - volume, - constants.FIX_PRICE, - price, - "qmtcli", - "qmtcli", - ) diff --git a/src/qmtcli/mcp_server.py b/src/qmtcli/mcp_server.py index cafdd45..aa6272d 100644 --- a/src/qmtcli/mcp_server.py +++ b/src/qmtcli/mcp_server.py @@ -25,13 +25,8 @@ # --- capability -> tool translation ------------------------------------------------------------- -# Danger levels that must never be reachable over MCP: qmtcli-over-MCP stays a read + guarded -# escape-hatch surface for agents; order placement/cancellation remain CLI/rpc/server only. -_EXCLUDED_DANGERS = {"places_order", "cancels_order"} - -# trade_call is an unguarded escape hatch onto the *entire* XtQuantTrader surface (which includes -# order placement/cancellation under other method names), so it is excluded by name even though -# its own "danger" is "escape_hatch" rather than places_order/cancels_order. data_call is the +# trade_call is an escape hatch onto caller-selected XtQuantTrader query methods, so it stays +# excluded by name; the named trade queries cover the agent use cases. data_call is the # read-only xtdata equivalent and stays included. "rpc"/"server"/"mcp" are defensive: none of them # are AGENT_CAPABILITIES commands today except "mcp" itself (added below), but excluding all three # by name means adding a future meta-command with one of these names can never accidentally turn @@ -57,8 +52,6 @@ def _dispatch_command(command: dict[str, Any]) -> str: def _is_excluded(command: dict[str, Any]) -> bool: - if command.get("danger") in _EXCLUDED_DANGERS: - return True if command["name"] in _EXCLUDED_NAMES: return True # Anything restricted to a transport list that excludes "rpc" cannot be reached through @@ -97,8 +90,7 @@ def _input_schema(command: dict[str, Any]) -> dict[str, Any]: def build_tool_specs() -> list[dict[str, Any]]: """Generate MCP tool specs from AGENT_CAPABILITIES -- the single source of truth. - Excludes order placement/cancellation (danger places_order/cancels_order), trade_call (an - unguarded escape hatch onto the full XtQuantTrader surface), and anything not reachable + Excludes trade_call (an escape hatch onto caller-selected XtQuantTrader query methods), and anything not reachable through the rpc transport (the subscribe trio, server-only; watch, CLI-only). Everything else -- status, doctor, data_call, fields, download, every named data command, and every trade query including asset/positions/orders/trades -- is included. @@ -141,8 +133,8 @@ async def call_tool(name: str, arguments: dict[str, Any]) -> list[types.TextCont else: # Merge arguments *under* the resolved command, not {"command": command, **arguments}: # if a caller's arguments happened to include a "command" key, spreading arguments last - # would let it silently override the dispatch target and reach an excluded command (buy, - # sell, cancel, trade_call, subscribe, ...) through an innocuous-looking tool. Building the + # would let it silently override the dispatch target and reach an excluded command + # (trade_call, subscribe, ...) through an innocuous-looking tool. Building the # request from arguments first and setting "command" last keeps the resolved command # authoritative no matter what the caller sends. request: dict[str, Any] = dict(arguments) diff --git a/tests/test_agent_protocol.py b/tests/test_agent_protocol.py index 8f1ff06..658033e 100644 --- a/tests/test_agent_protocol.py +++ b/tests/test_agent_protocol.py @@ -47,14 +47,12 @@ def test_capabilities_cli_is_agent_readable(capsys): "positions", "orders", "trades", - "buy", - "sell", - "cancel", "trade_call", } <= names - assert {"safe", "escape_hatch", "downloads_data", "places_order"} <= { - command["danger"] for command in response["data"]["commands"] - } + assert not {"buy", "sell", "cancel"} & names + dangers = {command["danger"] for command in response["data"]["commands"]} + assert {"safe", "escape_hatch", "downloads_data"} <= dangers + assert not {"places_order", "cancels_order"} & dangers def _registered_subcommand_names() -> set[str]: @@ -91,7 +89,7 @@ def test_examples_cli_returns_parseable_requests(capsys): response = _json_out(capsys) assert response["ok"] is True assert response["data"]["status"]["command"] == "status" - assert response["data"]["buy"]["command"] == "buy" + assert "buy" not in response["data"] def test_agent_metadata_available_over_rpc(monkeypatch, capsys): diff --git a/tests/test_boundaries.py b/tests/test_boundaries.py index 192c372..cde24e7 100644 --- a/tests/test_boundaries.py +++ b/tests/test_boundaries.py @@ -23,10 +23,64 @@ def test_trade_call_blocks_private_methods(): gateway.trader = object() gateway.account = object() - with pytest.raises(ValueError, match="unknown XtQuantTrader method"): + with pytest.raises(ValueError, match="read-only"): gateway.call_trader("_private") +class _OrderingTrader: + def __init__(self): + self.calls = [] + + def order_stock(self, *args): + self.calls.append(("order_stock", args)) + return 1 + + def cancel_order_stock(self, *args): + self.calls.append(("cancel_order_stock", args)) + return 0 + + def fund_transfer(self, *args): + self.calls.append(("fund_transfer", args)) + return 0 + + def query_stock_asset(self, account): + return {"cash": 1} + + +@pytest.mark.parametrize("method", ["order_stock", "cancel_order_stock", "fund_transfer", "order_stock_async"]) +def test_trade_call_refuses_every_non_query_method(method): + """G11: an agent must not be able to place, cancel or move money through qmtcli.""" + gateway = QMTGateway(QMTConnection("C:/qmt/userdata_mini", "123456")) + trader = _OrderingTrader() + gateway.trader = trader + gateway.account = object() + + with pytest.raises(ValueError, match="read-only"): + gateway.call_trader(method, "600519.SH", 23, 100) + assert trader.calls == [] + + +def test_trade_call_still_runs_query_methods(): + gateway = QMTGateway(QMTConnection("C:/qmt/userdata_mini", "123456")) + gateway.trader = _OrderingTrader() + gateway.account = object() + + assert gateway.call_trader("query_stock_asset") == {"cash": 1} + + +@pytest.mark.parametrize("command", ["buy", "sell", "cancel"]) +def test_rpc_has_no_order_commands(command): + response = _handle_rpc_request({"command": command, "account": "1", "symbol": "600519.SH", + "volume": 100, "price": 1.0, "order_id": "1"}) + + assert response == {"ok": False, "error": f"unknown command: {command}"} + + +def test_gateway_has_no_order_methods(): + assert not hasattr(QMTGateway, "submit_order") + assert not hasattr(QMTGateway, "cancel_order") + + def test_rpc_unknown_command_returns_error_without_id(): response = _handle_rpc_request({"command": "missing"}) @@ -45,7 +99,6 @@ def test_rpc_error_echoes_id(): def test_examples_are_valid_json(): for path in [ Path("examples/status.json"), - Path("examples/order.json"), Path("examples/data_call.json"), ]: assert json.loads(path.read_text(encoding="utf-8"))["command"] diff --git a/tests/test_cli_help.py b/tests/test_cli_help.py index 6f3b909..4002f23 100644 --- a/tests/test_cli_help.py +++ b/tests/test_cli_help.py @@ -14,4 +14,4 @@ def test_help_documents_escape_hatches(capsys): with pytest.raises(SystemExit) as exc: main(["trade-call", "--help"]) assert exc.value.code == 0 - assert "public XtQuantTrader method" in capsys.readouterr().out + assert "read-only XtQuantTrader query method" in capsys.readouterr().out diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index 20ff97f..bb00e52 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -24,9 +24,6 @@ def _spec_names() -> set[str]: def test_build_tool_specs_excludes_dangerous_and_unreachable_commands(): names = _spec_names() for excluded in ( - "qmt_buy", - "qmt_sell", - "qmt_cancel", "qmt_trade_call", "qmt_subscribe", "qmt_subscribe_whole", @@ -74,12 +71,9 @@ def test_build_tool_specs_count_matches_capabilities_minus_exclusions(): """No magic number: independently recompute the exclusion predicate from AGENT_CAPABILITIES itself and check build_tool_specs() agrees, so this test tracks the capability list as it grows instead of hard-coding today's count.""" - excluded_dangers = {"places_order", "cancels_order"} excluded_names = {"trade_call", "rpc", "server", "mcp"} def is_excluded(command: dict) -> bool: - if command.get("danger") in excluded_dangers: - return True if command["name"] in excluded_names: return True transports = command.get("transports") @@ -125,13 +119,13 @@ def test_mcp_quote_output_normalizes_native_missing_price(monkeypatch): def test_call_tool_command_argument_cannot_override_dispatch_target(monkeypatch): """A caller-supplied "command" argument must never win over the resolved dispatch target -- otherwise any exposed tool (even the read-only qmt_status) could be hijacked into reaching an - excluded command such as buy/sell/cancel/trade_call/subscribe through its arguments.""" + excluded command such as trade_call/subscribe through its arguments.""" monkeypatch.setattr(QMTGateway, "is_available", lambda path=None: True) result = anyio.run( call_tool, "qmt_status", - {"command": "buy", "symbol": "600519.SH", "volume": 100, "price": 1.0}, + {"command": "trade_call", "method": "query_stock_asset"}, ) payload = json.loads(result[0].text) diff --git a/tests/test_qmt_gateway.py b/tests/test_qmt_gateway.py index 271da75..6f06b68 100644 --- a/tests/test_qmt_gateway.py +++ b/tests/test_qmt_gateway.py @@ -67,14 +67,6 @@ def query_stock_orders(self, account, cancelable_only=False): def query_stock_trades(self, account): return [{"order_id": 123, "stock_code": "600519.SH"}] - def cancel_order_stock(self, account, order_id): - calls.append(("cancel", order_id)) - return 0 - - def order_stock(self, account, symbol, order_type, volume, price_type, price, strategy, remark): - calls.append(("order", symbol, order_type, volume, price_type, price, strategy, remark)) - return "ORDER123" - class FakeAccount: def __init__(self, account_id, account_type): self.account_id = account_id @@ -108,19 +100,6 @@ def __init__(self, account_id, account_type): ("start",), ("connect",), ] - assert gateway.submit_order("600519.SH", "BUY", 100, 1500.0) == "ORDER123" - assert calls[-1] == ( - "order", - "600519.SH", - 23, - 100, - 11, - 1500.0, - "qmtcli", - "qmtcli", - ) - assert gateway.cancel_order("123") == 0 - assert calls[-1] == ("cancel", 123) def test_qmt_doctor_reports_read_only_sdk_support(monkeypatch, capsys): @@ -166,15 +145,6 @@ def test_qmt_doctor_reports_install_hint_when_sdk_missing(monkeypatch): assert "does not auto-download" in report["diagnosis"]["fix_hint"] -def test_qmt_order_validation_without_real_submit(): - gateway = QMTGateway(QMTConnection("C:/qmt/userdata_mini", "123456")) - gateway.trader = object() - gateway.account = object() - - with pytest.raises(ValueError, match="multiple of 100"): - gateway.submit_order("600519.SH", "BUY", 50, 100.0) - - def test_qmt_rpc_status(monkeypatch, capsys): monkeypatch.setattr(sys, "stdin", io.StringIO('{"command":"status"}'))