@dshtrading/connector-qmt 0.6.0 → 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,34 +1,157 @@
1
- # @dshtrading/connector-template
1
+ # @dshtrading/connector-qmt
2
2
 
3
- **本包是脚手架源,不是可安装的连接器**:不入任何 bundle 依赖,仅在 `pnpm -r build/test`
4
- 基线内保证可编译、冒烟测试绿。真实接入用生成器:
3
+ 迅投 MiniQMT A 股**券商实盘交易 + 行情**连接器:单包提供 `tradingCnMarketData` 与
4
+ `tradingCnTrade` 两个服务,注册 5 个 `cn_*` 工具(行情 2 + 交易 3)。
5
+ 实盘动作受两重约束:`@dshtrading/authority` 的人工签署平面(`liveTrading` 只是非权威镜像),
6
+ 以及下推到服务实现内第一步的三态闸门(工具层另有闸门,双保险)。
5
7
 
6
- ```sh
7
- node scripts/new-connector.mjs --slug bybit --title Bybit --market crypto
8
- ```
8
+ ## 状态
9
+
10
+ **未实证(网关面从未有已提交的真实网络验证)**。
11
+
12
+ - 装配面有已提交的实证入口:[spikes/impl-qmt-alpaca-dataplane-activation/run-loader-activation-probe.mjs](../../spikes/impl-qmt-alpaca-dataplane-activation/run-loader-activation-probe.mjs)
13
+ (2026-10-14,走 loader 真实激活路径判定 `cn/qmt` 行是否 activated 并打印行情/交易两张注册表内容;不触网)。
14
+ 根因与修复记录在 [.agents/notes/implemented/bug-fix/2026-10-14-qmt-alpaca-dataplane-config-schema.md](../../.agents/notes/implemented/bug-fix/2026-10-14-qmt-alpaca-dataplane-config-schema.md)。
15
+ - [spikes/verify-connectors-suite/EVIDENCE.md](../../spikes/verify-connectors-suite/EVIDENCE.md)(2026-08-31,commit `b80000e`)
16
+ 把 qmt 列为「交易面 stub:`placeOrder` 不发网络请求、`getBalance` 硬编码 CNY 500000」——该结论针对旧实现,
17
+ 现实现经本地网关真实 HTTP(`src/rest.ts:242-354`),此处仅作历史记录引用,不代表当前行为。
18
+ - [spikes/impl-service-seam-gate/NET-VERIFY.md](../../spikes/impl-service-seam-gate/NET-VERIFY.md)(2026-09-01)
19
+ 在「范围与未验证项」中明确:qmt 等券商网关**未做真实下单验证**,服务缝三态只由离线矩阵
20
+ `test/trade-gate.test.ts` 覆盖。
21
+ - 2026-10-14 本出口对 `push2.eastmoney.com` 不可达(`curl: (52) Empty reply from server`),
22
+ 故网关离线时的东财回退路径本轮同样无法实证——回退取价口径与错误映射目前只有离线用例
23
+ (`test/template.test.ts` 的回退用例组,fixture 是同日真实响应行)。
24
+
25
+ ## 配置
26
+
27
+ | 字段 | 默认 | 说明 |
28
+ |---|---|---|
29
+ | `enabled` | `true` | 是否激活本连接器(`src/index.ts:51`) |
30
+ | `env` | `'demo'` | `demo` / `live`;作为 `OrderIntentLedger` 的 environment 维度参与幂等判定(`src/index.ts:52`、`src/index.ts:117`) |
31
+ | `dryRun` | `true` | 默认模拟下单;为真时服务缝强制本地模拟回执(`src/index.ts:53`、`src/index.ts:137`、`src/index.ts:174`) |
32
+ | `liveTrading` | `false` | 是否允许实盘;非权威镜像,实盘授权由 `@dshtrading/authority` 裁定(`src/index.ts:54`、`src/index.ts:130`) |
33
+ | `gatewayUrl` | `'http://127.0.0.1:5800'`(`src/index.ts:55`);env `QMT_GATEWAY_URL` 可覆盖(`src/rest.ts:78`) | MiniQMT 本地网关地址 |
34
+ | `accountId` | **无默认值**(`Schema.string()` 未给 default,`src/index.ts:56`);env `QMT_ACCOUNT_ID` 兜底(`src/rest.ts:79`) | 资金账号;缺失时资金 / 持仓 / 下单 / 撤单均抛 `TRADING_AUTH_FAILED` |
35
+
36
+ ## 接线
37
+
38
+ - **数据面行**:`packages/cn/cordis.patch.yml:39-42` → 行 id `dsh-trading-cn-dataplane-qmt`
39
+ (name `@dshtrading/connector-qmt/dataplane`,行 config 只写 `enabled: true`)。
40
+ 注册 `(cn, qmt)` 进 `tradingMarketDataRegistry`(`src/dataplane.ts:49`),
41
+ 若同市场存在 `tradingTradeRegistry` 再注册交易服务(`src/dataplane.ts:55`)。
42
+ - **preset 行**:`packages/cn/assets/preset/cn-trader/agent.cordis.yml:69-74` → 行 id
43
+ `dsh-trading-cn-connector-qmt`(`config: {enabled: true, dryRun: true, liveTrading: false}`)。
44
+ - **bundle 依赖**:`packages/cn/package.json:34` 的 `"@dshtrading/connector-qmt": "workspace:*"`。
45
+ - **provider 词汇**:`packages/router/src/index.ts:78`;与 `src/index.ts:218` 的 `ROUTER_PROVIDER = 'qmt'` 同名。
46
+ - **服务键**:`tradingCnMarketData`(`src/index.ts:59`)与 `tradingCnTrade`(`src/index.ts:60`);
47
+ dataplane 侧各自 `ctx.isolate(服务键)` 后注册(`src/dataplane.ts:47`、`src/dataplane.ts:53`)。
48
+ - **必须导出 `Config`**:`src/dataplane.ts:18` 就地重导出主行同一份 schema。loader 只认**被加载模块**导出的
49
+ Config;缺它则行 config 的默认值全丢,`env` 缺失会在 `OrderIntentLedger` 构造时抛 TypeError,
50
+ 导致整行 apply 失败、行情与交易两个服务一起不注册(同引上面的 bug-fix Note)。
51
+ - **设置页显示名与地址提示**:`PROVIDER_LABELS` 的 `qmt` 条目
52
+ (`packages/client-ui-settings/src/client/trading-settings-controller.ts:64`:`markets: ['cn']`、
53
+ `configField: 'gatewayUrl'`)与 `packages/client-ui-settings/src/client/locales.ts` 的
54
+ `'provider.qmt'` 条目(zh `MiniQMT (迅投券商实盘)` / en `MiniQMT (broker gateway)`)。
55
+ **凭据卡片已撤**(2026-10-15):面板原先收集 `gatewayUrl` 写进 `dshtrading.credentials.qmt`,
56
+ 而本包只读 `Config.gatewayUrl`(rest 层的 `QMT_GATEWAY_URL` 回落被 schema 默认值遮蔽),
57
+ 提示因此改走 `configField` 槽;见[设置页凭据面收窄](../../.agents/notes/implemented/simplification/2026-10-15-settings-credential-surface-narrowed.md)。
58
+
59
+ ## 上游端点
60
+
61
+ | 用途 | 端点 | 源码 |
62
+ |---|---|---|
63
+ | 报价 | `GET {gatewayUrl}/api/v1/market/ticker?symbol=<code>`(网关需返回 `code: 0` 且带 `data`) | `src/rest.ts:114-126` |
64
+ | K 线 | `GET {gatewayUrl}/api/v1/market/klines?symbol=<code>&period=<interval>&limit=<limit>` | `src/rest.ts:214` |
65
+ | 资金 | `GET {gatewayUrl}/api/v1/trade/asset?account_id=<acc>` | `src/rest.ts:251` |
66
+ | 持仓 | `GET {gatewayUrl}/api/v1/trade/positions?account_id=<acc>` | `src/rest.ts:279` |
67
+ | 下单 | `POST {gatewayUrl}/api/v1/trade/order`,体 `account_id` / `stock_code` / `order_type` / `order_side` / `price` / `order_volume` | `src/rest.ts:303-312` |
68
+ | 撤单 | `POST {gatewayUrl}/api/v1/trade/cancel`,体 `account_id` / `order_id` | `src/rest.ts:341-346` |
69
+ | 行情回退(网关不可用时:连不上 / 非 2xx / 非 JSON / 业务码非 0 / 缺 `data`) | `https://push2.eastmoney.com/api/qt/ulist.np/get?secids=<1 或 0>.<code>&fields=f1,f2,f5,f124`(按行 `f1` 取价)与 `https://push2his.eastmoney.com/api/qt/stock/kline/get?secid=<secid>&klt=101&fqt=1`(日线) | `src/rest.ts:156`、`src/rest.ts:221` |
9
70
 
