tickflow-assist 0.3.7 → 0.3.9

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.
Files changed (32) hide show
  1. package/README.md +8 -6
  2. package/dist/background/realtime-monitor.worker.d.ts +1 -1
  3. package/dist/background/realtime-monitor.worker.js +3 -4
  4. package/dist/bootstrap.js +9 -0
  5. package/dist/dev/run-monitor-loop.js +0 -1
  6. package/dist/plugin-commands.js +27 -0
  7. package/dist/prompts/analysis/pre-market-brief-prompt.d.ts +1 -1
  8. package/dist/prompts/analysis/pre-market-brief-prompt.js +4 -3
  9. package/dist/services/alert-service.js +34 -4
  10. package/dist/services/monitor-service.d.ts +1 -1
  11. package/dist/services/monitor-service.js +18 -9
  12. package/dist/services/mx-search-service.d.ts +8 -1
  13. package/dist/services/mx-search-service.js +400 -10
  14. package/dist/services/pre-market-brief-service.js +343 -39
  15. package/dist/services/watchlist-service.d.ts +5 -1
  16. package/dist/services/watchlist-service.js +8 -3
  17. package/dist/tools/eastmoney-watchlist.tool.d.ts +31 -0
  18. package/dist/tools/eastmoney-watchlist.tool.js +294 -0
  19. package/dist/tools/mx-data.tool.d.ts +8 -0
  20. package/dist/tools/mx-data.tool.js +94 -0
  21. package/dist/tools/mx-select-stock.tool.js +6 -2
  22. package/dist/tools/screen-stock-candidates.tool.d.ts +34 -0
  23. package/dist/tools/screen-stock-candidates.tool.js +477 -0
  24. package/dist/types/mx-data.d.ts +23 -0
  25. package/dist/types/mx-data.js +1 -0
  26. package/dist/types/mx-select-stock.d.ts +1 -0
  27. package/dist/types/mx-self-select.d.ts +30 -0
  28. package/dist/types/mx-self-select.js +1 -0
  29. package/openclaw.plugin.json +143 -24
  30. package/package.json +9 -9
  31. package/skills/stock-analysis/SKILL.md +31 -2
  32. package/skills/usage-help/SKILL.md +33 -0
@@ -33,6 +33,76 @@ const RISK_KEYWORDS = [
33
33
  "制裁",
34
34
  "关税",
35
35
  ];
