@dshtrading/watchlist 0.4.1 → 0.6.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/lib/file-store.js CHANGED
@@ -1,14 +1,17 @@
1
1
  import { WATCHLIST_SEEDS } from "./seeds.js";
2
2
  import { newWatchlistGroupId, normalizeWatchlistRow } from "./index.js";
3
- import { writeJsonAtomic } from "@dshtrading/dsh-home";
3
+ import { transactStore } from "@dshtrading/dsh-home";
4
4
  import { readFile } from "node:fs/promises";
5
5
  //#region src/file-store.ts
6
6
  /**
7
- * 文件持久化版自选/选中 store(Node.js 宿主侧使用,tmp+rename 原子写,
8
- * indicators/custom-fs 同款模式——issue #32 规格)。
7
+ * 文件持久化版自选/选中 store(Node.js 宿主侧使用,issue #32 规格)。
8
+ *
9
+ * 写路径经 `@dshtrading/dsh-home` 的 {@link transactStore}——跨进程排他锁 + 锁内
10
+ * 新鲜读盘 + 原子写(2026-10-09 多进程互相覆盖事故修复):桌面端与 CLI 各写一行时
11
+ * 双方都留在盘上,不再互相抹掉(此前是内存缓存 + 整表回写)。进程内的 enqueue 串行化
12
+ * 保留(它保证同一进程内的读改写顺序),跨进程部分由锁承担。
9
13
  */
