@dshtrading/watchlist 0.5.0 → 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 +170 -144
- package/lib/index.d.ts +10 -25
- package/lib/index.js +7 -1
- package/lib/plugin.js +57 -24
- package/package.json +3 -2
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 {
|
|
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 宿主侧使用,
|
|
8
|
-
*
|
|
7
|
+
* 文件持久化版自选/选中 store(Node.js 宿主侧使用,issue #32 规格)。
|
|
8
|
+
*
|
|
9
|
+
* 写路径经 `@dshtrading/dsh-home` 的 {@link transactStore}——跨进程排他锁 + 锁内
|
|
10
|
+
* 新鲜读盘 + 原子写(2026-10-09 多进程互相覆盖事故修复):桌面端与 CLI 各写一行时
|
|
11
|
+
* 双方都留在盘上,不再互相抹掉(此前是内存缓存 + 整表回写)。进程内的 enqueue 串行化
|
|
12
|
+
* 保留(它保证同一进程内的读改写顺序),跨进程部分由锁承担。
|
|
9
13
|
*/
|
|
10
|
-
|
|
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
|
-
|
|
21
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
47
|
-
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
148
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
160
|
-
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
-
|
|
218
|
-
|
|
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
|
-
|
|
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
|
-
|
|
251
|
+
return { instrument: null };
|
|
226
252
|
}
|
|
227
|
-
return cache;
|
|
228
253
|
}
|
|
229
|
-
async function
|
|
230
|
-
|
|
231
|
-
|
|
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
|
-
|
|
241
|
-
|
|
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
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
|
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
|
|
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
|
|
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 (
|
|
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
|
+
"version": "0.6.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"types": "./lib/index.d.ts",
|
|
@@ -21,7 +21,8 @@
|
|
|
21
21
|
],
|
|
22
22
|
"license": "PolyForm-Noncommercial-1.0.0",
|
|
23
23
|
"dependencies": {
|
|
24
|
-
"@dshtrading/
|
|
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",
|