36
+ const FALLBACK_MAJOR_NEWS_LIMIT = 5;
37
+ const FALLBACK_TOPIC_KEY_POINT_LIMIT = 6;
38
+ const TOPIC_RULES = [
39
+ {
40
+ category: "地缘与能源",
41
+ keywords: [
42
+ "伊朗",
43
+ "以色列",
44
+ "中东",
45
+ "海湾",
46
+ "霍尔木兹",
47
+ "俄乌",
48
+ "俄罗斯",
49
+ "乌克兰",
50
+ "原油",
51
+ "石油",
52
+ "油轮",
53
+ "EIA",
54
+ "能源",
55
+ "侵略",
56
+ "制裁",
57
+ "开战",
58
+ ],
59
+ score: 8,
60
+ macroImplication: "地缘风险与能源价格预期可能影响开盘风险偏好",
61
+ riskImplication: "海外扰动容易传导到原油、航运、防务及周期品情绪",
62
+ },
63
+ {
64
+ category: "科技产业",
65
+ keywords: [
66
+ "AI",
67
+ "人工智能",
68
+ "大模型",
69
+ "算力",
70
+ "芯片",
71
+ "半导体",
72
+ "台积电",
73
+ "量子",
74
+ "机器人",
75
+ "开源",
76
+ "3D生成",
77
+ ],
78
+ score: 7,
79
+ macroImplication: "科技成长方向的产业催化可能影响题材活跃度",
80
+ opportunityImplication: "产业链、算力、应用和设备端是否出现扩散",
81
+ riskImplication: "高热度题材若只停留在消息刺激,开盘后容易高开分化",
82
+ },
83
+ {
84
+ category: "政策与支付互联",
85
+ keywords: ["政策", "微信支付", "二维码", "跨境", "互联互通", "试点", "监管", "关税"],
86
+ score: 6,
87
+ macroImplication: "政策或跨境互联线索可能改变相关行业预期",
88
+ opportunityImplication: "支付、出海、消费场景和平台生态是否获得资金确认",
89
+ riskImplication: "监管或外部政策变化可能压制相关板块估值与情绪",
90
+ },
91
+ {
92
+ category: "产业与订单",
93
+ keywords: ["订单", "中标", "业绩", "回购", "增持", "涨价", "并购", "并购重组", "发布", "恢复"],
94
+ score: 5,
95
+ macroImplication: "产业事件或公司行为可能提供结构性交易线索",
96
+ opportunityImplication: "订单、业绩、价格和资本运作线索是否带来板块联动",
97
+ },
98
+ {
99
+ category: "资金与宏观",
100
+ keywords: ["美联储", "利率", "美元", "人民币", "股市", "出口", "进口", "产量", "库存"],
101
+ score: 5,
102
+ macroImplication: "宏观和资金面变化可能影响开盘定价与风险偏好",
103
+ riskImplication: "宏观数据变化若与市场预期背离,可能造成高开低走或避险交易",
104
+ },
105
+ ];
36
106
  export class PreMarketBriefService {
37
107
  watchlistService;
38
108
  jin10McpService;
@@ -185,13 +255,8 @@ function matchesPreMarketBrief(record) {
185
255
  function findMatchedItems(flash, watchlist) {
186
256
  const normalizedContent = normalizeText(flash.content);
187
257
  return watchlist.filter((item) => {
188
- const directKeywords = [item.symbol, item.symbol.slice(0, 6), item.name];
189
- const boardKeywords = [...extractSectorKeywords(item.sector), ...item.themes]
190
- .map((keyword) => keyword.replace(/\s+/g, "").trim())
191
- .filter((keyword) => keyword.length >= 2);
192
- return [...directKeywords, ...boardKeywords]
193
- .map((keyword) => normalizeText(keyword))
194
- .some((keyword) => keyword && normalizedContent.includes(keyword));
258
+ return buildWatchlistKeywordEntries(item)
259
+ .some((entry) => normalizedContent.includes(entry.normalized));
195
260
  });
196
261
  }
197
262
  function buildFlashMatchContext(flash, watchlist) {
@@ -205,64 +270,290 @@ function buildFlashMatchContext(flash, watchlist) {
205
270
  };
206
271
  }
207
272
  function buildFallbackSummary(matchContexts) {
208
- const opportunityContexts = matchContexts.filter((context) => containsAnyKeyword(context.flash.content, OPPORTUNITY_KEYWORDS));
209
- const riskContexts = matchContexts.filter((context) => containsAnyKeyword(context.flash.content, RISK_KEYWORDS));
273
+ const topics = buildFlashTopics(matchContexts);
274
+ const opportunityTopics = topics.filter(isOpportunityTopic);
275
+ const riskTopics = topics.filter(isRiskTopic);
210
276
  return [
211
277
  formatSectionTitle("🧭", "重大要闻"),
212
- formatFlashBullets(matchContexts, 5),
278
+ formatMajorNewsBullets(topics),
213
279
  "",
214
280
  formatSectionTitle("🎯", "自选相关"),
215
- formatMatchedBullets(matchContexts, 5),
281
+ formatMatchedBullets(matchContexts, topics, 5),
216
282
  "",
217
283
  formatSectionTitle("💡", "潜在机会"),
218
- opportunityContexts.length > 0
219
- ? formatFlashBullets(opportunityContexts, 4)
284
+ opportunityTopics.length > 0
285
+ ? formatOpportunityBullets(opportunityTopics, 4)
220
286
  : "• 未发现基于当前整理快讯可直接确认的新增机会方向。",
221
287
  "",
222
288
  formatSectionTitle("⚠️", "风险提示"),
223
- riskContexts.length > 0
224
- ? formatFlashBullets(riskContexts, 4)
289
+ riskTopics.length > 0
290
+ ? formatRiskBullets(riskTopics, 4)
225
291
  : "• 当前整理快讯中未发现特别突出的新增风险,但仍需留意开盘后的情绪变化。",
226
292
  "",
227
293
  formatSectionTitle("📌", "开盘前关注清单"),
228
- buildFocusBullets(matchContexts),
294
+ buildFocusBullets(topics, matchContexts),
229
295
  ].join("\n");
230
296
  }
231
- function formatFlashBullets(contexts, limit) {
232
- return contexts
233
- .slice(0, limit)
234
- .map((context) => {
235
- const time = context.flash.published_at.slice(11, 16);
236
- return `• [${time}] ${formatContextSummary(context)}`;
297
+ function buildFlashTopics(contexts) {
298
+ const seen = new Set();
299
+ const topics = [];
300
+ for (const context of contexts) {
301
+ const texts = context.keyPoints.length > 0
302
+ ? context.keyPoints
303
+ : [formatContextSummary(context)];
304
+ const limitedTexts = texts.slice(0, FALLBACK_TOPIC_KEY_POINT_LIMIT);
305
+ for (const text of limitedTexts) {
306
+ const normalizedText = normalizeDigestText(text);
307
+ if (!normalizedText || seen.has(normalizedText)) {
308
+ continue;
309
+ }
310
+ seen.add(normalizedText);
311
+ const rule = inferTopicRule(`${context.headline} ${text}`);
312
+ topics.push({
313
+ context,
314
+ text,
315
+ time: context.flash.published_at.slice(11, 16),
316
+ rule,
317
+ score: scoreTopic(text, context, rule),
318
+ matchedItems: context.matchedItems.filter((item) => topicMatchesWatchlistItem(text, item)),
319
+ });
320
+ }
321
+ }
322
+ if (topics.length > 0) {
323
+ return topics;
324
+ }
325
+ return contexts.map((context) => {
326
+ const text = formatContextSummary(context);
327
+ const rule = inferTopicRule(`${context.headline} ${text}`);
328
+ return {
329
+ context,
330
+ text,
331
+ time: context.flash.published_at.slice(11, 16),
332
+ rule,
333
+ score: scoreTopic(text, context, rule),
334
+ matchedItems: context.matchedItems,
335
+ };
336
+ });
337
+ }
338
+ function formatMajorNewsBullets(topics) {
339
+ const selected = selectDiverseTopics(topics, FALLBACK_MAJOR_NEWS_LIMIT);
340
+ if (selected.length === 0) {
341
+ return "• 未提取到足够正文细节,今日重大要闻暂只能按标题级线索观察。";
342
+ }
343
+ return selected
344
+ .map((topic) => `• [${topic.time}] ${topic.rule.category}: ${topic.text};${topic.rule.macroImplication}。`)
345
+ .join("\n");
346
+ }
347
+ function formatOpportunityBullets(topics, limit) {
348
+ return selectDiverseTopics(topics, limit)
349
+ .map((topic) => {
350
+ const implication = topic.rule.opportunityImplication ?? "相关方向是否获得资金确认";
351
+ return `• [${topic.time}] ${topic.rule.category}: ${topic.text};观察${implication}。`;
237
352
  })
238
353
  .join("\n");
239
354
  }
240
- function formatMatchedBullets(contexts, limit) {
241
- const matched = contexts.filter((context) => context.matchedItems.length > 0).slice(0, limit);
242
- if (matched.length === 0) {
355
+ function formatRiskBullets(topics, limit) {
356
+ return selectDiverseTopics(topics, limit)
357
+ .map((topic) => {
358
+ const implication = topic.rule.riskImplication ?? "消息不确定性对开盘情绪的扰动";
359
+ return `• [${topic.time}] ${topic.rule.category}: ${topic.text};风险点在于${implication}。`;
360
+ })
361
+ .join("\n");
362
+ }
363
+ function formatMatchedBullets(contexts, topics, limit) {
364
+ const matchedBySymbol = new Map();
365
+ for (const topic of topics) {
366
+ for (const item of topic.matchedItems) {
367
+ addWatchlistCue(matchedBySymbol, item, {
368
+ text: topic.text,
369
+ reason: describeWatchlistMatch(item, topic.text),
370
+ });
371
+ }
372
+ }
373
+ for (const context of contexts) {
374
+ for (const item of context.matchedItems) {
375
+ if (matchedBySymbol.has(item.symbol)) {
376
+ continue;
377
+ }
378
+ const cue = findBestWatchlistCue(context, item);
379
+ addWatchlistCue(matchedBySymbol, item, {
380
+ text: cue,
381
+ reason: describeWatchlistMatch(item, cue),
382
+ });
383
+ }
384
+ }
385
+ const entries = [...matchedBySymbol.values()].slice(0, limit);
386
+ if (entries.length === 0) {
243
387
  return "• 未发现直接命中自选股、行业或题材的盘前整理快讯。";
244
388
  }
245
- return matched.map((context) => {
246
- const labels = context.matchedItems.map((item) => `${item.name}(${item.symbol})`).join("、");
247
- return `• ${labels}: ${formatContextSummary(context)}`;
389
+ return entries.map(({ item, cues }) => {
390
+ const text = cues
391
+ .slice(0, 2)
392
+ .map((cue) => `${cue.reason}: ${cue.text}`)
393
+ .join(";");
394
+ return `• ${item.name}(${item.symbol}): ${text}`;
248
395
  }).join("\n");
249
396
  }
250
- function buildFocusBullets(contexts) {
397
+ function buildFocusBullets(topics, contexts) {
251
398
  const bullets = [];
252
- const matchedContexts = contexts.filter((context) => context.matchedItems.length > 0);
253
- for (const context of matchedContexts.slice(0, 3)) {
254
- const labels = context.matchedItems.map((item) => item.name).join("、");
255
- bullets.push(`• 关注 ${labels} 开盘后的量价反馈,重点核实“${formatFocusCue(context)}”是否继续发酵。`);
399
+ const matchedTopics = topics.filter((topic) => topic.matchedItems.length > 0);
400
+ for (const topic of selectDiverseTopics(matchedTopics, 2)) {
401
+ const labels = topic.matchedItems.map((item) => item.name).join("、");
402
+ addUniqueBullet(bullets, `• 关注 ${labels} 与“${formatFocusCueText(topic.text)}”的联动强度,开盘看竞价、放量承接和回落后的资金回流。`);
403
+ }
404
+ for (const topic of selectDiverseTopics(topics.filter(isRiskTopic), 2)) {
405
+ addUniqueBullet(bullets, `• 观察 ${topic.rule.category} 是否压制风险偏好,重点看“${formatFocusCueText(topic.text)}”有无后续快讯确认。`);
406
+ }
407
+ for (const topic of selectDiverseTopics(topics.filter(isOpportunityTopic), 2)) {
408
+ addUniqueBullet(bullets, `• 观察 ${topic.rule.category} 主题是否扩散,重点看“${formatFocusCueText(topic.text)}”对应方向高开后的承接而非单点冲高。`);
256
409
  }
257
410
  if (bullets.length < 3) {
258
- for (const context of contexts.slice(0, 3 - bullets.length)) {
259
- bullets.push(`• 关注“${formatFocusCue(context)}”对应板块是否出现竞价强化或高开分歧。`);
411
+ for (const context of contexts) {
412
+ addUniqueBullet(bullets, `• 复核“${formatFocusCue(context)}”是否有后续消息或竞价强化,避免仅按标题级线索追高。`);
413
+ if (bullets.length >= 3) {
414
+ break;
415
+ }
260
416
  }
261
417
  }
262
418
  return bullets.slice(0, 5).join("\n");
263
419
  }
420
+ function selectDiverseTopics(topics, limit) {
421
+ const sorted = [...topics].sort(compareTopicsByImportance);
422
+ const selected = [];
423
+ const selectedCategories = new Set();
424
+ const selectedTexts = new Set();
425
+ for (const topic of sorted) {
426
+ if (selected.length >= limit) {
427
+ break;
428
+ }
429
+ const normalizedText = normalizeDigestText(topic.text);
430
+ if (selectedTexts.has(normalizedText) || selectedCategories.has(topic.rule.category)) {
431
+ continue;
432
+ }
433
+ selected.push(topic);
434
+ selectedTexts.add(normalizedText);
435
+ selectedCategories.add(topic.rule.category);
436
+ }
437
+ for (const topic of sorted) {
438
+ if (selected.length >= limit) {
439
+ break;
440
+ }
441
+ const normalizedText = normalizeDigestText(topic.text);
442
+ if (selectedTexts.has(normalizedText)) {
443
+ continue;
444
+ }
445
+ selected.push(topic);
446
+ selectedTexts.add(normalizedText);
447
+ }
448
+ return selected.sort((left, right) => left.context.flash.published_ts - right.context.flash.published_ts);
449
+ }
450
+ function compareTopicsByImportance(left, right) {
451
+ return right.score - left.score || left.context.flash.published_ts - right.context.flash.published_ts;
452
+ }
453
+ function inferTopicRule(text) {
454
+ return TOPIC_RULES
455
+ .filter((rule) => containsAnyKeyword(text, rule.keywords))
456
+ .sort((left, right) => right.score - left.score)[0]
457
+ ?? {
458
+ category: "市场情绪",
459
+ keywords: [],
460
+ score: 3,
461
+ macroImplication: "该线索可能影响局部题材情绪,需结合竞价强弱确认",
462
+ };
463
+ }
464
+ function scoreTopic(text, context, rule) {
465
+ let score = rule.score;
466
+ if (containsAnyKeyword(text, OPPORTUNITY_KEYWORDS)) {
467
+ score += 2;
468
+ }
469
+ if (containsAnyKeyword(text, RISK_KEYWORDS)) {
470
+ score += 2;
471
+ }
472
+ if (context.matchedItems.length > 0) {
473
+ score += 1;
474
+ }
475
+ if (text.length >= 18) {
476
+ score += 1;
477
+ }
478
+ return score;
479
+ }
480
+ function isOpportunityTopic(topic) {
481
+ return Boolean(topic.rule.opportunityImplication) || containsAnyKeyword(topic.text, OPPORTUNITY_KEYWORDS);
482
+ }
483
+ function isRiskTopic(topic) {
484
+ return Boolean(topic.rule.riskImplication) || containsAnyKeyword(topic.text, RISK_KEYWORDS);
485
+ }
486
+ function addWatchlistCue(matchedBySymbol, item, cue) {
487
+ const entry = matchedBySymbol.get(item.symbol) ?? { item, cues: [] };
488
+ const normalizedCue = normalizeDigestText(cue.text);
489
+ if (!entry.cues.some((existing) => normalizeDigestText(existing.text) === normalizedCue)) {
490
+ entry.cues.push(cue);
491
+ }
492
+ matchedBySymbol.set(item.symbol, entry);
493
+ }
494
+ function findBestWatchlistCue(context, item) {
495
+ const exactCue = context.keyPoints.find((point) => topicMatchesWatchlistItem(point, item));
496
+ return exactCue ?? formatContextSummary(context);
497
+ }
498
+ function topicMatchesWatchlistItem(text, item) {
499
+ const normalizedText = normalizeText(text);
500
+ return buildWatchlistKeywordEntries(item)
501
+ .some((entry) => normalizedText.includes(entry.normalized));
502
+ }
503
+ function describeWatchlistMatch(item, text) {
504
+ const normalizedText = normalizeText(text);
505
+ const entries = buildWatchlistKeywordEntries(item);
506
+ const directMatch = entries
507
+ .find((entry) => entry.kind === "direct" && normalizedText.includes(entry.normalized));
508
+ if (directMatch) {
509
+ return "直接命中";
510
+ }
511
+ const themeMatch = entries
512
+ .find((entry) => entry.kind === "theme" && normalizedText.includes(entry.normalized));
513
+ if (themeMatch) {
514
+ return `题材${themeMatch.keyword}`;
515
+ }
516
+ const sectorMatch = entries
517
+ .find((entry) => entry.kind === "sector" && normalizedText.includes(entry.normalized));
518
+ if (sectorMatch) {
519
+ return `行业${sectorMatch.keyword}`;
520
+ }
521
+ return "规则命中";
522
+ }
523
+ function buildWatchlistKeywordEntries(item) {
524
+ const entries = [
525
+ ...[item.symbol, item.symbol.slice(0, 6), item.name].map((keyword) => ({ keyword, kind: "direct" })),
526
+ ...extractSectorKeywords(item.sector).map((keyword) => ({ keyword, kind: "sector" })),
527
+ ...item.themes.map((keyword) => ({ keyword, kind: "theme" })),
528
+ ];
529
+ const seen = new Set();
530
+ return entries
531
+ .map((entry) => ({
532
+ ...entry,
533
+ keyword: entry.keyword.replace(/\s+/g, "").trim(),
534
+ normalized: normalizeText(entry.keyword),
535
+ }))
536
+ .filter((entry) => entry.normalized.length >= 2)
537
+ .filter((entry) => {
538
+ if (seen.has(entry.normalized)) {
539
+ return false;
540
+ }
541
+ seen.add(entry.normalized);
542
+ return true;
543
+ });
544
+ }
545
+ function addUniqueBullet(bullets, bullet) {
546
+ const normalizedBullet = normalizeDigestText(bullet);
547
+ if (!bullets.some((existing) => normalizeDigestText(existing) === normalizedBullet)) {
548
+ bullets.push(bullet);
549
+ }
550
+ }
551
+ function formatFocusCueText(text) {
552
+ return truncateText(text, 34);
553
+ }
264
554
  function containsAnyKeyword(content, keywords) {
265
- return keywords.some((keyword) => content.includes(keyword));
555
+ const normalizedContent = normalizeText(content);
556
+ return keywords.some((keyword) => normalizedContent.includes(normalizeText(keyword)));
266
557
  }
267
558
  function normalizeText(value) {
268
559
  return value.toLowerCase().replace(/\s+/g, "");
@@ -330,7 +621,7 @@ function extractFlashKeyPoints(content, headline) {
330
621
  }
331
622
  deduped.add(normalizedSegment);
332
623
  keyPoints.push(truncateText(segment, 88));
333
- if (keyPoints.length >= 3) {
624
+ if (keyPoints.length >= FALLBACK_TOPIC_KEY_POINT_LIMIT) {
334
625
  break;
335
626
  }
336
627
  }
@@ -341,14 +632,27 @@ function stripHeadline(content, headline) {
341
632
  if (!headline) {
342
633
  return trimmed;
343
634
  }
344
- if (!trimmed.startsWith(headline)) {
635
+ const isTruncatedHeadline = headline.endsWith("...");
636
+ if (!isTruncatedHeadline && trimmed.startsWith(headline)) {
637
+ return trimmed.slice(headline.length).replace(/^[::。;,、\s-]+/, "").trim();
638
+ }
639
+ const withoutDigestPrefix = trimmed
640
+ .replace(/^【?金十数据整理[::]\s*/, "")
641
+ .replace(/^】\s*/, "")
642
+ .trim();
643
+ if (withoutDigestPrefix !== trimmed) {
644
+ return withoutDigestPrefix;
645
+ }
646
+ if (isTruncatedHeadline) {
345
647
  return trimmed;
346
648
  }
347
- return trimmed.slice(headline.length).replace(/^[::。;,、\s-]+/, "").trim();
649
+ return trimmed;
348
650
  }
349
651
  function cleanFlashSegment(segment) {
350
652
  return segment
351
653
  .trim()
654
+ .replace(/^【?金十数据整理[::]\s*/, "")
655
+ .replace(/^([^】]{2,40})】\s*/, "")
352
656
  .replace(/^[-•●▪◦]\s*/, "")
353
657
  .replace(/^\d+\s*[、..))]\s*/, "")
354
658
  .replace(/^[((]?\d+[))]\s*/, "")
@@ -5,6 +5,10 @@ import { WatchlistProfileService } from "./watchlist-profile-service.js";
5
5
  interface GetWatchlistItemOptions {
6
6
  refreshConceptBoards?: boolean;
7
7
  }
8
+ interface AddWatchlistOptions {
9
+ enrichProfile?: boolean;
10
+ name?: string;
11
+ }
8
12
  export interface AddWatchlistResult {
9
13
  item: WatchlistItem;
10
14
  profileError: string | null;
@@ -29,7 +33,7 @@ export declare class WatchlistService {
29
33
  private readonly instrumentService;
30
34
  private readonly watchlistProfileService;
31
35
  constructor(repository: WatchlistRepository, instrumentService: InstrumentService, watchlistProfileService?: WatchlistProfileService | null);
32
- add(symbolInput: string, costPrice?: number | null): Promise<AddWatchlistResult>;
36
+ add(symbolInput: string, costPrice?: number | null, options?: AddWatchlistOptions): Promise<AddWatchlistResult>;
33
37
  list(): Promise<WatchlistItem[]>;
34
38
  remove(symbolInput: string): Promise<boolean>;
35
39
  getBySymbol(symbolInput: string, options?: GetWatchlistItemOptions): Promise<WatchlistItem | null>;
@@ -12,11 +12,11 @@ export class WatchlistService {
12
12
  this.instrumentService = instrumentService;
13
13
  this.watchlistProfileService = watchlistProfileService;
14
14
  }
15
- async add(symbolInput, costPrice = null) {
15
+ async add(symbolInput, costPrice = null, options = {}) {
16
16
  const symbol = normalizeSymbol(symbolInput);
17
17
  const existing = await this.getBySymbol(symbol);
18
18
  const normalizedCostPrice = normalizeCostPrice(costPrice);
19
- const name = await this.instrumentService.resolveName(symbol);
19
+ const name = normalizeOptionalName(options.name) ?? await this.instrumentService.resolveName(symbol);
20
20
  const addedAt = formatChinaDateTime();
21
21
  const fallbackProfile = {
22
22
  sector: existing?.sector ?? null,
@@ -26,7 +26,8 @@ export class WatchlistService {
26
26
  };
27
27
  let profile = fallbackProfile;
28
28
  let profileError = null;
29
- if (this.watchlistProfileService) {
29
+ const enrichProfile = options.enrichProfile ?? true;
30
+ if (enrichProfile && this.watchlistProfileService) {
30
31
  try {
31
32
  const resolved = await this.watchlistProfileService.resolve(symbol, name, addedAt);
32
33
  profile = {
@@ -199,6 +200,10 @@ function sanitizeName(name, symbol) {
199
200
  }
200
201
  return trimmed;
201
202
  }
203
+ function normalizeOptionalName(value) {
204
+ const normalized = sanitizeName(String(value ?? ""), "");
205
+ return normalized || null;
206
+ }
202
207
  function toErrorMessage(error) {
203
208
  return error instanceof Error ? error.message : String(error);
204
209
  }
@@ -0,0 +1,31 @@
1
+ import { MxApiService } from "../services/mx-search-service.js";
2
+ import { WatchlistService } from "../services/watchlist-service.js";
3
+ export declare function listEastmoneyWatchlistTool(mxApiService: MxApiService): {
4
+ name: string;
5
+ description: string;
6
+ run(): Promise<string>;
7
+ };
8
+ export declare function syncEastmoneyWatchlistTool(mxApiService: MxApiService, watchlistService: WatchlistService): {
9
+ name: string;
10
+ description: string;
11
+ optional: boolean;
12
+ run({ rawInput }: {
13
+ rawInput?: unknown;
14
+ }): Promise<string>;
15
+ };
16
+ export declare function pushEastmoneyWatchlistTool(mxApiService: MxApiService, watchlistService: WatchlistService): {
17
+ name: string;
18
+ description: string;
19
+ optional: boolean;
20
+ run({ rawInput }: {
21
+ rawInput?: unknown;
22
+ }): Promise<string>;
23
+ };
24
+ export declare function removeEastmoneyWatchlistTool(mxApiService: MxApiService): {
25
+ name: string;
26
+ description: string;
27
+ optional: boolean;
28
+ run({ rawInput }: {
29
+ rawInput?: unknown;
30
+ }): Promise<string>;
31
+ };