10
- /** 原子写唯一实现在 @dshtrading/dsh-home(tmp+rename、EPERM/EBUSY 重试、失败保留旧文件并清理 tmp)。 */
11
- const safeAtomicWrite = (filePath, data) => writeJsonAtomic(filePath, data, "[dsh-trading/watchlist] failed to atomic flush watchlists to");
14
+ const LOG_PREFIX = "[dsh-trading/watchlist] failed to atomic flush watchlists to";
12
15
  function createFileWatchlistStore(filePath) {
13
16
  let cache = null;
14
17
  let pendingWrite = Promise.resolve();
@@ -17,122 +20,123 @@ function createFileWatchlistStore(filePath) {
17
20
  pendingWrite = next.then(() => {}, () => {});
18
21
  return next;
19
22
  };
20
- async function load() {
21
- if (cache !== null) return cache;
23
+ /** 新鲜读盘(坏 JSON/非对象按空表);供 load 与锁内重读共用。 */
24
+ async function readFromDisk() {
22
25
  try {
23
26
  const content = await readFile(filePath, "utf8");
24
27
  const parsed = JSON.parse(content);
25
- cache = parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
28
+ return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
26
29
  } catch (err) {
27
30
  if (err?.code !== "ENOENT") console.error(`[dsh-trading/watchlist] failed to read watchlists from ${filePath}:`, err);
28
- cache = {};
31
+ return {};
29
32
  }
30
- return cache;
31
- }
32
- /** 仅在 enqueue 队列内调用:写盘不能二次入队(外层已持队,内层 enqueue 会自等死锁)。 */
33
- function writeNow(map) {
34
- return safeAtomicWrite(filePath, map);
35
33
  }
36
- async function flush(map) {
37
- return enqueue(async () => {
38
- await writeNow(map);
39
- });
34
+ async function load() {
35
+ if (cache !== null) return cache;
36
+ cache = await readFromDisk();
37
+ return cache;
40
38
  }
41
39
  return {
42
40
  async list() {
43
41
  return { ...await load() };
44
42
  },
45
43
  async save(next) {
46
- cache = { ...next };
47
- await flush(cache);
44
+ await enqueue(async () => {
45
+ cache = await transactStore(filePath, readFromDisk, () => ({ ...next }), (map) => map, LOG_PREFIX);
46
+ });
48
47
  },
49
48
  async add(market, instrument) {
50
- return enqueue(async () => {
51
- const map = await load();
52
- const rows = map[market] ?? [];
53
- if (rows.some((row) => row.symbol === instrument.symbol)) return false;
54
- const next = {
55
- ...map,
56
- [market]: [...rows, normalizeWatchlistRow(instrument)]
57
- };
58
- cache = next;
59
- await writeNow(next);
60
- return true;
49
+ let added = false;
50
+ await enqueue(async () => {
51
+ cache = await transactStore(filePath, readFromDisk, (onDisk) => {
52
+ const rows = onDisk[market] ?? [];
53
+ if (rows.some((row) => row.symbol === instrument.symbol)) return onDisk;
54
+ added = true;
55
+ return {
56
+ ...onDisk,
57
+ [market]: [...rows, normalizeWatchlistRow(instrument)]
58
+ };
59
+ }, (map) => map, LOG_PREFIX);
61
60
  });
61
+ return added;
62
62
  },
63
63
  async remove(market, symbol) {
64
- return enqueue(async () => {
65
- const map = await load();
66
- const existing = map[market];
67
- const rows = Array.isArray(existing) ? existing : WATCHLIST_SEEDS[market] ?? [];
68
- const nextRows = rows.filter((row) => row.symbol !== symbol);
69
- if (nextRows.length === rows.length) return false;
70
- const next = {
71
- ...map,
72
- [market]: nextRows
73
- };
74
- cache = next;
75
- await writeNow(next);
76
- return true;
64
+ let removed = false;
65
+ await enqueue(async () => {
66
+ cache = await transactStore(filePath, readFromDisk, (onDisk) => {
67
+ const existing = onDisk[market];
68
+ const rows = Array.isArray(existing) ? existing : WATCHLIST_SEEDS[market] ?? [];
69
+ const nextRows = rows.filter((row) => row.symbol !== symbol);
70
+ if (nextRows.length === rows.length) return onDisk;
71
+ removed = true;
72
+ return {
73
+ ...onDisk,
74
+ [market]: nextRows
75
+ };
76
+ }, (map) => map, LOG_PREFIX);
77
77
  });
78
+ return removed;
78
79
  },
79
80
  async assignGroup(market, symbol, groupId, member) {
80
- return enqueue(async () => {
81
- const map = await load();
82
- const rows = map[market];
83
- if (!Array.isArray(rows)) return false;
84
- const index = rows.findIndex((row) => row.symbol === symbol);
85
- if (index < 0) return false;
86
- const base = rows[index];
87
- if (base === void 0) return false;
88
- const current = Array.isArray(base.groups) ? base.groups : [];
89
- if (member === current.includes(groupId)) return false;
90
- const nextGroups = member ? [...current, groupId] : current.filter((id) => id !== groupId);
91
- const nextRows = [...rows];
92
- nextRows[index] = normalizeWatchlistRow({
93
- market: base.market,
94
- symbol: base.symbol,
95
- ...base.name !== void 0 ? { name: base.name } : {},
96
- ...nextGroups.length > 0 ? { groups: nextGroups } : {}
97
- });
98
- const next = {
99
- ...map,
100
- [market]: nextRows
101
- };
102
- cache = next;
103
- await writeNow(next);
104
- return true;
81
+ let assigned = false;
82
+ await enqueue(async () => {
83
+ cache = await transactStore(filePath, readFromDisk, (onDisk) => {
84
+ const rows = onDisk[market];
85
+ if (!Array.isArray(rows)) return onDisk;
86
+ const index = rows.findIndex((row) => row.symbol === symbol);
87
+ if (index < 0) return onDisk;
88
+ const base = rows[index];
89
+ if (base === void 0) return onDisk;
90
+ const current = Array.isArray(base.groups) ? base.groups : [];
91
+ if (member === current.includes(groupId)) return onDisk;
92
+ const nextGroups = member ? [...current, groupId] : current.filter((id) => id !== groupId);
93
+ const nextRows = [...rows];
94
+ nextRows[index] = normalizeWatchlistRow({
95
+ market: base.market,
96
+ symbol: base.symbol,
97
+ ...base.name !== void 0 ? { name: base.name } : {},
98
+ ...base.form !== void 0 ? { form: base.form } : {},
99
+ ...base.assetClass !== void 0 ? { assetClass: base.assetClass } : {},
100
+ ...nextGroups.length > 0 ? { groups: nextGroups } : {}
101
+ });
102
+ assigned = true;
103
+ return {
104
+ ...onDisk,
105
+ [market]: nextRows
106
+ };
107
+ }, (map) => map, LOG_PREFIX);
105
108
  });
109
+ return assigned;
106
110
  },
107
111
  async stripGroup(groupId) {
108
- return enqueue(async () => {
109
- const map = await load();
110
- let cleaned = 0;
111
- const next = {};
112
- for (const [market, rows] of Object.entries(map)) {
113
- if (!Array.isArray(rows)) continue;
114
- let changed = false;
115
- const nextRows = rows.map((row) => {
116
- const groups = Array.isArray(row.groups) ? row.groups : [];
117
- if (!groups.includes(groupId)) return row;
118
- changed = true;
119
- cleaned++;
120
- const rest = groups.filter((id) => id !== groupId);
121
- return normalizeWatchlistRow({
122
- market: row.market,
123
- symbol: row.symbol,
124
- ...row.name !== void 0 ? { name: row.name } : {},
125
- ...rest.length > 0 ? { groups: rest } : {}
112
+ let cleaned = 0;
113
+ await enqueue(async () => {
114
+ cache = await transactStore(filePath, readFromDisk, (onDisk) => {
115
+ const next = {};
116
+ for (const [market, rows] of Object.entries(onDisk)) {
117
+ if (!Array.isArray(rows)) continue;
118
+ let changed = false;
119
+ const nextRows = rows.map((row) => {
120
+ const groups = Array.isArray(row.groups) ? row.groups : [];
121
+ if (!groups.includes(groupId)) return row;
122
+ changed = true;
123
+ cleaned++;
124
+ const rest = groups.filter((id) => id !== groupId);
125
+ return normalizeWatchlistRow({
126
+ market: row.market,
127
+ symbol: row.symbol,
128
+ ...row.name !== void 0 ? { name: row.name } : {},
129
+ ...row.form !== void 0 ? { form: row.form } : {},
130
+ ...row.assetClass !== void 0 ? { assetClass: row.assetClass } : {},
131
+ ...rest.length > 0 ? { groups: rest } : {}
132
+ });
126
133
  });
127
- });
128
- next[market] = changed ? nextRows : rows;
129
- }
130
- if (cleaned > 0) {
131
- cache = next;
132
- await writeNow(next);
133
- }
134
- return cleaned;
134
+ next[market] = changed ? nextRows : rows;
135
+ }
136
+ return cleaned > 0 ? next : onDisk;
137
+ }, (map) => map, LOG_PREFIX);
135
138
  });
139
+ return cleaned;
136
140
  }
137
141
  };
138
142
  }
@@ -144,65 +148,87 @@ function createFileWatchlistGroupsStore(filePath) {
144
148
  pendingWrite = next.then(() => {}, () => {});
145
149
  return next;
146
150
  };
147
- async function load() {
148
- if (cache !== null) return cache;
151
+ /** 新鲜读盘(容器包裹;坏形过滤);供 load 与锁内重读共用。 */
152
+ async function readFromDisk() {
149
153
  try {
150
154
  const content = await readFile(filePath, "utf8");
151
155
  const parsed = JSON.parse(content);
152
- cache = Array.isArray(parsed.groups) ? parsed.groups.filter((group) => typeof group === "object" && group !== null && typeof group.id === "string" && group.id !== "" && typeof group.name === "string") : [];
156
+ return Array.isArray(parsed.groups) ? parsed.groups.filter((group) => typeof group === "object" && group !== null && typeof group.id === "string" && group.id !== "" && typeof group.name === "string") : [];
153
157
  } catch (err) {
154
158
  if (err?.code !== "ENOENT") console.error(`[dsh-trading/watchlist] failed to read watchlist groups from ${filePath}:`, err);
155
- cache = [];
159
+ return [];
156
160
  }
161
+ }
162
+ async function load() {
163
+ if (cache !== null) return cache;
164
+ cache = await readFromDisk();
157
165
  return cache;
158
166
  }
159
- function writeNow(groups) {
160
- return safeAtomicWrite(filePath, { groups });
167
+ const serialize = (groups) => ({ groups });
168
+ /** 跨进程读改写;返回 null 表示无变化(不落盘),缓存仍取锁内新鲜值。 */
169
+ async function commit(mutate) {
170
+ cache = await transactStore(filePath, readFromDisk, (onDisk) => mutate(onDisk) ?? onDisk, serialize, LOG_PREFIX);
171
+ return cache;
161
172
  }
162
173
  return {
163
174
  async list() {
164
175
  return [...await load()];
165
176
  },
166
177
  async create(name) {
167
- return enqueue(async () => {
168
- const groups = await load();
169
- if (groups.some((group) => group.name === name)) return { error: "duplicate" };
170
- const group = {
171
- id: newWatchlistGroupId(),
172
- name,
173
- createdAt: Date.now()
174
- };
175
- cache = [...groups, group];
176
- await writeNow(cache);
177
- return { group };
178
+ let result;
179
+ await enqueue(async () => {
180
+ await commit((onDisk) => {
181
+ if (onDisk.some((group) => group.name === name)) {
182
+ result = { error: "duplicate" };
183
+ return null;
184
+ }
185
+ const group = {
186
+ id: newWatchlistGroupId(),
187
+ name,
188
+ createdAt: Date.now()
189
+ };
190
+ result = { group };
191
+ return [...onDisk, group];
192
+ });
178
193
  });
194
+ return result ?? { error: "duplicate" };
179
195
  },
180
196
  async rename(id, name) {
181
- return enqueue(async () => {
182
- const groups = await load();
183
- const existing = groups.find((group) => group.id === id);
184
- if (existing === void 0) return { error: "not-found" };
185
- if (groups.some((group) => group.id !== id && group.name === name)) return { error: "duplicate" };
186
- cache = groups.map((group) => group.id === id ? {
187
- ...group,
188
- name
189
- } : group);
190
- await writeNow(cache);
191
- return { group: {
192
- ...existing,
193
- name
194
- } };
197
+ let result;
198
+ await enqueue(async () => {
199
+ await commit((onDisk) => {
200
+ const existing = onDisk.find((group) => group.id === id);
201
+ if (existing === void 0) {
202
+ result = { error: "not-found" };
203
+ return null;
204
+ }
205
+ if (onDisk.some((group) => group.id !== id && group.name === name)) {
206
+ result = { error: "duplicate" };
207
+ return null;
208
+ }
209
+ result = { group: {
210
+ ...existing,
211
+ name
212
+ } };
213
+ return onDisk.map((group) => group.id === id ? {
214
+ ...group,
215
+ name
216
+ } : group);
217
+ });
195
218
  });
219
+ return result ?? { error: "not-found" };
196
220
  },
197
221
  async remove(id) {
198
- return enqueue(async () => {
199
- const groups = await load();
200
- const next = groups.filter((group) => group.id !== id);
201
- if (next.length === groups.length) return false;
202
- cache = next;
203
- await writeNow(next);
204
- return true;
222
+ let removed = false;
223
+ await enqueue(async () => {
224
+ await commit((onDisk) => {
225
+ const next = onDisk.filter((group) => group.id !== id);
226
+ if (next.length === onDisk.length) return null;
227
+ removed = true;
228
+ return next;
229
+ });
205
230
  });
231
+ return removed;
206
232
  }
207
233
  };
208
234
  }
@@ -214,22 +240,21 @@ function createFileSelectionStore(filePath) {
214
240
  pendingWrite = next.then(() => {}, () => {});
215
241
  return next;
216
242
  };
217
- async function load() {
218
- if (cache !== null) return cache;
243
+ /** 新鲜读盘(坏形按空选中);供 load 与锁内重读共用。 */
244
+ async function readFromDisk() {
219
245
  try {
220
246
  const content = await readFile(filePath, "utf8");
221
247
  const parsed = JSON.parse(content);
222
- cache = parsed !== null && typeof parsed === "object" ? { instrument: parsed.instrument ?? null } : { instrument: null };
248
+ return parsed !== null && typeof parsed === "object" ? { instrument: parsed.instrument ?? null } : { instrument: null };
223
249
  } catch (err) {
224
250
  if (err?.code !== "ENOENT") console.error(`[dsh-trading/watchlist] failed to read selection from ${filePath}:`, err);
225
- cache = { instrument: null };
251
+ return { instrument: null };
226
252
  }
227
- return cache;
228
253
  }
229
- async function flush(record) {
230
- return enqueue(async () => {
231
- await safeAtomicWrite(filePath, record);
232
- });
254
+ async function load() {
255
+ if (cache !== null) return cache;
256
+ cache = await readFromDisk();
257
+ return cache;
233
258
  }
234
259
  return {
235
260
  async get() {
@@ -237,8 +262,9 @@ function createFileSelectionStore(filePath) {
237
262
  return { instrument: record.instrument === null ? null : { ...record.instrument } };
238
263
  },
239
264
  async set(record) {
240
- cache = { instrument: record.instrument === null ? null : { ...record.instrument } };
241
- await flush(cache);
265
+ await enqueue(async () => {
266
+ cache = await transactStore(filePath, readFromDisk, () => ({ instrument: record.instrument === null ? null : { ...record.instrument } }), (value) => value, LOG_PREFIX);
267
+ });
242
268
  }
243
269
  };
244
270
  }
package/lib/index.d.ts CHANGED
@@ -1,35 +1,20 @@
1
1
  import { WATCHLIST_SEEDS, effectiveWatchlistRows, watchlistRowSource } from "./seeds.js";
2
+ import { InstrumentAssetClass, InstrumentForm } from "@dshtrading/api";
2
3
  //#region src/index.d.ts
3
- /**
4
- * @dshtrading/watchlist —— host 侧自选股与选中标的存储(issue #32 / P3)。
5
- *
6
- * 背景:自选股原先只存浏览器 localStorage(dshtrading.watchlist.v1),node 半的
7
- * Agent 触达不了——四块能力中 Agent 工具面唯一完全空白的一块。owner 2026-08-31
8
- * 裁决(D3):存储升位 localStorage → host file store(含一次性迁移),localStorage
9
- * 降级为缓存镜像,不再是 SSOT。
10
- *
11
- * 本模块(纯类型 + 内存 store,浏览器安全):
12
- * - `WatchlistsMap`:按 market 分桶的多列表行(rows 只存用户定制行;市场种子列表
13
- * 属客户端展示回退,不进 host store——空 market = 未定制,客户端回落种子)。
14
- * 行可带 `groups`(自定义分组 id 多归属,issue #82);分组注册表另存
15
- * watchlist-groups.json(`./file-store.ts` 的 groups store)。
16
- * - `SelectionRecord`:跨市场选中标的(watchlist_select 工具与左栏点击同源)。
17
- * - `./plugin` 子路径:file store(~/.dsh/watchlists.json、~/.dsh/selection.json、
18
- * ~/.dsh/watchlist-groups.json,tmp+rename 原子写)+ host 平面工具
19
- * watchlist_list/add/remove/select(全会话可见,owner 裁决 D4;select/add/remove
20
- * 后 emit tradingEvents('watchlists'|'selection'))。
21
- *
22
- * 词汇纪律:symbol 用市场规范形(docs/symbol-vocabulary.md),本包不做归一化
23
- * (写入方负责——工具参数与桥端点均原样落盘)。
24
- *
25
- * @module @dshtrading/watchlist
26
- */
27
4
  /** 跨市场标的行(market 为市场词汇 slug:crypto | us | cn | hk,开放新市场)。 */
28
5
  interface WatchlistInstrument {
29
6
  market: string;
30
7
  symbol: string;
31
8
  /** 展示名(可选;工具添加可缺省,客户端以 symbol 兜底展示)。 */
32
9
  name?: string;
10
+ /**
11
+ * 形态轴(2026-10-08 加密永续落地,词汇家在 `@dshtrading/api`):缺省 = 现货。
12
+ * 读侧判据与 catalog 同款——显式值优先,缺省回落 `instrumentFormOf(symbol)`
13
+ * (符号后缀即形态载体,见 docs/guides/symbol-vocabulary.md)。
14
+ */
15
+ form?: InstrumentForm;
16
+ /** 资产类别(交易所元数据原样透传;取不到即留空,禁止按符号猜——SPX/US500 反例)。 */
17
+ assetClass?: InstrumentAssetClass;
33
18
  /**
34
19
  * 所属自定义分组 id 数组(issue #82;多归属,一行可入多组;缺省/空 = 未分组)。
35
20
  * 分组注册表(名称等元数据)在 ./file-store.ts 的 groups store,行上只存 id。
@@ -79,7 +64,7 @@ interface SelectionStore {
79
64
  get(): Promise<SelectionRecord>;
80
65
  set(record: SelectionRecord): Promise<void>;
81
66
  }
82
- /** 行规范化副本:groups 空数组不落键,保持落盘/输出形状干净。 */
67
+ /** 行规范化副本:groups 空数组不落键,保持落盘/输出形状干净;形态/资产类别可选项原样保真。 */
83
68
  declare function normalizeWatchlistRow(row: WatchlistInstrument): WatchlistInstrument;
84
69
  /** 新分组 id(时间戳 base36 + 随机尾,进程内唯一即可——注册表单文件单写者)。 */
85
70
  declare function newWatchlistGroupId(): string;
package/lib/index.js CHANGED
@@ -1,12 +1,14 @@
1
1
  import { WATCHLIST_SEEDS, effectiveWatchlistRows, watchlistRowSource } from "./seeds.js";
2
2
  //#region src/index.ts
3
- /** 行规范化副本:groups 空数组不落键,保持落盘/输出形状干净。 */
3
+ /** 行规范化副本:groups 空数组不落键,保持落盘/输出形状干净;形态/资产类别可选项原样保真。 */
4
4
  function normalizeWatchlistRow(row) {
5
5
  const groups = Array.isArray(row.groups) ? [...new Set(row.groups.filter((item) => typeof item === "string" && item))] : [];
6
6
  return {
7
7
  market: row.market,
8
8
  symbol: row.symbol,
9
9
  ...row.name !== void 0 ? { name: row.name } : {},
10
+ ...row.form !== void 0 ? { form: row.form } : {},
11
+ ...row.assetClass !== void 0 ? { assetClass: row.assetClass } : {},
10
12
  ...groups.length > 0 ? { groups } : {}
11
13
  };
12
14
  }
@@ -59,6 +61,8 @@ function createMemoryWatchlistStore(initial = {}) {
59
61
  market: base.market,
60
62
  symbol: base.symbol,
61
63
  ...base.name !== void 0 ? { name: base.name } : {},
64
+ ...base.form !== void 0 ? { form: base.form } : {},
65
+ ...base.assetClass !== void 0 ? { assetClass: base.assetClass } : {},
62
66
  ...nextGroups.length > 0 ? { groups: nextGroups } : {}
63
67
  });
64
68
  map = {
@@ -83,6 +87,8 @@ function createMemoryWatchlistStore(initial = {}) {
83
87
  market: row.market,
84
88
  symbol: row.symbol,
85
89
  ...row.name !== void 0 ? { name: row.name } : {},
90
+ ...row.form !== void 0 ? { form: row.form } : {},
91
+ ...row.assetClass !== void 0 ? { assetClass: row.assetClass } : {},
86
92
  ...rest.length > 0 ? { groups: rest } : {}
87
93
  });
88
94
  });
package/lib/plugin.js CHANGED
@@ -6,6 +6,15 @@ import { defineTool } from "@deepseek-ai/dsh-tools";
6
6
  import path from "node:path";
7
7
  import { dshHomeDir } from "@dshtrading/dsh-home";
8
8
  //#region src/plugin.ts
9
+ function jsonOutput(schema) {
10
+ return {
11
+ schema,
12
+ render: (_args, value) => [{
13
+ type: "text",
14
+ text: JSON.stringify(value)
15
+ }]
16
+ };
17
+ }
9
18
  /** Cordis 插件名 = patch 行 id(TEMPLATES §8),市场无关共享行命名空间。 */
10
19
  const name = "dsh-trading-watchlist";
11
20
  /** 本插件不硬依赖任何服务;tools 经 ctx.inject 声明。 */
@@ -61,7 +70,7 @@ function countGroupMembers(map, groupId) {
61
70
  function createWatchlistListTool(deps) {
62
71
  return defineTool({
63
72
  name: "watchlist_list",
64
- description: "List the user's watchlist exactly as displayed in the trading GUI sidebar, across all markets (crypto/us/cn/hk). Rows merge the user's customized entries (source \"custom\") with each market's default seed rows (source \"seed\", shown while that market has no custom edits) — the GUI shows the same rows, so this list IS what the user sees. It also maps display names to symbols (e.g. 苹果 → AAPL / us, 贵州茅台 → 600519 / cn). It also returns \"groups\" (the user's custom groups with id, name and member count) and \"selection\" (the instrument currently focused in the GUI chart, or null). Group membership lives on each row as row.groups (group ids); rows without groups are ungrouped. ALWAYS call this first when the user mentions any instrument by name or symbol, when you need a group id for watchlist_group_assign/rename/delete, or when you need the current selection; never conclude an instrument is untracked from docs or connector coverage alone. Read-only.",
73
+ description: "List the user's watchlist across all markets (crypto/us/cn/hk), exactly as the trading GUI sidebar shows it. Rows merge customized entries (source \"custom\") with market seed rows (source \"seed\"), so this list IS what the user sees, and display names map to symbols (e.g. 苹果 → AAPL / us). Returns \"groups\" (id, name, member count) and \"selection\" (chart-focused instrument or null); membership is row.groups. Call it first when the user names an instrument or you need a group id.",
65
74
  parameters: {},
66
75
  output: {
67
76
  schema: { type: "string" },
@@ -118,25 +127,37 @@ function createWatchlistAddTool(deps) {
118
127
  },
119
128
  name: {
120
129
  type: "string",
121
- description: "Optional display name, e.g. 贵州茅台"
130
+ description: "Optional display name, e.g. 贵州茅台 (Kweichow Moutai)."
122
131
  }
123
132
  },
124
- output: {
125
- schema: { type: "string" },
126
- render: (_args, value) => [{
127
- type: "text",
128
- text: value
129
- }]
130
- },
133
+ output: jsonOutput({
134
+ type: "object",
135
+ additionalProperties: false,
136
+ properties: {
137
+ ok: {
138
+ type: "boolean",
139
+ const: true,
140
+ required: true
141
+ },
142
+ added: {
143
+ type: "boolean",
144
+ required: true
145
+ },
146
+ note: {
147
+ type: "string",
148
+ required: true
149
+ }
150
+ }
151
+ }),
131
152
  async execute(raw) {
132
153
  const instrument = parseInstrumentArgs(raw ?? {});
133
154
  const added = await deps.watchlists.add(instrument.market, instrument);
134
155
  if (added) deps.onWatchlistsChanged?.();
135
- return JSON.stringify({
156
+ return {
136
157
  ok: true,
137
158
  added,
138
159
  note: added ? `Added ${instrument.symbol} (${instrument.market}) to the watchlist.` : `${instrument.symbol} is already in the ${instrument.market} watchlist (deduplicated, nothing changed).`
139
- });
160
+ };
140
161
  }
141
162
  });
142
163
  }
@@ -156,22 +177,34 @@ function createWatchlistRemoveTool(deps) {
156
177
  description: "Market-canonical symbol to remove"
157
178
  }
158
179
  },
159
- output: {
160
- schema: { type: "string" },
161
- render: (_args, value) => [{
162
- type: "text",
163
- text: value
164
- }]
165
- },
180
+ output: jsonOutput({
181
+ type: "object",
182
+ additionalProperties: false,
183
+ properties: {
184
+ ok: {
185
+ type: "boolean",
186
+ const: true,
187
+ required: true
188
+ },
189
+ removed: {
190
+ type: "boolean",
191
+ required: true
192
+ },
193
+ note: {
194
+ type: "string",
195
+ required: true
196
+ }
197
+ }
198
+ }),
166
199
  async execute(raw) {
167
200
  const { market, symbol } = parseInstrumentArgs(raw ?? {});
168
201
  const removed = await deps.watchlists.remove(market, symbol);
169
202
  if (removed) deps.onWatchlistsChanged?.();
170
- return JSON.stringify({
203
+ return {
171
204
  ok: true,
172
205
  removed,
173
206
  note: removed ? `Removed ${symbol} from the ${market} watchlist.` : `${symbol} was not in the ${market} watchlist (nothing changed).`
174
- });
207
+ };
175
208
  }
176
209
  });
177
210
  }
@@ -218,7 +251,7 @@ function createWatchlistSelectTool(deps) {
218
251
  function createWatchlistGroupCreateTool(deps) {
219
252
  return defineTool({
220
253
  name: "watchlist_group_create",
221
- description: "Create a named custom watchlist group (a cross-market bucket for organizing the user watchlist, e.g. \"港股观察\" / \"核心仓\"). Group names are unique (trimmed, 24 chars max); creating an existing name is refused. The returned id is what watchlist_group_assign / watchlist_group_rename / watchlist_group_delete take — call watchlist_list first to see existing groups and their member counts. The open GUI sidebar refreshes live over the SSE invalidation channel.",
254
+ description: "Create a named custom watchlist group — a cross-market bucket for organizing the user watchlist, e.g. \"港股观察\". Names are trimmed, unique and at most 24 characters; an existing name is refused. The returned id is what watchlist_group_assign / watchlist_group_rename / watchlist_group_delete take, so call watchlist_list first to see existing groups and member counts. The open GUI sidebar refreshes live over the SSE channel.",
222
255
  parameters: { name: {
223
256
  type: "string",
224
257
  required: true,
@@ -314,7 +347,7 @@ function createWatchlistGroupRenameTool(deps) {
314
347
  function createWatchlistGroupDeleteTool(deps) {
315
348
  return defineTool({
316
349
  name: "watchlist_group_delete",
317
- description: "Delete a custom watchlist group by id. Deleting a group clears its membership from every watchlist row but NEVER deletes watchlist rows themselves (the instruments stay in the watchlist, only the grouping is dropped). DISCIPLINE: before calling this, tell the user the group name and how many member rows it has (watchlist_list returns members), and call it only after they confirm; then report the same numbers back after the call. Refused when the id is unknown. The open GUI sidebar refreshes live over the SSE invalidation channel.",
350
+ description: "Delete a custom watchlist group by id. Deleting a group clears its membership on every watchlist row but NEVER deletes watchlist rows — the instruments stay, only the grouping goes. DISCIPLINE: tell the user the group name and member count (watchlist_list returns members) and call it only after they confirm; report the same numbers afterwards. Refused for an unknown id. The GUI sidebar refreshes live over the SSE invalidation channel.",
318
351
  parameters: { id: {
319
352
  type: "string",
320
353
  required: true,
@@ -353,7 +386,7 @@ function createWatchlistGroupDeleteTool(deps) {
353
386
  function createWatchlistGroupAssignTool(deps) {
354
387
  return defineTool({
355
388
  name: "watchlist_group_assign",
356
- description: "Add or remove one watchlist instrument to/from a custom group (set member=true to add, member=false to remove). The instrument must already be in the watchlist or be a market seed row; when the market has no customized rows yet the target row is materialized from that market seed baseline first (result field materialized=true) — this mirrors the GUI drag behavior, and no other rows are lost. Removing membership never deletes the watchlist row. Unknown group ids are refused (no dangling membership). The open GUI sidebar refreshes live over the SSE invalidation channel.",
389
+ description: "Add or remove one watchlist instrument to/from a custom group (member=true adds, false removes). The instrument must already be in the watchlist or be a market seed row; when the market has no customized rows the target row is materialized from its seed baseline first (result field materialized=true) and no other rows are lost. Removing membership never deletes the row, and an unknown group id is refused. The GUI sidebar refreshes live over the SSE invalidation channel.",
357
390
  parameters: {
358
391
  id: {
359
392
  type: "string",
@@ -377,7 +410,7 @@ function createWatchlistGroupAssignTool(deps) {
377
410
  },
378
411
  name: {
379
412
  type: "string",
380
- description: "Optional display name used only when the row must be materialized, e.g. 贵州茅台"
413
+ description: "Optional display name used only when the row must be materialized, e.g. 贵州茅台 (Kweichow Moutai)."
381
414
  }
382
415
  },
383
416
  output: {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@dshtrading/watchlist",
3
3
  "description": "Host-side watchlist + selection stores for dsh-trading: multi-list file store (~/.dsh/watchlists.json), selection store (~/.dsh/selection.json), and cross-market watchlist_* tools (watchlist_list/add/remove/select) on the host plane",
4
- "version": "0.4.1",
4
+ "version": "0.6.0",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "types": "./lib/index.d.ts",
@@ -21,11 +21,12 @@
21
21
  ],
22
22
  "license": "PolyForm-Noncommercial-1.0.0",
23
23
  "dependencies": {
24
- "@dshtrading/dsh-home": "^0.4.1"
24
+ "@dshtrading/api": "^0.6.0",
25
+ "@dshtrading/dsh-home": "^0.6.0"
25
26
  },
26
27
  "peerDependencies": {
27
28
  "@deepseek-ai/cordis": ">=4.0.0",
28
- "@deepseek-ai/dsh-tools": ">=0.1.5-rc.1"
29
+ "@deepseek-ai/dsh-tools": ">=0.2.0-rc.2"
29
30
  },
30
31
  "devDependencies": {
31
32
  "tsdown": "^0.22.0",