10
- 生成后按 [docs/guides/connector-playbook.md](../../docs/guides/connector-playbook.md) 的填写顺序
11
- (src/rest.ts 头部清单 7 项)实现 TODO。每个 TODO 的参照段都在 connector-okx
12
- (packages/connector-okx/),它是本仓第一个真实 TradeService 连接器。
71
+ 该网关契约不在本仓、也不是官方契约(类头注 `src/index.ts:97-104` 明示),请求体字段按
72
+ 「不猜字段」纪律只写实现已用到的键。
13
73
 
14
- ## 结构
74
+ ## 周期与契约
15
75
 
16
- - `src/rest.ts` —— 错误载体(TradingServiceError)+ 通用请求管线(超时/错误映射/
17
- 模拟盘头/注入 fetch)直接可用;签名与端点 TODO。
18
- - `src/index.ts` —— Config(enabled/env/dryRun/liveTrading + 三 ref 凭证组)、
19
- 闸门三态判定、凭证解析(BYOK + 环境变量回落)、MarketDataService/TradeService 骨架、
20
- 7 个市场前缀工具注册(duplicate-safe)、apply(enabled=false 静默退出)。
21
- - `test/template.test.ts` —— 结构冒烟 7 用例。
76
+ - **周期词汇**:`INTERVAL_VOCABULARY = ['1m','5m','15m','30m','1h','1d','1w','1M']`(`src/rest.ts:28`),
77
+ 同一数组进 `cn_get_klines` 的 `interval` 枚举(`src/index.ts:255`)。
78
+ - **网关路径把 `interval` 原样当 `period` 传**(`src/rest.ts:214`),映射在网关侧;
79
+ **东财回退路径恒 `klt=101`(日线)**(`src/rest.ts:221`),网关不可用时任何周期都只能拿到日线。
80
+ - **符号形态**(`toQmtCode`,`src/rest.ts:30-37`):含 `.` 原样;裸码 `6`/`5`→`.SH`,`0`/`3`→`.SZ`,
81
+ `8`/`4`→`.BJ`,其余→`.SH`。
82
+ - **错误映射**:网关非 2xx → `TRADING_UPSTREAM_ERROR`、不可达或响应非 JSON → `TRADING_NETWORK`
83
+ (`src/rest.ts:83-101`);网关 `code !== 0` 或缺 `data` 时**交易面**(资金 / 持仓 / 下单 / 撤单)
84
+ → `TRADING_EXCHANGE_ERROR`(资金 `src/rest.ts:254`、持仓 `:282`、下单 `:316`、撤单 `:350`),
85
+ **报价面不抛该错**——同一形态交给回退(见下);缺 `accountId` → `TRADING_AUTH_FAILED`
86
+ (`src/rest.ts:245,267,296,335`)。
87
+ - **东财回退的映射**(`quoteViaEastmoneyFallback`,`src/rest.ts:151-206`):非 2xx →
88
+ `TRADING_UPSTREAM_ERROR`、连不上 → `TRADING_NETWORK`、非 JSON 或行里没有可用价格 →
89
+ `TRADING_EXCHANGE_ERROR`;抛出的错 `cause` 一律是触发本次降级的网关失败。
90
+ - **服务缝三态闸门**(`src/index.ts:126-181`):
91
+ ① `dryRun=false` 且实盘未授权 → 结构化拒绝 `TRADING_LIVE_TRADING_DISABLED`,且不触网;
92
+ ② `dryRun` 缺省或 `config.dryRun` → 本地模拟回执(`dry-<时间戳>-<随机>`,`dryRun: true`);
93
+ ③ 放行 → 先写 `OrderIntentLedger`(`provider: 'qmt'`,维度 env + account),
94
+ 同 intentId 命中 `replay` 时直接回放已记回执、不二次下单;新意图触网后 `confirm` 落盘(`src/index.ts:152-168`)。
95
+ 撤单与真实下单同门槛(`src/index.ts:171-181`),防经撤单接口绕过下单闸门。
22
96
 
