dsh-invest 0.1.0 → 0.2.0

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/CHANGELOG.md CHANGED
@@ -3,6 +3,504 @@
3
3
  本项目的版本记录。格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),
4
4
  版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。
5
5
 
6
+ ## [0.2.0] - 2026-11-02
7
+
8
+ ### Changed (breaking)
9
+
10
+ - **核心收敛为微内核:Binance 实现整体搬到 `dsh-invest-provider-binance`。**
11
+ 公开 K 线 venue、WebSocket 推送描述、交易执行器、交易所适配器与行情湖驱动
12
+ 不再由核心持有;核心只保留四张注册表(`investSources` / `investExecutors` /
13
+ `investExchanges` / `investLakeVenues`)与契约。
14
+ **升级注意**:只装 `dsh-invest` 时,`BINANCE.HOST`、Binance 交易账户与
15
+ `binance:usdm` 湖驱动都不存在 —— 需要同时装
16
+ `dsh-invest-provider-binance`。
17
+ - **`activeProvider` 退役:没有全局「当前数据源」。** 端点由标的自己携带
18
+ (`BINANCE.HOST:BTCUSDT` / `binance:BTCUSDT`)。删除的内容:
19
+ - 设置字段 `activeProvider` 与 `normalizeActive`(旧 `settings.json` 里的该字段
20
+ 被忽略,不会让设置加载失败);
21
+ - 设置页每行的「设为当前」按钮与 `data-active` 标记;
22
+ - 保存设置时「把当前 ticker 折成目标源写法」并 `setSymbol` 的行为
23
+ (`tickerForProvider` / `providerSymbol` / `direct:` 前缀一并删除);
24
+ - 设置字段 `direct` 与设置页的「浏览器直连」分区(浏览器直连 provider 改由
25
+ 数据源插件的 `ctx.invest.charts` 注册,设置里不再有开关);
26
+ - `ExchangeSourceSettings.symbolHint`(切源折 ticker 的最后一个消费者随之上移);
27
+ - `source.configure` 的 `active` 参数;
28
+ - 自选裸 ticker(`BTCUSDT`)的读取期补 venue:现在直接报
29
+ 「instrumentId 不合法(应写成 venue:ticker)」;写入路径本来就绑定 venue。
30
+ **盯盘**:行情 venue 改为账户执行器声明或 Run 绑定的数据源
31
+ (`provenance.providerId`);两者都没有的 Run 跳过订阅并告警 —— 创建 paper Run
32
+ 时请在策略里绑定数据源,或给账户用会声明 venue 的执行器。
33
+ **为什么**:所有写入路径本来就绑定 venue;全局默认值每次猜错都是静默的(换源后
34
+ 裸 `BTCUSDT` 指向另一个交易所),还会让保存设置变成切换图表标的的副作用。
35
+ - **核心不再自带任何交易所。** Coinbase / Hyperliquid 的宿主 venue、Hyperliquid 的
36
+ WS 推送、浏览器直连 provider 全部搬到 `dsh-invest-provider-ccxt`(用 ccxt 的
37
+ `fetchOHLCV` 供数)。`BUILTIN_PROVIDERS` / `BUILTIN_SOURCES` / `VENUES` /
38
+ `VENUE_STREAMS` 现在都是空表;**升级注意**:只装 `dsh-invest` 时没有任何行情源,
39
+ 需要装 `dsh-invest-provider-ccxt`(并安装 `ccxt`)才有 Coinbase / Hyperliquid /
40
+ OKX / Bybit / Kraken。
41
+ - **湖同步按 `marketType` 过滤名录**:一个 `BarSource` 可以同时服务 spot/perp
42
+ (`BarRequest.marketType` 决定取哪类);之前名录整份塞进每个 `(venue, marketType)`
43
+ 键,会让现货符号出现在永续序列里。
44
+ - **代理测试目标不再指向交易所**:`testProxyConnection` 改用两个通用公开端点
45
+ (`gstatic` / GitHub),核心不再知道任何交易所域名。
46
+ - **策略向导重新认识插件源**:微内核之后 `InvestSettings` 里不再有 binance/hyperliquid
47
+ 等内置源,向导第 3 步只读设置会显示「没有数据源」。现在并读 `/sources`,把
48
+ binance / tencent / ccxt 等插件公开源一起列出;读不到时退回设置源,不阻塞建策略。
49
+ - **`Venue` 新增 SDK 接缝**:`request`/`parse` 变为可选;`bars(query, ports)` /
50
+ `symbols(ports)` 让 ccxt 这类有状态客户端也能接入,出站面仍由核心提供。
51
+ 同一条接缝新增可选 `classifyError(error)`:SDK 源可以把异常折成
52
+ `rate-limited` / `not-covered`,核心保留这个分类而不是一律 `upstream-error`。
53
+ - **`ExecutorRegistry` 新增 `registerFactory`**:执行器按工作区拿 `ExecutorScope`
54
+ (root + 按连接解析的 `egress` + now),插件不再需要知道代理设置怎么读。
55
+ - **客户端新增 `ctx.invest.charts` 注册面**:浏览器直连 provider 与周期表改由
56
+ 数据源插件注册(能力名 `chartProvider`);核心不再 import Vela 的任何交易所
57
+ provider(`BinanceProvider` / `CoinbaseProvider` / `HyperliquidProvider`)。
58
+
59
+ ### Fixed
60
+
61
+ - **图表外观配置不再把宿主主题钉死**:`renderer.getConfig()` 会把主题颜色解析成具体
62
+ 值,亮色时期写下的完整配置在深色宿主里恢复时,会把背景/文字/网格/边框/分隔线
63
+ 全部盖回亮色 —— 而设置面板的 Theme 行仍显示 dark(它读的是宿主表面色)。现在
64
+ `chart.json` 的读写边界(workspace 状态、根文档 `config`、`getWorkspaceState()`)
65
+ 会摘掉「逐字等于内置调色板」的主题快照(`src/client/chart/config-theme.ts`),让
66
+ 这些字段回到 Vela 的「继承主题」;非调色板值的用户定制原样保留。
67
+
68
+ ### Changed
69
+
70
+ - **期货行情 venue 按账户选**:盯盘不再用全局 `activeProvider` 给永续账户订阅现货
71
+ 代码;执行器是 `binancefutures` 时用 `binancefutures` 写行情缓存,MUUSDT/
72
+ SPCXUSDT 这类持仓才会有 parquet,权益曲线直接用现有行情缓存。
73
+ - **仓位不变的采样只更新最后一帧**:同一段持仓最多两帧,后续更新
74
+ `Cash/总资产/仓位 mark`,不追加新行;行情 parquet 没覆盖的标的回退到
75
+ 最后一次持仓快照的 mark。
76
+ - **账户市值曲线改为轻量 SVG**:x 轴按整齐时间间隔对齐(短 Run 显示秒,不再重复
77
+ `HH:mm`),y 轴按权益范围裁剪并用紧凑金额,去掉品种水印/重复当前价标签;
78
+ 曲线仍由 host 采样,不在前端拿成交重放。
79
+ - **position.csv 不再因行情/现金变化追加行**:同一段持仓最多两帧,后续只更新最后
80
+ 一帧的 `Cash/总资产`;仓位没变时曲线用持仓 + 行情重算。`runEquity` 在最新
81
+ 台账之后用最新 mark 补当前点,不依赖最后一次写盘时间。
82
+ - **期货权益口径修正**:`position.csv` 的 `Cash` 改为**可用现金**(钱包余额 -
83
+ 保证金),`Cash + 保证金 + 浮盈` 与账户权益/KPI 一致;之前把已含保证金的钱包
84
+ 余额又加一次保证金,总资产偏高。新增 `现金口径=可用` 列区分旧文件,旧头
85
+ 读入时自动把保证金从 Cash 扣掉;Run 曲线、`runEquity`、`position.csv` 三处
86
+ 统一按此口径计算。
87
+ - **切 session 首屏立即可读**:每个 agent session 缓存自己的 workspace;切回时
88
+ 不等 `useSession` 更新,直接用该 session 的路径/工作区发请求。`load` 带上
89
+ 会话+工作区依赖,旧会话的迟到响应丢弃,同身份并发请求合并。
90
+ - **按 agent session 记住投资策略路径**:策略列表/详情/Run 详情从 sessionStorage
91
+ 改存 localStorage,客户端重启后仍能恢复;切换 session 时先清掉上一份状态再读,
92
+ 不会短暂显示上一个会话的列表。会话/工作区晚到、tab 重新可见/聚焦都会立即重读,
93
+ 同身份的并发 load 合并,旧会话的迟到响应不会覆盖新会话。
94
+ - **撤单在 Run 里可见**:`transaction.csv` 的订单状态会折回 synthetic intent 的
95
+ `status`,Run 的订单历史/日志显示「已撤销」;`order-closed` 记录带上 intent,
96
+ 撤单发生时刻那一行不再只有 clientOrderId。`已确认未成交` 与 `已撤销` 分开,
97
+ 避免把人工确认未成交误显示成撤单。
98
+ - **投资策略 tab 记住路径**:策略列表/详情/Run 详情按会话存进 sessionStorage,
99
+ 关掉侧栏 tab 再打开会回到上次的页面,不再退回列表。
100
+ - **position.csv 合并重复帧**:同一账本连续采样最多保留两帧;第三帧相同只把
101
+ 第二帧的时间推到最新,历史文件里的重复段也会在下次写入时合并。
102
+ - **权益回填后视口跟着扩展**:回填更早的点时,如果视口原来框住的是「当时的最老
103
+ 点」,重新用 ALL 框住新历史,不再只停在最近 500 点。
104
+ - **Run 台账改为人读 CSV**:`positions.jsonl` → `position.csv`(每个采样时刻的持仓行 +
105
+ Cash 行),`transactions.jsonl` → `transaction.csv`(一行一笔订单,状态变化重写该行)。
106
+ `transaction.csv` 在你给的 16 列之外扩展出 `委托编号`/`账户编号`/`交易所订单号`/
107
+ `只减仓`/`退出计划`/`成交时间`,机器恢复需要的字段不再另存旁路文件。
108
+ - **旧台账已迁移**:工作区里的旧 `positions.jsonl` / `transactions.jsonl` 已一次性转成
109
+ `position.csv` / `transaction.csv` 并删除;新代码不再读旧格式。
110
+ - **CSV 数值列全精度**:现金/市值/浮盈/保证金/总资产/成交额/平仓盈亏/手续费等
111
+ 数值按 double 的最短可回读表示写入,不做 2/4 位舍入;读回来是同一个数,去重
112
+ 不再因舍入误判。
113
+ - **权益不落盘**:`position.csv` 的 `总资产` 只给人复核;Run 页的权益曲线用
114
+ `cash + Σ(现货市值)` / `cash + Σ(保证金 + 浮盈)` 恢复,价格优先取行情缓存的历史序列,缺失才回退
115
+ 快照价。
116
+ - **Run 页通知改走宿主 Toast**:复制提示词/路径、下单提交、对账/停止/开始结果等
117
+ 瞬时通知不再在页面里占一行,而是用宿主 `Toast` 浮层;错误仍保留页内可重试告警。
118
+ - **KPI 文字按含义着色**:权益相对启动资金、盈亏/今日盈亏、收益率、回撤与相对峰值
119
+ 染绿/红;图标与第三行同色,标签保持灰色;敞口/成交数保持中性。
120
+ - **账户市值曲线先回填历史再跟随最新**:`GET /runs/:id/equity` 支持 `before` / `limit`
121
+ 分页,页面先从 `position.csv` 一页页回填更早的持仓快照并重算权益,再每 5 秒读最新页;
122
+ 视口贴着右端时自动平移跟随,用户拖到中间时不抢视口。
123
+ - **账户市值卡片标出初始资金与回本线**:权益图新增两条参考横线(初始资金、初始资金
124
+ + 已付手续费),右上角新增「自适应」按钮把视口拉回第一个采样点到最新点;标题下方
125
+ 给出两条线的文字图例。
126
+ - **决策流聚合成交与 agent 理由**:`trade.place` / HTTP 下单新增可选 `exitPlan`;
127
+ 决策卡显示 agent 写的 `basis`(开仓理由)与 `exitPlan`(退出计划)、开仓/平仓角色、
128
+ 成交进度条、`已成交 / 委托`、成交均价、手续费与这笔决策的盈亏。系统 `order.fill`
129
+ 信号按 `clientOrderId` 聚合进 intent 决策,不再单独成帖;部分成交显示真实百分比。
130
+ - **Run 详情页的身份卡与 KPI 改版**:左侧大字母块垂直居中,右侧依次是名称/状态/执行
131
+ 方式、启动时间、账户/引擎/风控/Agent 四枚带图标的事实;目录路径不再出现在身份卡
132
+ (只在 Files 卡画一次)。控制组只留「对账」和「停止/开始交易」:停止带图标、红色
133
+ 实底且比对账大一号,未运行时换成绿色;暂停与熔断开关从界面移除。
134
+ - **Run 详情页的 KPI 聚合为四张三行卡**:图标 + 标签 / 数值 / 同源附加信息(权益↔
135
+ 启动资金、盈亏↔收益率、今日↔手续费、敞口↔持仓市值;回测为期末权益/盈亏/最大
136
+ 回撤/成交数)。
137
+ - **Files 卡的文件名可直接在侧栏打开**:点击文件名经 sidebar 宿主打开对应文件;路径
138
+ 右边新增「复制路径」与「在文件树中打开」两个图标按钮。
139
+
140
+ - **指标引擎注册面改名**(子插件需要同步):服务 `investEngines` → `investIndicatorEngines`;
141
+ 契约类型 `InvestEngine` → `IndicatorEngine`、`EngineInfo` → `IndicatorEngineInfo`、
142
+ `EngineCapabilities` → `IndicatorEngineCapabilities`;执行体 I/O `EngineBar/Plot/Marker/
143
+ Warning/Output` → `Indicator*`;broker 清单里的 `engines` 字段改为 `indicatorEngines`。
144
+ 为什么叫指标引擎而不是因子引擎:这个注册面的输入是**单标的 bars**、输出是等长序列,
145
+ 横截面(因子)要的是面板(标的 × 时间),是另一个契约 —— 见
146
+ `docs/architecture/18-domain-relations.md` §5。
147
+
148
+ ### Removed
149
+
150
+ - 覆盖率门整条移除:`coverage` 脚本、vitest 的 `thresholds`、`@vitest/coverage-v8` 依赖与 CI 的那一步全部删除。行为测试改由 `pnpm run test` 承担(`check:all` 与 CI 里都是这一条),所以「测试照跑,只是不再报覆盖率」。
151
+
152
+ ### Added
153
+
154
+ - Agent 发起的 `human-approval`(以及插件规则返回 `ask`)订单改为在风控判定之后请求审批:优先走宿主 `ctx.userQuestions`(`ask_user_question` 同一面板),没有 answerer 时回退 `ctx.approval`;HTTP/UI 下单仍走待审批队列。`trade.place` 本身仍然没有 `approval` 参数。
155
+ - 创建策略的风控步骤改为**单选一个风控规则**(`/trading/risk-rules`),硬上限与规则自己的字段放进 `Strategy.riskRuleConfig`;账户不再携带 limits。内置完全权限/人类审批都执行这份 limits,插件可注册自动审查等自定义规则。策略引用的规则在启动时缺失会以 `risk-rule-missing` 拒绝,并由界面弹窗让用户显式选择是否降级为人类审批。
156
+ - **交易页的真实数据落地**(按 `docs/trading/ui.md`):
157
+ - **策略详情的文件表**改为读 `GET /dsh-invest/trading/portfolios/:id/files` 的真实
158
+ 目录清单:`strategy.md` 的字节与 mtime、`runs/` 的条数与最新 mtime;带加载/空/
159
+ 失败重试三态,旧 host 没有这条路由时退回两条已知项。About 的 Updated 也改用
160
+ `strategy.md` 的真实 mtime。
161
+ - **Run 页持仓的「全部平仓」**:二次确认后对每个持仓提交一笔只减仓市价单
162
+ (多头卖出、空头买回,`reduceOnly`);human-approval 模式下排队而不是声称已成交,
163
+ 部分失败仍保留已提交订单的手动标记与「你」的决策帖。
164
+ - **图表底部交易面板**的金额/数量改用统一格式化,订单方向与状态、触发器 kind/
165
+ 方向、journal 类型走字典;未知协议值原样保留,不再把 `open` 之类当成默认值。
166
+ - **「Session attached」徽标**只在策略 attach 到**当前**会话时显示。
167
+ - **交易页不再预填编出来的数值**:
168
+ - 手动下单抽屉的标的来自账户白名单或当前持仓,限价来自真实标记价;读不到就留空并
169
+ 禁用提交(不再预填 `BTC/USDT` / `0.01` / `60000`)。
170
+ - 触发器表单的标的同样来自账户/持仓,阈值不留假值;管理工作区的 Run 名与 slug
171
+ 不再预填常量,slug 随名字生成。
172
+ - 新建 paper 账户的初始资金变成可见、可改的输入;testnet/live 账户不再继承纸面
173
+ 那套 10 万上限与 `BTC/USDT` 白名单,改用保守默认(白名单为空 = 谁也不许)。
174
+
175
+ - **Binance 永续合约(USDⓈ-M)**:
176
+ - **数据源 `binancefutures`**:走 `/fapi/v1/klines`(响应与现货同形状,单页上限
177
+ 1500),预设/baseUrl/代理/图表周期与 ticker 折算都接进现有设置与 HostFeed。
178
+ id 不带连字符是硬约束:Vela 的标的解析只认 `[A-Za-z0-9_.]` 的 provider 段,
179
+ `BINANCE-FUTURES.HOST:BTCUSDT` 会被整串当成 ticker。
180
+ - **交易账户执行器 `binancefutures`**:签名/凭据/精度与现货共用
181
+ `src/host/trading/binance-rest.ts`。支持限价/市价、`reduceOnly`、多空双向持仓
182
+ (有符号 qty)、`cumQuote`/`avgPrice` 成交额、`/fapi/v2/account` 的钱包余额与
183
+ 保证金权益、探活/对账/撤单;未成交仍撤余量并报 `binance-order-not-filled`。
184
+ 凭据前缀与现货分开(`BINANCE_FUTURES[_TESTNET]`),但账户存储的凭据优先。
185
+ - 执行器的 `snapshot` 新增可选 `equity`:衍生品账户的权益不能由
186
+ `cash + Σ notional` 推出(仓位是敞口,不是买下的资产),运行时优先生效。
187
+ - 刻意不做:杠杆/保证金模式切换、一键全平、资金费率与强平价展示、双向持仓
188
+ (`positionSide`)—— 核心契约今天表达不了这些,塞进去只会是半套。
189
+ - **数据源偏好对话框**(图表页第一行的齿轮)—— 代理 / 金融数据 / 关于三个 tab:
190
+ - **代理**:只作用于 host 侧出站的 HTTP 代理(`http://`,可带 Basic 凭据;SOCKS 明确不支持)。
191
+ host 半自己实现 CONNECT 隧道(`src/host/feed/proxy.ts`),不为一条 GET 引入
192
+ `https-proxy-agent`;`createFetchJsonFor` 是唯一的传输选择点,行情与更新检查共用。
193
+ - **金融数据**:列出内置(可改名称/baseUrl/启用)与自定义数据源;支持增删自定义源、
194
+ 「设为当前」,并保存后把图表切到 `<PROVIDER>.HOST:<当前 ticker>`。
195
+ 自定义源走一条通用契约:`GET <urlTemplate>`(占位符 `{ticker}` `{timeframe}`
196
+ `{from}` `{to}` `{limit}`),响应为 `[time, open, high, low, close, volume]` 数组
197
+ 或 `{ bars: [...] }`,time 为 epoch 毫秒。
198
+ - **关于**:版本(构建时从 package.json 注入 `__PLUGIN_VERSION__`)、构建时间、
199
+ 许可证/作者/官网/GitHub/更新日志/问题反馈,以及「检查更新」
200
+ (`GET /dsh-invest/update-check`,读 GitHub latest release,读不到再退到 tags,
201
+ **失败折成可读结果而不是抛错**)。
202
+ - **设置持久化**:`<workspace>/.dsh-invest/settings.json`(工作区按会话解析,与 `chart.json`
203
+ 同一套),临时文件 → fsync → rename 的原子写;读失败/损坏回出厂值,从不抛错。
204
+ 纯模型在 `src/shared/settings.ts`(`normalizeSettings` 逐字段兜底、`activeProvider`
205
+ 必须是已启用的源、自定义 id 有形状与数量上界)。
206
+ - **`GET|PUT /dsh-invest/settings`**:loopback 门 + 1 MB body 上限 + 写入前规整;
207
+ `PUT` 返回**宿主规整后**的那份,界面以它为准。
208
+ - **行情按设置重建**:`createVenues` 把 settings 折成 venue 集合(内置 baseUrl 覆盖、
209
+ 通用 provider),`HostFeed` 仍是原来的 LRU + 单飞;`src/host/settings/runtime.ts`
210
+ 按工作区缓存实例并用设置签名判断重建,所以改代理/接口**不用重启宿主**。
211
+ - **客户端 Vela provider 表**:`createVelaProviders(ids)` 接受自定义 provider id,
212
+ `ChartController.ensureProviders` 支持页面打开后设置才到时补注册(注册本身会解开
213
+ 「provider 还没到」的挂起加载)。
214
+ - 100% 语句/行覆盖;新增用例覆盖设置规整、原子读写、代理隧道(本机 CONNECT 代理 +
215
+ TLS 自签夹具)、通用 provider、更新检查、对话框三个 tab、图表保存/失败/降级路径。
216
+
217
+ ### Added
218
+
219
+ - **把 client 半真的挂进宿主**(`src/client/index.ts`)—— 在此之前窗口层只是"写好了、测过了",
220
+ 但**没有任何东西会渲染它**:
221
+ - **`ctx.invest` 服务面**:`ctx.provide('invest', …)` 把工作空间服务交给子插件。
222
+ 提供不出去**不阻塞本插件**(界面照常,只是没有子插件能挂上来),所以它不抛、只报一句 ——
223
+ 一个 `provide` 失败就把整个 tab 干掉是不成比例的。为此在 `ClientCtx` 上补了可选的 `provide`。
224
+ - **窗口层挂进 `shell.overlay`**:宿主唯一的**帧级浮层**(`position: absolute; inset: 0;
225
+ z-index: 20; pointer-events: none`)。席位 id `dsh-invest.windows` 带命名空间前缀 ——
226
+ 那一席是**列表槽**,别的插件(例如 dsh-git 的提示条)也往上放东西。
227
+ - **作用域分工写清楚了**:这一层画的是**全局**窗口(跨会话常驻的盯盘类);
228
+ 会话作用域的窗口由会话作用域的承载面画(右侧栏 tab 正文,那里有 `sessionId`)——
229
+ 同一个组件、两个作用域,不是两套渲染。
230
+ - 六条新用例覆盖:服务面交出的**就是**那个对象(替身没有回读 `provide` 的访问器,
231
+ 所以在提供的那一刻记下来)、`provide` 缺席/被拒都只报警不抛、席位 id 与 name、
232
+ **组件工厂体真的能跑**(渲染出 `data-dsh-invest-window-layer="global"`)、
233
+ 二次挂载守卫、以及子插件的坏订阅者只被隔离并记一句而不冒到宿主。
234
+
235
+ - **窗口层**(`src/client/invest/window-layer.tsx`,真 React + jsdom 覆盖):把工作空间里的
236
+ 窗口画出来,并处理拖动与缩放。
237
+ - 参照项目为此返工过三处,最后固化成**四段**模式,缺一段就退化:
238
+ **ref + 直写 DOM**(否则每帧 `setState` ⇒ 每帧重渲染所有窗口)→
239
+ **每帧一次**(否则每次指针移动写一次 DOM) → **松手提交**(否则拖动期间每帧一次全工作台通知)→
240
+ **abort 路径**(否则拖拽状态永久卡住,窗口停在半路且没提交)。
241
+ - **拖动用 window 级监听,不在元素上 capture** —— 在容器上 capture 会重定向派生的 click
242
+ 并杀死窗口内部的按钮(参照项目注释里逐字记下的一条)。
243
+ - **量不到帧就不夹**:帧尺寸来自本层自己的 `clientWidth/Height`,量到 0 时若照样夹取,
244
+ 每个窗口都会被甩到角上。
245
+ - **最小化 = 收成一条标题栏,而不是消失** —— 没有任务栏时消失就再也拿不回来
246
+ (真正的任务栏是 [U-09](../../docs/architecture/04-window-manager.md#u-09) 的待决项)。
247
+ - **每个窗口一个错误边界**:一个子插件的渲染异常只弄坏它自己那一格。
248
+ - 调度器是**必填 prop**(与 `frame-batch.ts` 同一条理由:默认值会把"每帧最多写一次"
249
+ 这条性质从单元测试里搬走)。
250
+ - **注册表变化现在会让快照换新**(`service.ts`)。这是写窗口层时被测试逼出来的一个**真缺口**:
251
+ 注销一个窗口类型原本**不会**通知状态订阅者,于是那个窗口会一直按旧描述符渲染,
252
+ 或者停在旧内容上而用户不知道发生了什么。参照项目为此专门给 memo 加了 `registryVersion`;
253
+ 这里并进快照的 `version` —— 少一个需要被记住的字段。
254
+
255
+ ### Fixed
256
+
257
+ - **标题栏上的关闭按钮会顺便开始一次拖动**(由新用例 `点标题栏上的关闭按钮不会顺便把窗口拖走` 抓到)。
258
+ 事件从按钮冒泡到标题栏,move 拖动照常启动 —— 表现是"点关闭时窗口先动了一下"。
259
+ 修法是让标题栏上的交互元素**自己吃掉 `pointerdown`**;这与"别在容器上 capture"是同一个坑的两面。
260
+
261
+ - **窗口管理器的几何数学**(`src/client/invest/geometry.ts`,纯函数、零 DOM、100% 覆盖):
262
+ - `clampToFrame` —— **帧坐标**(不是视口)夹取,规则是"装得下就整个留在框内;
263
+ 装不下至少保住 `KEEP_ON_SCREEN = 48` 像素"(标题栏能拖回来 + 右下角缩放点能改回来)。
264
+ 这条来自参照项目的真机事故:窗口挂到屏幕底边外,连自己的缩放角都看不见。
265
+ 比帧还大的窗口**不被压窄** —— 压窄会改掉用户的布局意图。
266
+ - `resizeWindow` —— 八个把手落在**四条规则**上(把手名字含 `w`/`e`/`n`/`s` 就动那一侧),
267
+ 而不是八份会互相漂移的代码。缩到最小尺寸时**保持对侧不动**,否则到达下限那一刻
268
+ 窗口会整体跳一下。位移语义是"跟着指针走"(北/西把手的正位移是缩小)。
269
+ - `cascadeGeometry` —— 级联偏移**循环**(第 20 个窗口不该被推到框外)。
270
+ - `service.ts` 不再自己算级联:布局数学归 `geometry.ts`,服务只决定**什么时候**用它。
271
+ - **每帧批处理**(`src/client/frame-batch.ts`,100% 覆盖):把高频 `pointermove` 压成
272
+ "每帧最多写一次 DOM"。这是参照项目返工三处(分隔线、面板高度、中列跟踪)后固化下来的
273
+ 模式里的那一段。三个方法的语义各不相同且都容易写错:
274
+ `schedule` **覆盖**待提交值(连调十次只提交最后一次);`flush` **立刻**提交并取消已排的帧
275
+ (否则松手后窗口会再挪一下);`cancel` **丢掉且不提交**(中断路径上"用户想要的最后位置"
276
+ 根本不存在)。调度器**必须注入、没有默认值** —— 默认值会让"每帧一次"这条性质只能在
277
+ 真浏览器里被验证,而它是这个模块存在的全部理由。
278
+
279
+ - **核心的 client 半:插件宿主**(`src/client/invest/**`)—— 在此之前它**是 0 行**:
280
+ - `scope.ts` —— 作用域(会话 / 全局)与稳定键。单独成模块是为了让 `types.ts` 保持
281
+ **纯类型**(可以整体排除出覆盖率),而这里的一切都被测到。
282
+ - `types.ts` —— 子插件契约的类型面:窗口类型 / widget / 数据视图三种描述符,
283
+ 以及 `WindowComponentProps` / `DataViewProps` 等 props 形状。
284
+ - `service.ts` —— 三个注册表(复用 `shared/registry.ts`:同 id 抛错、disposer 身份检查、
285
+ 通知逐 listener 隔离)+ 每个作用域一份窗口状态。四条纪律:
286
+ ① **窗口"显示哪个标的"是派生值,不是字段**(`displayedInstrument`)——把标的复制进每个
287
+ 窗口会让"点哪只票所有图跟着走"失效,而没有 `link` 开关又会让"这一格钉死"做不到;
288
+ ② **快照对象只在变更时换新**(否则 `useSyncExternalStore` 会无限重渲染);
289
+ ③ **无操作不提交**(重复聚焦已置顶的窗口、把活动标的设成同一个值,都不自增版本 ——
290
+ 否则一次点击会让整个工作台重渲染);
291
+ ④ 描述符可声明 `requires`,由核心在注册时兜底拒绝 —— 在 `features` 探测之外的第二道防护。
292
+ - 联动位的语义直接照搬 OpenTerminal 的 `useWidgetSymbol`(见 [10 §1](../../docs/architecture/10-reference-review.md)):
293
+ `link ? 活动标的 : 自己绑定的 ?? 活动标的`。
294
+ - `InstrumentScope` 从 host 服务层**上提到契约层**(`shared/market/coverage.ts`):
295
+ 两半都要用它(host 选源、client 构造 `DataRequest`)。
296
+
297
+ - **服务层**(`src/host/service/market-data.ts`):选源 → 缓存 → 调用 → **校验回声** → 回填。
298
+ 四条不变量,每条对应一类**不会报错的错误**:
299
+ - **失败是返回值,不是异常** —— provider 抛出的任何东西(含**同步**抛出)都折成
300
+ `upstream-error` 的 `DataResult`。一个写坏的 provider 不该把服务打穿,否则
301
+ "某一只票取不到数"会变成"整个工作台白屏"。
302
+ - **失败不进缓存** —— 缓存失败等于把一次临时故障固化成一小时的错误,用户点"重试"
303
+ 时打不到上游(表现是"重试按钮是假的")。
304
+ - **缓存键必须含 `asOf` 与区间** —— 不含 `asOf` 会让一次回测读到另一代数据(只在
305
+ 数据被修订后才显形);不含区间会让"最近 5 根"与"最近 250 根"互相覆盖。
306
+ - **上游结果必须回声正确** —— 标的或频率对不上就是 provider 实现有误,那类错误不报错、
307
+ 只是数字属于另一只票;在这里拦下,并且**不缓存**。
308
+ 另加单飞(in-flight 共享):清理用 `.then` 而不是 `.finally` —— `then` 的回调**总在
309
+ 微任务里**跑,所以即使 provider 同步抛错、`work` 一上来就落定,清理也发生在
310
+ `inFlight.set` **之后**;用 `finally` 会留下一条永不消失的在飞记录,后续同样的请求
311
+ 永远拿到那个旧结果(单飞最经典的坑,有专门的用例钉住)。
312
+ - **provider 注册表**(`src/host/providers/registry.ts`):`register` / `list` / `descriptors` /
313
+ `plan`(给声明)/ **`select`(给可调用的运行时对象)** / `subscribe`。
314
+ 分成 `plan` 与 `select` 而不是让调用方自己按 id 二次查找:那次查找会多出一个
315
+ "理论上不可达"的分支 —— 要么覆盖率永远到不了,要么它真的可达(那就说明选源与查找
316
+ 用了两份不同的判据)。为此在契约层抽出 `providerCovers` 与 `notCoveredError`
317
+ (后者的理由是:"没有源"有两个产地,两处给出不同文案会让用户以为是两个问题)。
318
+ - **`describeError` 抽成 `src/shared/errors.ts`** —— 它此前被复制了四份,而"四份各自
319
+ 维护的 `instanceof Error` 判断"迟早会有一份忘了处理非 `Error` 抛出,表现是日志里
320
+ 一串 `undefined`,而那恰恰是最需要看清原因的时刻。
321
+
322
+ - **数据源层**(`src/host/providers/**`)与**双半共享的注册表原语**(`src/shared/registry.ts`):
323
+ - `types.ts` —— provider 的运行期契约。它**不直接 `fetch` / 读文件**,而是拿到一个
324
+ `ProviderPorts`(核心递进来的 IO)。这不是洁癖:红线"子插件不许绕过服务面自己取数"
325
+ 因此从文档变成了**类型约束** —— 拿不到 `fetch` 就没法绕过;顺带超时/限量/路径围栏
326
+ 这些义务只需在核心侧实施一次,而不是每个 provider 各写一遍(各写一遍总会有人忘)。
327
+ - `csv.ts` —— 用户自建 CSV 的**严格**解析器:未知列**报错不忽略**(否则"我明明写了
328
+ amount 却一直是空的"会被归咎于界面);不支持**半套** CSV 引号转义(那比不支持更糟,
329
+ 会让人以为这是通用 CSV 读器);`eventTime` 只接受 epoch 毫秒或 UTC 的
330
+ `YYYY-MM-DD`/ISO-8601(接受本地时间会让同一份文件在不同机器上解析出不同的历史);
331
+ 行序无关(结果按时间升序,因为 `SeriesResult` 就是这么承诺的);重复时刻报错。
332
+ - `user-file.ts` —— 第一种载体(C 类数据源)。**一个实例 = 一个标的 + 一个频率**,
333
+ 每次读取都重新读文件(文件是用户随时会改的东西,"改完界面没反应"是最难解释的问题;
334
+ 真正的缓存属于服务层的 as-of + revision)。
335
+ - `registry.ts` —— 通用注册表,把 better-sidebar 用真实事故换来的三条纪律固化成
336
+ 不可能写错的原语:同 id 重复注册**必须抛**(静默覆盖会让两个注册方都以为成功了);
337
+ disposer 做**身份检查**(否则旧实例的清理会删掉新实例);通知**逐个 listener 隔离**
338
+ (一个坏订阅者抛错曾把整个侧栏换成一条错误条)。
339
+ - **契约层补一个关键维度:标的身**。`DataNeed` 现在带 `instrumentId`,
340
+ `ProviderDescriptor` 可声明 `instruments`,选源时据此过滤。修的是一个**不报错的错误**:
341
+ 一份用户手写的 CSV 描述符若不声明"我只服务这一只票",同一个文件就会去回答**任何**
342
+ 标的的查询 —— 数字看着都对,只是属于另一只票。
343
+ - **新增失败码 `malformed-source`**:数据源本身坏了(CSV 少一列、日期写不成日期)。
344
+ 与 `upstream-error` **刻意分开** —— 它重试一万次也一样,要等人去改文件;合成一个码的
345
+ 后果是界面给出一个永远点不好的"重试"按钮,而那正是这份分类存在的理由。
346
+
347
+ - **存储门面 `InvestStore`**(`src/host/storage/store.ts` + `face.ts`):打开 domain、
348
+ 读写四张表、把状态如实回报出来。三条纪律都来自参照项目的真实事故:
349
+ - **打开失败不抛**,降级为内存态(数据仍可读可写,只是"重启后会丢"—— 而这句话
350
+ 必须**说得出来**);
351
+ - **状态是三个字段不是一个是非**:`storageError`(打开/读失败)、`lastWriteError`
352
+ (打开成功但写不进去)、`persistent`(两个失败面合一)。合并成一个 `error` 的后果是
353
+ health 撒谎:"打得开"被当成"存得住";
354
+ - **每个写方法返回 `PersistOutcome`** 而不是 `void`:`persisted` 与 `changed` 分开
355
+ (删一个不存在的键是 `changed: false, persisted: true`),于是调用点能区分
356
+ "写成功了"与"写失败了"—— 而路由层恰恰需要区分(磁盘满时不能回 `ok:true`)。
357
+ 写失败**不回滚内存**:本次运行里数据仍在,下一次写自然重试;代价由 `PersistOutcome`
358
+ 如实暴露。读走内存镜像但**仍过一遍校验器**(另一个版本写的记录不许静默放过)。
359
+ 关闭时把状态改回 `unavailable` 并记下原因 —— 否则调用点会拿到
360
+ `persisted: false, error: null` 这种无法排查的组合。
361
+ - **接进 `apply`**:存储走**独立的**一条可选注入(`ctx.inject(['storageDomain'])`),
362
+ 与 `webServer` 那条互不牵连:存储断了路由照常服务,反之亦然。
363
+ `/dsh-invest/health` 新增 `storage` 字段(三态自己表达区别,消费方不需要
364
+ "字段在不在"这种分支)。打开是异步的而 inject 回调是同步的,所以**不等它** ——
365
+ 等它会把整条激活链变成一个会 pending 的异步边界,而"打开失败"本来就是一条正常路径。
366
+ - **两个替身宿主说了假话,一并修好**:`scripts/verify.mjs` 与 `tests/host/handler.spec.ts`
367
+ 的 `ctx.inject` 原本**无条件**跑回调,而 cordis 的语义是**依赖齐了才跑**。
368
+ 第二条可选注入加进来时它立刻暴露(把 storage 那条 effect 也挂了,于是"抛错的 disposer
369
+ 不再是清理链里的第一个",一条断言悄悄失效)。修好之后替身才开始真的走"服务缺席"这条路。
370
+
371
+ - **数据基础架构第 ② 层:存储声明与校验**(`src/host/storage/**`,纯逻辑、无宿主服务、100% 覆盖):
372
+ - `validate.ts` —— 极小的校验器组合子(`str` / `num` / `int` / `bool` / `epoch` / `oneOf` /
373
+ `optionalOf` / `listOf` / `record`)+ 结构化深比较。**不引入 zod**:宿主对记录 schema 的
374
+ 唯一交互是 `valueSchema.parse(raw)`,所以需要的运行期面只有一个 `parse` —— 这与
375
+ 本仓库既有的"结构化重述 + 使用点再证形状"是同一条教条(风险与边界写进 COMPATIBILITY.md §7.9)。
376
+ 两个细节:`record` **不把 `undefined` 写进结果**(可选字段"缺席"必须保持缺席,
377
+ 否则"解析一遍"会改变对象形状);错误消息**带字段名**(那个消息的读者只有排查的人)。
378
+ - `shape.ts` —— 宿主 `DomainSpec` / `DomainTableSpec` 的结构化重述 + 我们自己的
379
+ `defineInvestDomain`(比宿主更严:至少一张表、**每张表必须声明 probe**)。
380
+ 外加 `selfCheckDomain` / `assertDomainSpec`:用 probe 做校验器往返比对,
381
+ 在**模块加载期**抓"TS 接口加了字段、校验器忘了加"这类**静默丢数据**
382
+ (dsh-git 用一段悔过注释换来的教训)。
383
+ - `records.ts` —— 四类用户数据:`watchlist`(自选)、`fills`(成交流水)、
384
+ `holdings`(持仓)、`providers`(数据源配置)。**`holdings` 是从 `fills` 物化出来的缓存,
385
+ 不是独立真相** —— 因为 storage domain 没有跨表事务,"改一笔成交"与"改一行持仓"之间
386
+ 必然有一个可能崩溃的窗口;把持仓定义成派生物,那个窗口就退化成"缓存略旧"(重算即自愈),
387
+ 而不是"账实不符"。金额类记录一律带 `currency`;`credentialsRef` 是**引用**不是凭据。
388
+ - `domains.ts` —— 一个 domain(`dsh_invest`)、四张表、`layout: 'per-record'`、**没有 `global`**
389
+ (偏好归 `Config`,顺带绕开宿主"global schema 必须拒绝 null"的哨兵陷阱)。
390
+ 记录键一律经 `toRecordKey` **单射编码**成 `[0-9a-f]`:把非法字符直接替换不是单射,
391
+ `a@b` 与 `a-b` 会撞进同一个键,而撞键的表现是**两条记录互相覆盖**。
392
+
393
+ - **数据基础架构第 ① 层:契约层**(`src/shared/market/**`,纯函数、零宿主依赖、100% 覆盖):
394
+ - `ids.ts` —— **`Instrument` ≠ `Listing`**:身份是内部生成的稳定 id,代码只是挂牌上的
395
+ 一个字段(代码会复用、会变更、A+H 两地不同、期货合约会到期)。`listingAt` 按期时点
396
+ 查挂牌,区间半开。`AssetClass` 是**封闭**集合,因为类别决定归一化规则;
397
+ - `time.ts` —— 频率(大小写敏感:`1m` ≠ `1M`)+ **`AsOf` 必填**(`latest` / `at(t)`):
398
+ 全局开关的表现是"忘了传就默认读最新",而那个默认值在回测里恰好最坏。
399
+ 月/季/年**不给**固定毫秒数(依赖日历,乘法会让时间窗月末偏移一格);
400
+ - `result.ts` —— **失败是返回值,不是异常**:`DataResult` 带机器可读 code,`retryable`
401
+ 由 code 推导(`not-covered` / `no-provider` 刻意不可重试 —— 自动重试只会把真正的故障
402
+ 埋进日志)。`requestKey` 不含 as-of 就是最隐蔽的一类错误,所以它必填且字段先排序;
403
+ - `coverage.ts` —— **「全覆盖」的可执行形式**:`cellCovers` 要求市场/品种相等、
404
+ 频率与字段**全包含**(少一个字段就不是这次读取的数据源);`planProvider` 把
405
+ "没有源"变成一条 `not-covered` 说明,而不是 `undefined` 或抛错。新增一个市场 =
406
+ 新增一格能力,而不是修改任何分支;
407
+ - `series.ts` —— wire 契约(纯类型,与实现面分开存放)。**`Bar` 上刻意没有
408
+ `knowledgeTime`**:一个响应要么整份来自同一代数据、要么整份不算数,知识时间放在
409
+ 信封上(`asOf` + `revision`)。
410
+ - `vitest.config.ts` 把 `src/shared/market/series.ts` 加进覆盖率排除(纯类型,无可插桩面)。
411
+
412
+ ### Changed
413
+
414
+ - **仓库改为 npm workspaces,核心包搬到 `packages/dsh-invest`(M0)**:
415
+ - 包名、`dsh` 清单、`cordis.patch.yml`、`locale` 命名空间、host 路由前缀**全部不变** ——
416
+ 这几个身份一起构成"已装用户能不能升级",搬目录不许碰它们;
417
+ - 根目录只剩 workspace 清单、锁文件、仓库级文档(`AGENTS.md` / `COMPATIBILITY.md` /
418
+ `CONTRIBUTING.md`)与**转发脚本**;`npm run check:all` 仍然在根目录一条命令跑完整条链;
419
+ - 构建工具链(`scripts/`)**暂不上提**,随包走;等第二个子插件落地时再抽到根并参数化;
420
+ - `verify-plugin.mjs` 的文档断言按归属拆成两组:包内文档(随 `npm pack` 出去)
421
+ 与仓库根文档(不进包)—— 这三份文档**没有**被搬进包里凑绿灯;
422
+ - `release.yml` 的版本号真源改读 `packages/dsh-invest/package.json`,
423
+ 发布改用 `npm publish -w dsh-invest`(在根上发布会打到 private 的 workspace 根)。
424
+ - **架构文档落地**(`docs/architecture/`):覆盖模型(全市场 / 全生命周期 / 时空全覆盖)、
425
+ 仓库与包边界、核心插件双半与服务面、窗口管理器、数据基础架构、子插件契约、
426
+ 信任与路线图、工程约定与宿主接缝勘误、决策台账。
427
+ `.gitignore` 不再忽略 `/docs` —— 进不了版本库的设计文档等于不存在。
428
+
429
+ 补齐对照同类插件(`dsh-context`)识别出的基础设施缺口:国际化、设置面板、
430
+ 响应式布局、二次挂载守卫、错误边界、真渲染测试、开发闭环与兼容性决定文档。
431
+ **不新增任何投资能力**,界面仍然不发请求。
432
+
433
+ ### Added
434
+
435
+ - **国际化**:
436
+ - `src/shared/locale.ts` —— 语言 id、双语文本类型与解析,默认语言为英文;
437
+ - `src/shared/stages.ts` 的阶段文案改为**双语**(两种语言都必填,是编译期不变量),
438
+ 因为这份模型同时是 HTTP 契约;
439
+ - `src/client/i18nZh.ts` / `i18nEn.ts` —— 界面字典,英文那份以
440
+ `satisfies Record<DictKey, string>` 声明,少翻一条或多留一条废弃 key 都是编译错误;
441
+ - `src/client/viewkit.ts` + `src/client/locale-source.ts` —— 语言面走延迟注入,
442
+ 晚到也能让**已经渲染**的面板重渲染;没有语言插件时回退到本地字典(界面出英文),
443
+ 而不是显示原始 key;
444
+ - `locale/{zh,en}.json` —— 插件展示元数据(插件管理页读它);
445
+ - `package.json` 补 `icon` 字段(`icon.svg` 之前随包发布但没被声明,所以插件页没有图标)。
446
+ - **设置面板**(`设置 → 插件 → dsh-invest`):
447
+ - `src/config.ts` —— schemastery `Config`,同时驱动 cordis 的校验与 Plugins 页的表单;
448
+ 每个字段 `.volatile()`(在线改不重挂 entry)+ `.loose()`(过期值降级为默认);
449
+ - `src/shared/prefs.ts` —— 偏好字段名 / 取值集合 / 默认值的运行时单一真源;
450
+ - `src/client/settings.ts` —— 可观察 store,写入走「本地回显 → 受围栏的写 → 被拒则回滚」;
451
+ - 三个偏好:显示产出物、显示常见错误、行密度;
452
+ - 命名空间未被服务时不出现这张卡片(`configForms.whileServed`)。
453
+ - **响应式布局**:样式表改为**容器查询**(面板宽度跟随侧栏折叠与拖拽,视口宽度不变),
454
+ 窄面板下折行而不是压碎。
455
+ - **二次挂载守卫**(`entrySeated`):client-plugin runner 会为每次注册强制分配新的
456
+ shadowing priority,导致席位注册表自带的同 id 去重在跨挂载时永不生效,每个席位会被
457
+ 画两遍。守卫读原始账本,在 `apply` 开头与注入回调**内部**各判一次(后者挡竞态窗口)。
458
+ - **错误边界**(`InvestErrorBoundary`):宿主自己的边界只留空占位,这个边界给出说明与
459
+ 可用的重试;正文席位已经接上它。
460
+ - **数据态约定**(`AGENTS.md`)与它的第一条活路径:
461
+ - **展示面**(面板)**fail open 到默认值**并把完整界面画出来 —— 为了一个尚未到达的
462
+ 偏好把整页压成空白,会让打开过程看起来像坏了;
463
+ - **编辑面**(设置卡片)在 Host 分段到达前**不画值行**,只说「正在读取」——
464
+ 把默认值画成"当前设置"是在告诉用户一个尚未读到的答案;
465
+ - 两条都有对应用例钉住(`tests/client/invest-page.spec.tsx`、`tests/client/apply.spec.tsx`);
466
+ - 行情/持仓的 loading / 空 / 陈旧三态**仍是契约而非代码**,理由见 COMPATIBILITY.md。
467
+ - **测试**(vitest,`host` 走 node / `client` 走 jsdom + 真 React 渲染器,共 180+ 用例):
468
+ - `tests/client/helpers/harness.ts` —— 忠实实现 cordis 语义的替身(挂起注入在服务到达时
469
+ 重放、服务替换时重挂依赖它的回调、effect 按 LIFO 清理),以及带原始账本的席位替身;
470
+ - 覆盖服务缺失 / 晚到 / 替换、命名空间未被服务、二次挂载与竞态、错误边界与重试、
471
+ 双语切换、偏好开关与回滚;
472
+ - 覆盖率棘轮:语句与行 100%,分支与函数 95%。
473
+ - **开发闭环**:`npm run dev:watch`(esbuild 监听,并说明「该重启 dsh」还是「刷新页面」)、
474
+ `dev:web`(重启 dsh web)、`dev:register`(注册到 profile)。
475
+ - **文档**:`AGENTS.md`(改动者约定与三层验证链的分工)、`COMPATIBILITY.md`
476
+ (兼容性决定、locale 协议、已接受的限制与覆盖缺口)。
477
+
478
+ ### Changed
479
+
480
+ - **`npm run verify` 不再假装渲染 React 树**:替身环境只提供 `createElement`/`Component`
481
+ (新增 `Component` 是必需的 —— 产物里的错误边界在模块求值时就 `extends` 它),
482
+ 渲染断言全部移到 vitest 那一层。原先「手写调用函数组件」的做法在结构上抓不到钩子、
483
+ reconciliation 与 DOM 相关的问题,属于假绿灯。
484
+ - `npm run typecheck` 同时检查 `src` 与 `tests`(新增 `tsconfig.tests.json`)。
485
+ - `npm run check:all` 与 CI 增加行为测试 + 覆盖率棘轮。
486
+ - `src/shared/stages.ts` 的 `title` / `question` / `artifacts` / `pitfalls` 从字符串改为
487
+ 双语对象;`GET /dsh-invest/stages` 的响应因此与语言无关(新增 `locales` 与
488
+ `defaultLocale` 字段)。`GET /dsh-invest/health` 新增 `locales` 与 `prefs`
489
+ (回报**生效**的偏好,便于诊断「设置到底有没有生效」)。
490
+ - 样式表的真规矩被澄清:不是「套在 `.dsh-invest-root` 之下」,而是**每个顶层选择器都是
491
+ `.dsh-invest-*` 类**(扁平命名空间,效果等价且可断言)。
492
+ - `@types/react` / `@types/react-dom` 对齐到 18(与 peer 范围一致,之前是 19)。
493
+
494
+ ### Notes
495
+
496
+ - **本包仍然不依赖任何 `@deepseek-ai/dsh-*` 包**,因此 harness 的插件兼容预检对它
497
+ **没有意见**,也仍然能在任何 dsh 版本上加载。唯一的 `@deepseek-ai/*` 依赖是
498
+ `schemastery`(设置 schema 必需),而它不在预检审查的那两族 peer 里。
499
+ 这是一个**知情的取舍**,理由与「要加门禁时该怎么做」写在
500
+ [COMPATIBILITY.md](../../COMPATIBILITY.md)。
501
+ - 刻意**不做**:跳转设置的 DOM 驱动、更新提示、兼容矩阵测试、类型产物、投影单元。
502
+ 逐条理由见 COMPATIBILITY.md 的「已接受的限制」。
503
+
6
504
  ## [0.1.0] - 2026-10-08
7
505
 
8
506
  首个版本,**预览版**:目标是确立 npm 包名 `dsh-invest`,并把双半插件的装载链路