@dshtrading/watchlist 0.1.6 → 0.2.1
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 +7 -32
- package/lib/plugin.d.ts +12 -2
- package/lib/plugin.js +276 -6
- package/package.json +3 -3
package/lib/file-store.js
CHANGED
|
@@ -1,39 +1,14 @@
|
|
|
1
1
|
import { WATCHLIST_SEEDS } from "./seeds.js";
|
|
2
2
|
import { newWatchlistGroupId, normalizeWatchlistRow } from "./index.js";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
3
|
+
import { writeJsonAtomic } from "@dshtrading/dsh-home";
|
|
4
|
+
import { readFile } from "node:fs/promises";
|
|
5
5
|
//#region src/file-store.ts
|
|
6
6
|
/**
|
|
7
7
|
* 文件持久化版自选/选中 store(Node.js 宿主侧使用,tmp+rename 原子写,
|
|
8
8
|
* indicators/custom-fs 同款模式——issue #32 规格)。
|
|
9
9
|
*/
|
|
10
|
-
/**
|
|
11
|
-
|
|
12
|
-
* 重试耗尽或任何失败一律保留旧文件、清理 tmp、log + throw——目标文件永远只被
|
|
13
|
-
* 原子 rename 触碰,绝不非原子直写(防止半截写损坏 JSON 导致 load 静默重置)。
|
|
14
|
-
*/
|
|
15
|
-
async function safeAtomicWrite(filePath, data) {
|
|
16
|
-
const dir = dirname(filePath);
|
|
17
|
-
const tmpPath = `${filePath}.tmp.${Date.now()}.${Math.random().toString(36).slice(2, 8)}`;
|
|
18
|
-
try {
|
|
19
|
-
await mkdir(dir, { recursive: true });
|
|
20
|
-
await writeFile(tmpPath, data, "utf8");
|
|
21
|
-
let lastError;
|
|
22
|
-
for (let attempt = 0; attempt < 3; attempt++) try {
|
|
23
|
-
await rename(tmpPath, filePath);
|
|
24
|
-
return;
|
|
25
|
-
} catch (err) {
|
|
26
|
-
if (err?.code !== "EPERM" && err?.code !== "EBUSY") throw err;
|
|
27
|
-
lastError = err;
|
|
28
|
-
await new Promise((resolve) => setTimeout(resolve, 25 * (attempt + 1)));
|
|
29
|
-
}
|
|
30
|
-
throw lastError;
|
|
31
|
-
} catch (error) {
|
|
32
|
-
console.error(`[dsh-trading/watchlist] failed to atomic flush watchlists to ${filePath}:`, error);
|
|
33
|
-
await unlink(tmpPath).catch(() => {});
|
|
34
|
-
throw error;
|
|
35
|
-
}
|
|
36
|
-
}
|
|
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");
|
|
37
12
|
function createFileWatchlistStore(filePath) {
|
|
38
13
|
let cache = null;
|
|
39
14
|
let pendingWrite = Promise.resolve();
|
|
@@ -56,7 +31,7 @@ function createFileWatchlistStore(filePath) {
|
|
|
56
31
|
}
|
|
57
32
|
/** 仅在 enqueue 队列内调用:写盘不能二次入队(外层已持队,内层 enqueue 会自等死锁)。 */
|
|
58
33
|
function writeNow(map) {
|
|
59
|
-
return safeAtomicWrite(filePath,
|
|
34
|
+
return safeAtomicWrite(filePath, map);
|
|
60
35
|
}
|
|
61
36
|
async function flush(map) {
|
|
62
37
|
return enqueue(async () => {
|
|
@@ -182,7 +157,7 @@ function createFileWatchlistGroupsStore(filePath) {
|
|
|
182
157
|
return cache;
|
|
183
158
|
}
|
|
184
159
|
function writeNow(groups) {
|
|
185
|
-
return safeAtomicWrite(filePath,
|
|
160
|
+
return safeAtomicWrite(filePath, { groups });
|
|
186
161
|
}
|
|
187
162
|
return {
|
|
188
163
|
async list() {
|
|
@@ -253,7 +228,7 @@ function createFileSelectionStore(filePath) {
|
|
|
253
228
|
}
|
|
254
229
|
async function flush(record) {
|
|
255
230
|
return enqueue(async () => {
|
|
256
|
-
await safeAtomicWrite(filePath,
|
|
231
|
+
await safeAtomicWrite(filePath, record);
|
|
257
232
|
});
|
|
258
233
|
}
|
|
259
234
|
return {
|
package/lib/plugin.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { SelectionStore, WatchlistStore, createMemorySelectionStore, createMemoryWatchlistStore } from "./index.js";
|
|
1
|
+
import { SelectionStore, WatchlistGroupsStore, WatchlistStore, createMemorySelectionStore, createMemoryWatchlistGroupsStore, createMemoryWatchlistStore } from "./index.js";
|
|
2
2
|
import { createFileSelectionStore, createFileWatchlistGroupsStore, createFileWatchlistStore } from "./file-store.js";
|
|
3
3
|
import { Context, Service } from "@deepseek-ai/cordis";
|
|
4
4
|
//#region src/plugin.d.ts
|
|
@@ -18,6 +18,8 @@ interface TradingEventsPublisher {
|
|
|
18
18
|
interface WatchlistToolDeps {
|
|
19
19
|
watchlists: WatchlistStore;
|
|
20
20
|
selection: SelectionStore;
|
|
21
|
+
/** 分组注册表(issue #86 / G5):缺席时分组工具明确报错,不静默降级。 */
|
|
22
|
+
groups?: WatchlistGroupsStore;
|
|
21
23
|
onWatchlistsChanged?: () => void;
|
|
22
24
|
onSelectionChanged?: () => void;
|
|
23
25
|
}
|
|
@@ -25,9 +27,14 @@ declare function createWatchlistListTool(deps: WatchlistToolDeps): import("@deep
|
|
|
25
27
|
declare function createWatchlistAddTool(deps: WatchlistToolDeps): import("@deepseek-ai/dsh-tools").ToolDefinition;
|
|
26
28
|
declare function createWatchlistRemoveTool(deps: WatchlistToolDeps): import("@deepseek-ai/dsh-tools").ToolDefinition;
|
|
27
29
|
declare function createWatchlistSelectTool(deps: WatchlistToolDeps): import("@deepseek-ai/dsh-tools").ToolDefinition;
|
|
30
|
+
declare function createWatchlistGroupCreateTool(deps: WatchlistToolDeps): import("@deepseek-ai/dsh-tools").ToolDefinition;
|
|
31
|
+
declare function createWatchlistGroupRenameTool(deps: WatchlistToolDeps): import("@deepseek-ai/dsh-tools").ToolDefinition;
|
|
32
|
+
declare function createWatchlistGroupDeleteTool(deps: WatchlistToolDeps): import("@deepseek-ai/dsh-tools").ToolDefinition;
|
|
33
|
+
declare function createWatchlistGroupAssignTool(deps: WatchlistToolDeps): import("@deepseek-ai/dsh-tools").ToolDefinition;
|
|
28
34
|
interface WatchlistPluginDeps {
|
|
29
35
|
watchlists: WatchlistStore;
|
|
30
36
|
selection: SelectionStore;
|
|
37
|
+
groups: WatchlistGroupsStore;
|
|
31
38
|
}
|
|
32
39
|
declare function registerWatchlistTools(ctx: Context, deps: WatchlistPluginDeps): void;
|
|
33
40
|
/** SDK 服务键:自选/选中 store 单实例(桥与工具共享同一缓存;2026-09-08 审查 H1 收口)。 */
|
|
@@ -42,12 +49,15 @@ declare const TRADING_WATCHLIST_KEY = "tradingWatchlist";
|
|
|
42
49
|
declare class WatchlistStoreService extends Service {
|
|
43
50
|
readonly store: WatchlistStore;
|
|
44
51
|
readonly selection: SelectionStore;
|
|
52
|
+
/** 分组注册表单实例(issue #86 / G5):工具与桥共用,杜绝双 store 互踩。 */
|
|
53
|
+
readonly groups: WatchlistGroupsStore;
|
|
45
54
|
constructor(ctx: Context, deps: {
|
|
46
55
|
watchlists: WatchlistStore;
|
|
47
56
|
selection: SelectionStore;
|
|
57
|
+
groups: WatchlistGroupsStore;
|
|
48
58
|
}, serviceName?: string);
|
|
49
59
|
}
|
|
50
60
|
/** Host plugin body:file store provide + 4 工具注册(host 平面,全会话可见)。 */
|
|
51
61
|
declare function apply(ctx: Context): void;
|
|
52
62
|
//#endregion
|
|
53
|
-
export { TRADING_WATCHLIST_KEY, TradingEventsPublisher, WatchlistPluginDeps, WatchlistStoreService, WatchlistToolDeps, apply, createFileSelectionStore, createFileWatchlistGroupsStore, createFileWatchlistStore, createMemorySelectionStore, createMemoryWatchlistStore, createWatchlistAddTool, createWatchlistListTool, createWatchlistRemoveTool, createWatchlistSelectTool, defaultSelectionStorePath, defaultWatchlistGroupsStorePath, defaultWatchlistStorePath, inject, name, registerWatchlistTools };
|
|
63
|
+
export { TRADING_WATCHLIST_KEY, TradingEventsPublisher, WatchlistPluginDeps, WatchlistStoreService, WatchlistToolDeps, apply, createFileSelectionStore, createFileWatchlistGroupsStore, createFileWatchlistStore, createMemorySelectionStore, createMemoryWatchlistGroupsStore, createMemoryWatchlistStore, createWatchlistAddTool, createWatchlistGroupAssignTool, createWatchlistGroupCreateTool, createWatchlistGroupDeleteTool, createWatchlistGroupRenameTool, createWatchlistListTool, createWatchlistRemoveTool, createWatchlistSelectTool, defaultSelectionStorePath, defaultWatchlistGroupsStorePath, defaultWatchlistStorePath, inject, name, registerWatchlistTools };
|
package/lib/plugin.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { WATCHLIST_SEEDS, effectiveWatchlistRows, watchlistRowSource } from "./seeds.js";
|
|
2
|
-
import { createMemorySelectionStore, createMemoryWatchlistStore } from "./index.js";
|
|
2
|
+
import { createMemorySelectionStore, createMemoryWatchlistGroupsStore, createMemoryWatchlistStore } from "./index.js";
|
|
3
3
|
import { createFileSelectionStore, createFileWatchlistGroupsStore, createFileWatchlistStore } from "./file-store.js";
|
|
4
4
|
import { Service } from "@deepseek-ai/cordis";
|
|
5
5
|
import { defineTool } from "@deepseek-ai/dsh-tools";
|
|
@@ -35,10 +35,33 @@ function parseInstrumentArgs(raw) {
|
|
|
35
35
|
...name !== void 0 ? { name } : {}
|
|
36
36
|
};
|
|
37
37
|
}
|
|
38
|
+
/** 分组注册表取用(缺席 = 老部署,写工具必须响亮失败而不是假装成功)。 */
|
|
39
|
+
function requireGroups(deps, tool) {
|
|
40
|
+
if (deps.groups === void 0) throw new Error(`${tool}: watchlist group store is not mounted — upgrade the dsh-trading profile so @dshtrading/watchlist/plugin provides it`);
|
|
41
|
+
return deps.groups;
|
|
42
|
+
}
|
|
43
|
+
/** 分组名校验(与桥 parseGroupNameBody 同口径:trim 非空、≤24 字符)。 */
|
|
44
|
+
function parseGroupName(raw, tool) {
|
|
45
|
+
const name = typeof raw === "string" ? raw.trim() : "";
|
|
46
|
+
if (!name) throw new Error(`${tool}: name is required (non-empty string)`);
|
|
47
|
+
if (name.length > 24) throw new Error(`${tool}: group name too long (24 chars max, got ${name.length})`);
|
|
48
|
+
return name;
|
|
49
|
+
}
|
|
50
|
+
function parseGroupId(raw, tool) {
|
|
51
|
+
const id = typeof raw === "string" ? raw.trim() : "";
|
|
52
|
+
if (!id) throw new Error(`${tool}: id is required (a group id from watchlist_list / watchlist_group_create)`);
|
|
53
|
+
return id;
|
|
54
|
+
}
|
|
55
|
+
/** 全表统计某分组的成员行数(合并视图口径 = 用户实际看到的行)。 */
|
|
56
|
+
function countGroupMembers(map, groupId) {
|
|
57
|
+
let count = 0;
|
|
58
|
+
for (const market of Object.keys(map)) for (const row of effectiveWatchlistRows(map, market)) if (Array.isArray(row.groups) && row.groups.includes(groupId)) count += 1;
|
|
59
|
+
return count;
|
|
60
|
+
}
|
|
38
61
|
function createWatchlistListTool(deps) {
|
|
39
62
|
return defineTool({
|
|
40
63
|
name: "watchlist_list",
|
|
41
|
-
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). ALWAYS call this first when the user mentions any instrument by name or symbol; never conclude an instrument is untracked from docs or connector coverage alone. Read-only.",
|
|
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.",
|
|
42
65
|
parameters: {},
|
|
43
66
|
output: {
|
|
44
67
|
schema: { type: "string" },
|
|
@@ -59,12 +82,21 @@ function createWatchlistListTool(deps) {
|
|
|
59
82
|
sources[market] = watchlistRowSource(map, market);
|
|
60
83
|
total += rows.length;
|
|
61
84
|
}
|
|
85
|
+
const groups = (deps.groups === void 0 ? [] : await deps.groups.list()).map((group) => ({
|
|
86
|
+
id: group.id,
|
|
87
|
+
name: group.name,
|
|
88
|
+
createdAt: group.createdAt,
|
|
89
|
+
members: countGroupMembers(watchlists, group.id)
|
|
90
|
+
}));
|
|
91
|
+
const selection = (await deps.selection.get()).instrument;
|
|
62
92
|
return JSON.stringify({
|
|
63
93
|
ok: true,
|
|
64
94
|
total,
|
|
65
95
|
markets,
|
|
66
96
|
sources,
|
|
67
|
-
watchlists
|
|
97
|
+
watchlists,
|
|
98
|
+
groups,
|
|
99
|
+
selection
|
|
68
100
|
});
|
|
69
101
|
}
|
|
70
102
|
});
|
|
@@ -183,6 +215,234 @@ function createWatchlistSelectTool(deps) {
|
|
|
183
215
|
}
|
|
184
216
|
});
|
|
185
217
|
}
|
|
218
|
+
function createWatchlistGroupCreateTool(deps) {
|
|
219
|
+
return defineTool({
|
|
220
|
+
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.",
|
|
222
|
+
parameters: { name: {
|
|
223
|
+
type: "string",
|
|
224
|
+
required: true,
|
|
225
|
+
description: "Group display name (non-empty, 24 chars max, unique among groups)"
|
|
226
|
+
} },
|
|
227
|
+
output: {
|
|
228
|
+
schema: { type: "string" },
|
|
229
|
+
render: (_args, value) => [{
|
|
230
|
+
type: "text",
|
|
231
|
+
text: value
|
|
232
|
+
}]
|
|
233
|
+
},
|
|
234
|
+
async execute(raw) {
|
|
235
|
+
const groups = requireGroups(deps, "watchlist_group_create");
|
|
236
|
+
const name = parseGroupName((raw ?? {}).name, "watchlist_group_create");
|
|
237
|
+
const result = await groups.create(name);
|
|
238
|
+
if (result.error === "duplicate") return JSON.stringify({
|
|
239
|
+
ok: false,
|
|
240
|
+
code: "duplicate",
|
|
241
|
+
note: `A group named ${JSON.stringify(name)} already exists — call watchlist_list to see the existing groups and reuse that id.`
|
|
242
|
+
});
|
|
243
|
+
if (result.group === void 0) return JSON.stringify({
|
|
244
|
+
ok: false,
|
|
245
|
+
code: "unavailable",
|
|
246
|
+
note: "The watchlist group store returned no group."
|
|
247
|
+
});
|
|
248
|
+
deps.onWatchlistsChanged?.();
|
|
249
|
+
return JSON.stringify({
|
|
250
|
+
ok: true,
|
|
251
|
+
id: result.group.id,
|
|
252
|
+
name: result.group.name,
|
|
253
|
+
note: `Created group ${JSON.stringify(result.group.name)} (id ${result.group.id}, 0 members). Assign instruments with watchlist_group_assign.`
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
function createWatchlistGroupRenameTool(deps) {
|
|
259
|
+
return defineTool({
|
|
260
|
+
name: "watchlist_group_rename",
|
|
261
|
+
description: "Rename a custom watchlist group by id (membership is untouched — only the display name changes). Refused when the id is unknown or the new name is already used by another group. Call watchlist_list first to get the id and report the old → new name back to the user.",
|
|
262
|
+
parameters: {
|
|
263
|
+
id: {
|
|
264
|
+
type: "string",
|
|
265
|
+
required: true,
|
|
266
|
+
description: "Group id from watchlist_list / watchlist_group_create"
|
|
267
|
+
},
|
|
268
|
+
name: {
|
|
269
|
+
type: "string",
|
|
270
|
+
required: true,
|
|
271
|
+
description: "New group display name (non-empty, 24 chars max, unique among groups)"
|
|
272
|
+
}
|
|
273
|
+
},
|
|
274
|
+
output: {
|
|
275
|
+
schema: { type: "string" },
|
|
276
|
+
render: (_args, value) => [{
|
|
277
|
+
type: "text",
|
|
278
|
+
text: value
|
|
279
|
+
}]
|
|
280
|
+
},
|
|
281
|
+
async execute(raw) {
|
|
282
|
+
const groups = requireGroups(deps, "watchlist_group_rename");
|
|
283
|
+
const args = raw ?? {};
|
|
284
|
+
const id = parseGroupId(args.id, "watchlist_group_rename");
|
|
285
|
+
const name = parseGroupName(args.name, "watchlist_group_rename");
|
|
286
|
+
const before = (await groups.list()).find((group) => group.id === id);
|
|
287
|
+
const result = await groups.rename(id, name);
|
|
288
|
+
if (result.error === "not-found") return JSON.stringify({
|
|
289
|
+
ok: false,
|
|
290
|
+
code: "not-found",
|
|
291
|
+
note: `No watchlist group with id ${JSON.stringify(id)} — call watchlist_list for the current ids.`
|
|
292
|
+
});
|
|
293
|
+
if (result.error === "duplicate") return JSON.stringify({
|
|
294
|
+
ok: false,
|
|
295
|
+
code: "duplicate",
|
|
296
|
+
note: `Another group is already named ${JSON.stringify(name)} — pick a different name.`
|
|
297
|
+
});
|
|
298
|
+
if (result.group === void 0) return JSON.stringify({
|
|
299
|
+
ok: false,
|
|
300
|
+
code: "unavailable",
|
|
301
|
+
note: "The watchlist group store returned no group."
|
|
302
|
+
});
|
|
303
|
+
deps.onWatchlistsChanged?.();
|
|
304
|
+
return JSON.stringify({
|
|
305
|
+
ok: true,
|
|
306
|
+
id: result.group.id,
|
|
307
|
+
from: before?.name ?? null,
|
|
308
|
+
to: result.group.name,
|
|
309
|
+
note: `Renamed group ${JSON.stringify(before?.name ?? id)} → ${JSON.stringify(result.group.name)} (membership unchanged).`
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
function createWatchlistGroupDeleteTool(deps) {
|
|
315
|
+
return defineTool({
|
|
316
|
+
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.",
|
|
318
|
+
parameters: { id: {
|
|
319
|
+
type: "string",
|
|
320
|
+
required: true,
|
|
321
|
+
description: "Group id from watchlist_list (unknown id is refused)"
|
|
322
|
+
} },
|
|
323
|
+
output: {
|
|
324
|
+
schema: { type: "string" },
|
|
325
|
+
render: (_args, value) => [{
|
|
326
|
+
type: "text",
|
|
327
|
+
text: value
|
|
328
|
+
}]
|
|
329
|
+
},
|
|
330
|
+
async execute(raw) {
|
|
331
|
+
const groups = requireGroups(deps, "watchlist_group_delete");
|
|
332
|
+
const id = parseGroupId((raw ?? {}).id, "watchlist_group_delete");
|
|
333
|
+
const target = (await groups.list()).find((group) => group.id === id);
|
|
334
|
+
if (target === void 0) return JSON.stringify({
|
|
335
|
+
ok: false,
|
|
336
|
+
code: "not-found",
|
|
337
|
+
note: `No watchlist group with id ${JSON.stringify(id)} — call watchlist_list for the current ids.`
|
|
338
|
+
});
|
|
339
|
+
const membersCleared = await deps.watchlists.stripGroup(id);
|
|
340
|
+
const removed = await groups.remove(id);
|
|
341
|
+
if (removed || membersCleared > 0) deps.onWatchlistsChanged?.();
|
|
342
|
+
return JSON.stringify({
|
|
343
|
+
ok: true,
|
|
344
|
+
id,
|
|
345
|
+
name: target.name,
|
|
346
|
+
removed,
|
|
347
|
+
membersCleared,
|
|
348
|
+
note: removed ? `Deleted group ${JSON.stringify(target.name)} (${membersCleared} row(s) lost this membership). The watchlist rows themselves are untouched.` : `Group ${JSON.stringify(target.name)} was already gone; ${membersCleared} stale membership(s) cleaned.`
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
function createWatchlistGroupAssignTool(deps) {
|
|
354
|
+
return defineTool({
|
|
355
|
+
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.",
|
|
357
|
+
parameters: {
|
|
358
|
+
id: {
|
|
359
|
+
type: "string",
|
|
360
|
+
required: true,
|
|
361
|
+
description: "Group id from watchlist_list / watchlist_group_create"
|
|
362
|
+
},
|
|
363
|
+
market: {
|
|
364
|
+
type: "string",
|
|
365
|
+
required: true,
|
|
366
|
+
description: "Market vocabulary slug: crypto | us | cn | hk"
|
|
367
|
+
},
|
|
368
|
+
symbol: {
|
|
369
|
+
type: "string",
|
|
370
|
+
required: true,
|
|
371
|
+
description: "Market-canonical symbol (docs/symbol-vocabulary.md), e.g. BTCUSDT / AAPL / 600519 / 00700"
|
|
372
|
+
},
|
|
373
|
+
member: {
|
|
374
|
+
type: "boolean",
|
|
375
|
+
required: true,
|
|
376
|
+
description: "true = add the instrument to the group; false = remove it from the group"
|
|
377
|
+
},
|
|
378
|
+
name: {
|
|
379
|
+
type: "string",
|
|
380
|
+
description: "Optional display name used only when the row must be materialized, e.g. 贵州茅台"
|
|
381
|
+
}
|
|
382
|
+
},
|
|
383
|
+
output: {
|
|
384
|
+
schema: { type: "string" },
|
|
385
|
+
render: (_args, value) => [{
|
|
386
|
+
type: "text",
|
|
387
|
+
text: value
|
|
388
|
+
}]
|
|
389
|
+
},
|
|
390
|
+
async execute(raw) {
|
|
391
|
+
const groups = requireGroups(deps, "watchlist_group_assign");
|
|
392
|
+
const args = raw ?? {};
|
|
393
|
+
const id = parseGroupId(args.id, "watchlist_group_assign");
|
|
394
|
+
const { market, symbol } = parseInstrumentArgs(args);
|
|
395
|
+
const displayName = typeof args.name === "string" && args.name.trim() ? args.name.trim() : void 0;
|
|
396
|
+
if (typeof args.member !== "boolean") throw new Error("watchlist_group_assign: member is required (true = add to the group, false = remove from it)");
|
|
397
|
+
const member = args.member;
|
|
398
|
+
const target = (await groups.list()).find((group) => group.id === id);
|
|
399
|
+
if (target === void 0) return JSON.stringify({
|
|
400
|
+
ok: false,
|
|
401
|
+
code: "not-found",
|
|
402
|
+
note: `No watchlist group with id ${JSON.stringify(id)} — call watchlist_list for the current ids.`
|
|
403
|
+
});
|
|
404
|
+
let materialized = false;
|
|
405
|
+
if (member) {
|
|
406
|
+
const map = await deps.watchlists.list();
|
|
407
|
+
const rows = map[market];
|
|
408
|
+
if (!Array.isArray(rows)) {
|
|
409
|
+
const baseline = (WATCHLIST_SEEDS[market] ?? []).map((row) => ({
|
|
410
|
+
market: row.market,
|
|
411
|
+
symbol: row.symbol,
|
|
412
|
+
...row.name !== void 0 ? { name: row.name } : {}
|
|
413
|
+
}));
|
|
414
|
+
if (!baseline.some((row) => row.symbol === symbol)) baseline.push({
|
|
415
|
+
market,
|
|
416
|
+
symbol,
|
|
417
|
+
...displayName !== void 0 ? { name: displayName } : {}
|
|
418
|
+
});
|
|
419
|
+
await deps.watchlists.save({
|
|
420
|
+
...map,
|
|
421
|
+
[market]: baseline
|
|
422
|
+
});
|
|
423
|
+
materialized = true;
|
|
424
|
+
} else if (!rows.some((row) => row.symbol === symbol)) materialized = await deps.watchlists.add(market, {
|
|
425
|
+
market,
|
|
426
|
+
symbol,
|
|
427
|
+
...displayName !== void 0 ? { name: displayName } : {}
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
const changed = await deps.watchlists.assignGroup(market, symbol, id, member);
|
|
431
|
+
if (changed || materialized) deps.onWatchlistsChanged?.();
|
|
432
|
+
return JSON.stringify({
|
|
433
|
+
ok: true,
|
|
434
|
+
id,
|
|
435
|
+
group: target.name,
|
|
436
|
+
market,
|
|
437
|
+
symbol,
|
|
438
|
+
assigned: member ? changed : false,
|
|
439
|
+
removed: member ? false : changed,
|
|
440
|
+
materialized,
|
|
441
|
+
note: member ? changed ? `Added ${symbol} (${market}) to group ${JSON.stringify(target.name)}${materialized ? " (row materialized from the market seed baseline)" : ""}.` : `${symbol} (${market}) is already in group ${JSON.stringify(target.name)} (nothing changed).` : changed ? `Removed ${symbol} (${market}) from group ${JSON.stringify(target.name)}; the watchlist row itself is untouched.` : `${symbol} (${market}) was not in group ${JSON.stringify(target.name)} (nothing changed).`
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
});
|
|
445
|
+
}
|
|
186
446
|
function registerWatchlistTools(ctx, deps) {
|
|
187
447
|
ctx.inject(["tools"], (toolCtx) => {
|
|
188
448
|
const tools = toolCtx.tools;
|
|
@@ -199,6 +459,10 @@ function registerWatchlistTools(ctx, deps) {
|
|
|
199
459
|
shared.register(createWatchlistAddTool(hookDeps));
|
|
200
460
|
shared.register(createWatchlistRemoveTool(hookDeps));
|
|
201
461
|
shared.register(createWatchlistSelectTool(hookDeps));
|
|
462
|
+
shared.register(createWatchlistGroupCreateTool(hookDeps));
|
|
463
|
+
shared.register(createWatchlistGroupRenameTool(hookDeps));
|
|
464
|
+
shared.register(createWatchlistGroupDeleteTool(hookDeps));
|
|
465
|
+
shared.register(createWatchlistGroupAssignTool(hookDeps));
|
|
202
466
|
});
|
|
203
467
|
}
|
|
204
468
|
/** SDK 服务键:自选/选中 store 单实例(桥与工具共享同一缓存;2026-09-08 审查 H1 收口)。 */
|
|
@@ -213,24 +477,30 @@ const TRADING_WATCHLIST_KEY = "tradingWatchlist";
|
|
|
213
477
|
var WatchlistStoreService = class extends Service {
|
|
214
478
|
store;
|
|
215
479
|
selection;
|
|
480
|
+
/** 分组注册表单实例(issue #86 / G5):工具与桥共用,杜绝双 store 互踩。 */
|
|
481
|
+
groups;
|
|
216
482
|
constructor(ctx, deps, serviceName = TRADING_WATCHLIST_KEY) {
|
|
217
483
|
super(ctx, serviceName);
|
|
218
484
|
this.store = deps.watchlists;
|
|
219
485
|
this.selection = deps.selection;
|
|
486
|
+
this.groups = deps.groups;
|
|
220
487
|
}
|
|
221
488
|
};
|
|
222
489
|
/** Host plugin body:file store provide + 4 工具注册(host 平面,全会话可见)。 */
|
|
223
490
|
function apply(ctx) {
|
|
224
491
|
const watchlists = createFileWatchlistStore(defaultWatchlistStorePath());
|
|
225
492
|
const selection = createFileSelectionStore(defaultSelectionStorePath());
|
|
493
|
+
const groups = createFileWatchlistGroupsStore(defaultWatchlistGroupsStorePath());
|
|
226
494
|
new WatchlistStoreService(ctx, {
|
|
227
495
|
watchlists,
|
|
228
|
-
selection
|
|
496
|
+
selection,
|
|
497
|
+
groups
|
|
229
498
|
});
|
|
230
499
|
registerWatchlistTools(ctx, {
|
|
231
500
|
watchlists,
|
|
232
|
-
selection
|
|
501
|
+
selection,
|
|
502
|
+
groups
|
|
233
503
|
});
|
|
234
504
|
}
|
|
235
505
|
//#endregion
|
|
236
|
-
export { TRADING_WATCHLIST_KEY, WatchlistStoreService, apply, createFileSelectionStore, createFileWatchlistGroupsStore, createFileWatchlistStore, createMemorySelectionStore, createMemoryWatchlistStore, createWatchlistAddTool, createWatchlistListTool, createWatchlistRemoveTool, createWatchlistSelectTool, defaultSelectionStorePath, defaultWatchlistGroupsStorePath, defaultWatchlistStorePath, inject, name, registerWatchlistTools };
|
|
506
|
+
export { TRADING_WATCHLIST_KEY, WatchlistStoreService, apply, createFileSelectionStore, createFileWatchlistGroupsStore, createFileWatchlistStore, createMemorySelectionStore, createMemoryWatchlistGroupsStore, createMemoryWatchlistStore, createWatchlistAddTool, createWatchlistGroupAssignTool, createWatchlistGroupCreateTool, createWatchlistGroupDeleteTool, createWatchlistGroupRenameTool, createWatchlistListTool, createWatchlistRemoveTool, createWatchlistSelectTool, defaultSelectionStorePath, defaultWatchlistGroupsStorePath, defaultWatchlistStorePath, inject, name, registerWatchlistTools };
|
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.1
|
|
4
|
+
"version": "0.2.1",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"types": "./lib/index.d.ts",
|
|
@@ -21,11 +21,11 @@
|
|
|
21
21
|
],
|
|
22
22
|
"license": "PolyForm-Noncommercial-1.0.0",
|
|
23
23
|
"dependencies": {
|
|
24
|
-
"@dshtrading/dsh-home": "^0.1
|
|
24
|
+
"@dshtrading/dsh-home": "^0.2.1"
|
|
25
25
|
},
|
|
26
26
|
"peerDependencies": {
|
|
27
27
|
"@deepseek-ai/cordis": ">=4.0.0",
|
|
28
|
-
"@deepseek-ai/dsh-tools": ">=0.1.
|
|
28
|
+
"@deepseek-ai/dsh-tools": ">=0.1.5-rc.1"
|
|
29
29
|
},
|
|
30
30
|
"devDependencies": {
|
|
31
31
|
"tsdown": "^0.22.0",
|