23
- ## Token
97
+ ## 能力面
24
98
 
25
- `qmt`(slug)/ `QMT`(title)/ `QMT`(ref 前缀)/
26
- `crypto`(工具前缀)/ `Crypto`(服务键 infix)。token 未展开也可编译;
27
- 生成器保证全部替换。
99
+ | 服务方法 | 本包实现 | 工具暴露 |
100
+ |---|---|---|
101
+ | `QmtMarketDataService.getTicker`(网关优先,网关不可用即回退东财) | 有(`src/index.ts:74`) | `cn_get_ticker`(`src/index.ts:241`) |
102
+ | `QmtMarketDataService.getKlines`(同上) | 有(`src/index.ts:78`) | `cn_get_klines`(`src/index.ts:251`) |
103
+ | `QmtMarketDataService.subscribeTicker`(轮询,最小 1000 ms、默认 5000 ms) | 有(`src/index.ts:82-90`) | 缺席 |
104
+ | `QmtTradeService.placeOrder`(三态闸门 + 意图台账 + 网关 POST) | 有(`src/index.ts:126-169`) | `cn_place_order`(`src/index.ts:266`) |
105
+ | `QmtTradeService.cancelOrder`(同门槛) | 有(`src/index.ts:171-181`) | `cn_cancel_order`(`src/index.ts:326`) |
106
+ | `QmtTradeService.getBalance` | 有(网关资金;缺 `accountId` 抛错,`src/index.ts:122`) | `cn_get_balance`(`src/index.ts:337`) |
107
+ | `QmtTradeService.getPositions` | 有(网关持仓,`src/index.ts:183`) | 缺席 |
108
+ | `QmtTradeService.getBalances` | 有(包一层,异常吞成 `[]`,`src/index.ts:187-194`) | 缺席 |
109
+ | `QmtTradeService.listOpenOrders` / `listTradeFills` | 恒返回 `[]`(`src/index.ts:196-202`) | 缺席 |
110
+ | `QmtTradeService.getOrder` | 返回合成对象(`quantity: 0`、`status: 'new'`、`dryRun: false`),非网关查询(`src/index.ts:204-215`) | 缺席 |
111
+ | `listInstruments` / 盘口 / 基本面 | 未实现 | 未实现 |
28
112
 
29
- ## 注意(勿改的坑)
113
+ ## 已知边界
114
+
115
+ - **网关契约未经真实联调**:路径与请求体来自 2026-08-31 的实现记录
116
+ ([.agents/notes/implemented/feature/2026-08-31-real-trading-suite-and-cn-fix.md](../../.agents/notes/implemented/feature/2026-08-31-real-trading-suite-and-cn-fix.md) §1.2),
117
+ 本仓无网关真实响应证据;`accountId` 未配则资金 / 持仓 / 下单 / 撤单全部 fail-closed。
118
+ - **报价降级只有一条路径**:`getTicker`(`src/rest.ts:110-136`)把网关传输层失败(连不上 / 非 2xx /
119
+ 非 JSON)与网关明确回「业务码非 0 或缺 `data`」交给同一条东财回退(`src/rest.ts:151-206`)——上游
120
+ 业务错误码与 HTTP 4xx/5xx 在本仓同属一类上游失败,不为业务码另立第三种语义。
121
+ - **回退不再交零价快照**:拿不到可用报价行(未收录 secid 的 `data:null`、停牌占位 `f2` 缺失或为 0)
122
+ 即抛结构化错误(映射见上「错误映射」)。`price: 0` 在消费方读起来是「这只标的不值钱」,不是
123
+ 「本次没取到价」;不借用词汇表外的 `TRADING_SYMBOL_NOT_FOUND`——走进回退的唯一原因是主取价路径
124
+ 不可用,从 push2 的沉默推不出「该标的不存在」(secid 由 `toQmtCode` 启发式派生)。
125
+ - **回退是限流敏感路径**:每次 `getTicker` 最多一个 push2 请求,不重试、不批量。push2 域对突发并发
126
+ 极敏感(2026-10-14 实测在飞 4 即被断连、失效面持续 ≥12 分钟,见
127
+ [connector-eastmoney README 的「已知边界」](../connector-eastmoney/README.md))。
128
+ - **下单请求不带 venue 幂等字段**:类头注(`src/index.ts:97-104`)说明 QMT 官方 `order_stock` 有
129
+ `order_remark`,但本包打的是本地网关、其契约未承诺该键,按「不猜字段」纪律不写入请求体;
130
+ 幂等完全由本地 `OrderIntentLedger`(`@dshtrading/authority`)保证。
131
+ - **账户读面工具缺失**:`getPositions` / `listOpenOrders` / `listTradeFills` / `getOrder` 服务有实现
132
+ 但无工具暴露,其中 `getOrder` 与两个 `list*` 尚无真实数据源。同类缺口登记在
133
+ [.agents/notes/implemented/architecture/2026-09-08-agent-native-plugin-interface-audit.md](../../.agents/notes/implemented/architecture/2026-09-08-agent-native-plugin-interface-audit.md)。
134
+ - 无 `listInstruments`、盘口、基本面能力。
135
+
136
+ ## 合规记录
137
+
138
+ MiniQMT 是券商侧授权软件(需在券商开通迅投 QMT 权限),本地网关跑在用户机器上;
139
+ 本包不内置凭证、不落日志,资金账号只经 `accountId` 配置或 `QMT_ACCOUNT_ID` 环境变量进入(铁律 #3/#5)。
140
+ 实盘放行的唯一权威是 `@dshtrading/authority` 的人工签署平面,`liveTrading` 只是非权威镜像;
141
+ 下单默认 `dryRun: true`,闸门三态在服务缝与工具层双保险,不经统一审批闸门触达不到真实下单。
142
+
143
+ ## 出网验证
144
+
145
+ 本包暂无已提交的独立出网验证脚本(网关面)。可复跑的入口:
146
+
147
+ ```sh
148
+ # 装配面:走真实 loader 激活路径,判定 cn/qmt 行是否 activated,并打印行情/交易注册表内容(不触网)
149
+ node spikes/impl-qmt-alpaca-dataplane-activation/run-loader-activation-probe.mjs
150
+
151
+ # 网关面最小复跑(需本机 MiniQMT 网关在 5800;网关不可用时回退东财)
152
+ ./node_modules/.bin/tsx -e "import('./packages/connector-qmt/src/rest.ts').then(async m => console.log(JSON.stringify(await new m.QmtRestClient({ accountId: process.env.QMT_ACCOUNT_ID }).getTicker('600519.SH'))))"
153
+ ```
30
154
 
31
- - 服务类用 TS 编译期 `private`(禁 ECMAScript `#`,realm 代理会炸)。
32
- - 工具名 = 市场前缀 + 语义词(不带交易所名)——base 闸门正则只认市场前缀。
33
- - 行 id = `dsh-trading-<market>-connector-<slug>`,全仓唯一(insert-only 铁律 #1)。
34
- - 凭证只进 ref,绝不内置/落日志(铁律 #3/#5)。
155
+ 不触网的离线矩阵:服务缝三态 `packages/connector-qmt/test/trade-gate.test.ts`、
156
+ 意图幂等 `packages/connector-qmt/test/order-intent.test.ts`(假 venue 见 `packages/connector-qmt/test/fake-qmt-venue.ts`)、
157
+ 数据面装配 `packages/connector-qmt/test/dataplane.test.ts`。
@@ -2,6 +2,6 @@ import { Config } from "./index.js";
2
2
  import { Context } from "@deepseek-ai/cordis";
3
3
  //#region src/dataplane.d.ts
4
4
  declare const inject: string[];
5
- declare function apply(ctx: Context, config: Config): void;
5
+ declare function apply(ctx: Context, config?: Config): void;
6
6
  //#endregion
7
- export { apply, inject };
7
+ export { Config, apply, inject };
package/lib/dataplane.js CHANGED
@@ -1,6 +1,8 @@
1
- import { QmtMarketDataService, QmtTradeService, TRADING_CN_MARKET_DATA_KEY, TRADING_CN_TRADE_KEY } from "./index.js";
1
+ import { Config, QmtMarketDataService, QmtTradeService, TRADING_CN_MARKET_DATA_KEY, TRADING_CN_TRADE_KEY } from "./index.js";
2
2
  //#region src/dataplane.ts
3
3
  const inject = [];
4
+ /** schema 的运行时可调用形态(schemastery 校验即补默认值);手工 ctx 不传 config 时用。 */
5
+ const withDefaults = (value) => Config(value);
4
6
  function resolveMarketDataRegistry(ctx) {
5
7
  const candidate = ctx.get?.("tradingMarketDataRegistry", false);
6
8
  return candidate !== void 0 ? candidate : void 0;
@@ -9,9 +11,8 @@ function resolveTradeRegistry(ctx) {
9
11
  const candidate = ctx.get?.("tradingTradeRegistry", false);
10
12
  return candidate !== void 0 ? candidate : void 0;
11
13
  }
12
- const ROUTER_PROVIDER = "qmt";
13
14
  const MARKET = "cn";
14
- function apply(ctx, config) {
15
+ function apply(ctx, config = withDefaults(void 0)) {
15
16
  if (!config.enabled) return;
16
17
  const registry = resolveMarketDataRegistry(ctx);
17
18
  if (registry === void 0) {
@@ -26,7 +27,7 @@ function apply(ctx, config) {
26
27
  gatewayUrl: config.gatewayUrl,
27
28
  accountId: config.accountId
28
29
  });
29
- ctx.effect(() => registry.register(MARKET, ROUTER_PROVIDER, service));
30
+ ctx.effect(() => registry.register(MARKET, "qmt", service));
30
31
  const tradeRegistry = resolveTradeRegistry(ctx);
31
32
  if (tradeRegistry !== void 0) {
32
33
  const tradeInner = ctx.isolate(TRADING_CN_TRADE_KEY);
@@ -35,8 +36,8 @@ function apply(ctx, config) {
35
36
  accountId: config.accountId,
36
37
  config
37
38
  });
38
- ctx.effect(() => tradeRegistry.register(MARKET, ROUTER_PROVIDER, trade));
39
+ ctx.effect(() => tradeRegistry.register(MARKET, "qmt", trade));
39
40
  }
40
41
  }
41
42
  //#endregion
42
- export { apply, inject };
43
+ export { Config, apply, inject };
package/lib/index.d.ts CHANGED
@@ -28,6 +28,15 @@ declare class QmtTradeService extends Service implements TradeService {
28
28
  private readonly client;
29
29
  /** 插件配置(服务缝闸门 P0:dryRun 强制模拟 / liveTrading 非权威镜像——实盘授权由 @dshtrading/authority 的人工签署平面裁定)。 */
30
30
  private readonly config;
31
+ /**
32
+ * 订单意图台账(实盘幂等前提;写 agent 自己的 trading home,不写授权平面)。
33
+ *
34
+ * **不发 venue client key**:QMT 官方 XtQuant 的 `order_stock` 有 `order_remark` 参数,
35
+ * 但本连接器打的是本地 MiniQMT HTTP 网关(`/api/v1/trade/order`,契约不在本仓、也不是
36
+ * 官方契约)——按「不猜字段」纪律不往网关请求体里加它没承诺过的键;
37
+ * 幂等完全由本地台账保证(台账已保证未知不重发)。
38
+ */
39
+ private readonly intents;
31
40
  constructor(ctx: Context, options: QmtRestOptions & {
32
41
  config: Config;
33
42
  }, serviceName?: string);
package/lib/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { INTERVAL_VOCABULARY, QmtRestClient, TradingServiceError, toQmtCode as toQmtCode$1 } from "./rest.js";
2
2
  import { liveTradingEnabled } from "@dshtrading/authority";
3
+ import { OrderIntentLedger, orderFromReceipt, receiptFromOrder } from "@dshtrading/authority/order-intents";
3
4
  import { Service } from "@deepseek-ai/cordis";
4
5
  import { defineTool } from "@deepseek-ai/dsh-tools";
5
6
  import Schema from "@deepseek-ai/schemastery";
@@ -45,10 +46,24 @@ var QmtTradeService = class extends Service {
45
46
  client;
46
47
  /** 插件配置(服务缝闸门 P0:dryRun 强制模拟 / liveTrading 非权威镜像——实盘授权由 @dshtrading/authority 的人工签署平面裁定)。 */
47
48
  config;
49
+ /**
50
+ * 订单意图台账(实盘幂等前提;写 agent 自己的 trading home,不写授权平面)。
51
+ *
52
+ * **不发 venue client key**:QMT 官方 XtQuant 的 `order_stock` 有 `order_remark` 参数,
53
+ * 但本连接器打的是本地 MiniQMT HTTP 网关(`/api/v1/trade/order`,契约不在本仓、也不是
54
+ * 官方契约)——按「不猜字段」纪律不往网关请求体里加它没承诺过的键;
55
+ * 幂等完全由本地台账保证(台账已保证未知不重发)。
56
+ */
57
+ intents;
48
58
  constructor(ctx, options, serviceName = TRADING_CN_TRADE_KEY) {
49
59
  super(ctx, serviceName);
50
60
  this.client = new QmtRestClient(options);
51
61
  this.config = options.config;
62
+ this.intents = new OrderIntentLedger({
63
+ provider: "qmt",
64
+ environment: options.config.env,
65
+ account: options.config.accountId ?? "default"
66
+ });
52
67
  }
53
68
  async getBalance() {
54
69
  return this.client.getBalance();
@@ -67,7 +82,28 @@ var QmtTradeService = class extends Service {
67
82
  dryRun: true,
68
83
  timestamp: Date.now()
69
84
  };
70
- return this.client.placeOrder(void 0, order);
85
+ const payload = {
86
+ symbol: order.symbol,
87
+ side: order.side,
88
+ type: order.type,
89
+ quantity: order.quantity,
90
+ ...order.price !== void 0 ? { price: order.price } : {}
91
+ };
92
+ const decision = await this.intents.begin({
93
+ intentId: order.intentId,
94
+ payload
95
+ });
96
+ if (decision.action === "replay") return {
97
+ ...orderFromReceipt(decision.receipt),
98
+ status: decision.receipt.status
99
+ };
100
+ const placed = await this.client.placeOrder(void 0, order);
101
+ await this.intents.confirm({
102
+ intentId: decision.intentId,
103
+ payload,
104
+ receipt: receiptFromOrder(placed)
105
+ });
106
+ return placed;
71
107
  }
72
108
  async cancelOrder(orderId, _symbol) {
73
109
  if (!liveTradingEnabled(this.config.liveTrading) || this.config.dryRun) throw new TradingServiceError("TRADING_LIVE_TRADING_DISABLED", "QMT TradeService.cancelOrder rejected at the service seam: cancel is a live action and requires a signed live-trading grant (see @dshtrading/authority) with dryRun=false.");
@@ -212,6 +248,10 @@ function apply(ctx, config) {
212
248
  type: "boolean",
213
249
  default: true,
214
250
  description: "Dry run"
251
+ },
252
+ intentId: {
253
+ type: "string",
254
+ description: "Stable idempotency id, required when dryRun=false. Same id + same request returns the recorded receipt and never places a second order; a different payload/provider/environment/account under the same id is rejected (TRADING_INTENT_CONFLICT); an unresolved id stays blocked as unknown until reconciled."
215
255
  }
216
256
  },
217
257
  output: {
@@ -252,6 +292,7 @@ function apply(ctx, config) {
252
292
  type: args.type,
253
293
  quantity: args.quantity,
254
294
  price: args.price,
295
+ ...typeof args.intentId === "string" ? { intentId: args.intentId } : {},
255
296
  dryRun: false
256
297
  });
257
298
  return JSON.stringify(order);
package/lib/rest.d.ts CHANGED
@@ -17,7 +17,27 @@ declare class QmtRestClient {
17
17
  private readonly fetchImpl;
18
18
  constructor(options?: QmtRestOptions);
19
19
  private requestJson;
20
+ /**
21
+ * 网关报价。网关**传输层**失败(连不上 / 非 2xx / 非 JSON)与网关明确回「业务码非 0 或缺
22
+ * data」走**同一条**回退路径([quoteViaEastmoneyFallback]):主取价路径任一形态不可用即降级,
23
+ * 不在业务码上另立第三种语义——上游业务错误码与 HTTP 4xx/5xx 在本仓是同一类失败
24
+ * (`@dshtrading/api` 的 `TRADING_UPSTREAM_ERROR` 注释即此口径)。
25
+ */
20
26
  getTicker(symbol: string): Promise<Ticker$1>;
27
+ /**
28
+ * 网关取价路径不可用时的东财回退报价——2026-10-14 实测建立的降级设计:本地网关离线仍要能出价。
29
+ *
30
+ * 报价走批量端点 ulist.np/get(2026-10-14 实测单标的 /api/qt/stock/get 对任意 secid 都是传输层
31
+ * 空回,而同一时刻同域同 UA 的 ulist.np/get 正常),取价按该行自带的 f1 小数位数——市场级 ÷100
32
+ * 会把 A 股 ETF(f1=3)读成 10 倍。原始证据见 spikes/impl-eastmoney-quote-endpoint/。
33
+ *
34
+ * 每次调用最多一个请求:push2 域对突发并发极敏感(connector-eastmoney/README「已知边界」:
35
+ * 2026-10-14 实测在飞 4 即被断连且失效面持续 ≥12 分钟),这条路径不做批量、不重试。
36
+ *
37
+ * 拿不到可用报价行时抛结构化错误,**绝不交出零价快照**:`price: 0` 在消费方读起来是「这只标的
38
+ * 不值钱」,而不是「本次没取到价」。`cause` 一律是触发本次降级的网关失败,便于回溯主路径为何不可用。
39
+ */
40
+ private quoteViaEastmoneyFallback;
21
41
  getKlines(symbol: string, interval?: Interval$1, limit?: number): Promise<Kline$1[]>;
22
42
  getBalance(accountId?: string): Promise<AccountBalance$1>;
23
43
  getPositions(accountId?: string): Promise<Position$1[]>;
package/lib/rest.js CHANGED
@@ -26,6 +26,29 @@ function toQmtCode(symbol) {
26
26
  if (clean.startsWith("8") || clean.startsWith("4")) return `${clean}.BJ`;
27
27
  return `${clean}.SH`;
28
28
  }
29
+ /**
30
+ * 东财回退报价的行字段(ulist.np/get):f1=小数位数 f2=最新价 f5=成交量(手) f124=行情时间(秒)。
31
+ * 价格类字段是 ×10^f1 的整数。
32
+ */
33
+ const EM_QUOTE_FIELDS = "f1,f2,f5,f124";
34
+ /** 东财报价字段 → 有限数;null/空串/停牌占位返回 undefined,由调用方兜底。 */
35
+ function toFiniteNumber(value) {
36
+ if (typeof value === "number") return Number.isFinite(value) ? value : void 0;
37
+ if (typeof value === "string" && value.trim() !== "") {
38
+ const parsed = Number(value);
39
+ return Number.isFinite(parsed) ? parsed : void 0;
40
+ }
41
+ }
42
+ /**
43
+ * 东财报价行的价格除数 = 10^f1(f1 = 上游给该标的声明的小数位数):A 股股票 f1=2、A 股 ETF
44
+ * f1=3(2026-10-14 实测),故取价一律按行——市场级 ×100 会把 ETF 读成 10 倍。
45
+ * f1 缺失或落在 0..6 之外时回落到市场级 ×100。
46
+ */
47
+ function quotePriceScale(decimalDigits) {
48
+ const digits = toFiniteNumber(decimalDigits);
49
+ if (digits === void 0 || !Number.isInteger(digits) || digits < 0 || digits > 6) return 100;
50
+ return 10 ** digits;
51
+ }
29
52
  var QmtRestClient = class {
30
53
  gatewayUrl;
31
54
  accountId;
@@ -52,8 +75,15 @@ var QmtRestClient = class {
52
75
  throw new TradingServiceError("TRADING_NETWORK", `QMT network error: ${err instanceof Error ? err.message : String(err)}`, err);
53
76
  }
54
77
  }
78
+ /**
79
+ * 网关报价。网关**传输层**失败(连不上 / 非 2xx / 非 JSON)与网关明确回「业务码非 0 或缺
80
+ * data」走**同一条**回退路径([quoteViaEastmoneyFallback]):主取价路径任一形态不可用即降级,
81
+ * 不在业务码上另立第三种语义——上游业务错误码与 HTTP 4xx/5xx 在本仓是同一类失败
82
+ * (`@dshtrading/api` 的 `TRADING_UPSTREAM_ERROR` 注释即此口径)。
83
+ */
55
84
  async getTicker(symbol) {
56
85
  const qmtCode = toQmtCode(symbol);
86
+ let gatewayFailure;
57
87
  try {
58
88
  const data = await this.requestJson(`/api/v1/market/ticker?symbol=${encodeURIComponent(qmtCode)}`);
59
89
  if (data.code === 0 && data.data) return {
@@ -62,26 +92,52 @@ var QmtRestClient = class {
62
92
  volume: data.data.volume,
63
93
  timestamp: data.data.timestamp ?? Date.now()
64
94
  };
65
- } catch {
66
- const secid = qmtCode.startsWith("6") || qmtCode.startsWith("5") ? `1.${qmtCode.split(".")[0]}` : `0.${qmtCode.split(".")[0]}`;
67
- const d = await (await this.fetchImpl(`https://push2.eastmoney.com/api/qt/stock/get?secid=${secid}&fields=f43,f47,f86`)).json();
68
- let rawPrice = 0;
69
- if (typeof d.data?.f43 === "number") rawPrice = d.data.f43 / 100;
70
- else if (typeof d.data?.f43 === "string" && d.data.f43 !== "-" && d.data.f43 !== "−") {
71
- const parsed = parseFloat(d.data.f43);
72
- rawPrice = Number.isNaN(parsed) ? 0 : parsed / 100;
73
- }
74
- return {
75
- symbol: qmtCode,
76
- price: rawPrice > 0 ? rawPrice : 0,
77
- volume: d.data?.f47 ?? 0,
78
- timestamp: d.data?.f86 ? d.data.f86 * 1e3 : Date.now()
79
- };
95
+ gatewayFailure = new TradingServiceError("TRADING_EXCHANGE_ERROR", `QMT getTicker failed: ${data.message ?? `gateway returned code ${data.code} without a quote payload`}`);
96
+ } catch (err) {
97
+ gatewayFailure = err;
80
98
  }
99
+ return await this.quoteViaEastmoneyFallback(qmtCode, gatewayFailure);
100
+ }
101
+ /**
102
+ * 网关取价路径不可用时的东财回退报价——2026-10-14 实测建立的降级设计:本地网关离线仍要能出价。
103
+ *
104
+ * 报价走批量端点 ulist.np/get(2026-10-14 实测单标的 /api/qt/stock/get 对任意 secid 都是传输层
105
+ * 空回,而同一时刻同域同 UA 的 ulist.np/get 正常),取价按该行自带的 f1 小数位数——市场级 ÷100
106
+ * 会把 A 股 ETF(f1=3)读成 10 倍。原始证据见 spikes/impl-eastmoney-quote-endpoint/。
107
+ *
108
+ * 每次调用最多一个请求:push2 域对突发并发极敏感(connector-eastmoney/README「已知边界」:
109
+ * 2026-10-14 实测在飞 4 即被断连且失效面持续 ≥12 分钟),这条路径不做批量、不重试。
110
+ *
111
+ * 拿不到可用报价行时抛结构化错误,**绝不交出零价快照**:`price: 0` 在消费方读起来是「这只标的
112
+ * 不值钱」,而不是「本次没取到价」。`cause` 一律是触发本次降级的网关失败,便于回溯主路径为何不可用。
113
+ */
114
+ async quoteViaEastmoneyFallback(qmtCode, cause) {
115
+ const secid = qmtCode.startsWith("6") || qmtCode.startsWith("5") ? `1.${qmtCode.split(".")[0]}` : `0.${qmtCode.split(".")[0]}`;
116
+ let res;
117
+ try {
118
+ res = await this.fetchImpl(`https://push2.eastmoney.com/api/qt/ulist.np/get?secids=${secid}&fields=${EM_QUOTE_FIELDS}`, { headers: {
119
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)",
120
+ "Referer": "https://quote.eastmoney.com/"
121
+ } });
122
+ } catch (err) {
123
+ throw new TradingServiceError("TRADING_NETWORK", `Eastmoney fallback unreachable: ${String(err)}`, err);
124
+ }
125
+ if (!res.ok) throw new TradingServiceError("TRADING_UPSTREAM_ERROR", `Eastmoney fallback HTTP ${res.status} ${res.statusText}`, cause);
126
+ let payload;
127
+ try {
128
+ payload = await res.json();
129
+ } catch (err) {
130
+ throw new TradingServiceError("TRADING_EXCHANGE_ERROR", `Eastmoney fallback returned a non-JSON body for ${qmtCode}: ${String(err)}`, cause);
131
+ }
132
+ const row = payload.data?.diff?.[0];
133
+ const rawPrice = toFiniteNumber(row?.f2);
134
+ if (row === void 0 || rawPrice === void 0 || rawPrice <= 0) throw new TradingServiceError("TRADING_EXCHANGE_ERROR", `Eastmoney fallback returned no usable quote for ${qmtCode}`, cause);
135
+ const quoteTime = toFiniteNumber(row.f124);
81
136
  return {
82
137
  symbol: qmtCode,
83
- price: 0,
84
- timestamp: Date.now()
138
+ price: rawPrice / quotePriceScale(row.f1),
139
+ volume: toFiniteNumber(row.f5) ?? 0,
140
+ timestamp: quoteTime !== void 0 && quoteTime > 0 ? quoteTime * 1e3 : Date.now()
85
141
  };
86
142
  }
87
143
  async getKlines(symbol, interval = "1d", limit = 100) {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@dshtrading/connector-qmt",
3
- "description": "【模板】交易所连接器插件脚手架:QMT(qmt)v2 插件骨架 —— 市场数据服务 + 交易服务 + 三态闸门 + BYOK 凭证 ref。生成器:node scripts/new-connector.mjs --slug <exchange>",
4
- "version": "0.6.0",
3
+ "description": "迅投 MiniQMT A 股券商实盘交易与行情连接器:提供 tradingCnMarketData 与 tradingCnTrade 两个服务,注册 cn_get_ticker / cn_get_klines / cn_place_order / cn_cancel_order / cn_get_balance 五个工具,实盘受 @dshtrading/authority 签署平面与服务缝三态闸门约束。",
4
+ "version": "0.6.2",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "types": "./lib/index.d.ts",
@@ -21,12 +21,12 @@
21
21
  ],
22
22
  "license": "PolyForm-Noncommercial-1.0.0",
23
23
  "dependencies": {
24
- "@dshtrading/authority": "^0.6.0",
25
- "@dshtrading/api": "^0.6.0"
24
+ "@dshtrading/authority": "^0.6.2",
25
+ "@dshtrading/api": "^0.6.2"
26
26
  },
27
27
  "peerDependencies": {
28
28
  "@deepseek-ai/cordis": ">=4.0.0",
29
- "@deepseek-ai/dsh-tools": ">=0.2.0-rc.2",
29
+ "@deepseek-ai/dsh-tools": ">=0.2.1-rc.2",
30
30
  "@deepseek-ai/schemastery": ">=3.18.0"
31
31
  },
32
32
  "devDependencies": {