tickflow-assist 0.3.6 → 0.3.8

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 (65) hide show
  1. package/README.md +11 -42
  2. package/dist/analysis/types/composite-analysis.d.ts +27 -0
  3. package/dist/background/realtime-monitor.worker.d.ts +1 -1
  4. package/dist/background/realtime-monitor.worker.js +3 -4
  5. package/dist/bootstrap.js +24 -4
  6. package/dist/config/tickflow-access.d.ts +2 -1
  7. package/dist/config/tickflow-access.js +10 -3
  8. package/dist/dev/run-monitor-loop.js +0 -1
  9. package/dist/dev/tickflow-assist-cli.js +4 -3
  10. package/dist/dev/validate-mx-search.js +10 -2
  11. package/dist/plugin-commands.js +27 -0
  12. package/dist/plugin.js +4 -6
  13. package/dist/prompts/analysis/kline-analysis-user-prompt.js +2 -1
  14. package/dist/prompts/analysis/post-close-review-user-prompt.js +40 -1
  15. package/dist/prompts/analysis/pre-market-brief-prompt.d.ts +3 -1
  16. package/dist/prompts/analysis/pre-market-brief-prompt.js +8 -3
  17. package/dist/services/industry-peer-service.d.ts +9 -0
  18. package/dist/services/industry-peer-service.js +152 -0
  19. package/dist/services/jin10-flash-monitor-service.js +2 -1
  20. package/dist/services/monitor-service.d.ts +1 -1
  21. package/dist/services/monitor-service.js +21 -26
  22. package/dist/services/mx-search-service.d.ts +8 -1
  23. package/dist/services/mx-search-service.js +400 -10
  24. package/dist/services/post-close-review-service.d.ts +11 -4
  25. package/dist/services/post-close-review-service.js +113 -10
  26. package/dist/services/pre-market-brief-service.js +500 -42
  27. package/dist/services/tickflow-client.d.ts +4 -1
  28. package/dist/services/tickflow-client.js +32 -0
  29. package/dist/services/tickflow-universe-service.d.ts +26 -0
  30. package/dist/services/tickflow-universe-service.js +213 -0
  31. package/dist/services/watchlist-profile-service.d.ts +4 -1
  32. package/dist/services/watchlist-profile-service.js +58 -29
  33. package/dist/services/watchlist-service.d.ts +5 -1
  34. package/dist/services/watchlist-service.js +9 -4
  35. package/dist/storage/repositories/universe-membership-repo.d.ts +11 -0
  36. package/dist/storage/repositories/universe-membership-repo.js +38 -0
  37. package/dist/storage/repositories/universe-repo.d.ts +17 -0
  38. package/dist/storage/repositories/universe-repo.js +62 -0
  39. package/dist/storage/schemas.d.ts +2 -0
  40. package/dist/storage/schemas.js +13 -0
  41. package/dist/tools/add-stock.tool.d.ts +2 -1
  42. package/dist/tools/add-stock.tool.js +10 -1
  43. package/dist/tools/eastmoney-watchlist.tool.d.ts +31 -0
  44. package/dist/tools/eastmoney-watchlist.tool.js +294 -0
  45. package/dist/tools/mx-data.tool.d.ts +8 -0
  46. package/dist/tools/mx-data.tool.js +94 -0
  47. package/dist/tools/mx-select-stock.tool.js +6 -2
  48. package/dist/tools/query-database.tool.js +6 -0
  49. package/dist/tools/refresh-watchlist-profiles.tool.d.ts +2 -1
  50. package/dist/tools/refresh-watchlist-profiles.tool.js +11 -1
  51. package/dist/tools/screen-stock-candidates.tool.d.ts +34 -0
  52. package/dist/tools/screen-stock-candidates.tool.js +477 -0
  53. package/dist/tools/test-alert.tool.js +56 -19
  54. package/dist/types/mx-data.d.ts +23 -0
  55. package/dist/types/mx-data.js +1 -0
  56. package/dist/types/mx-select-stock.d.ts +1 -0
  57. package/dist/types/mx-self-select.d.ts +30 -0
  58. package/dist/types/mx-self-select.js +1 -0
  59. package/dist/types/tickflow.d.ts +12 -0
  60. package/dist/utils/tickflow-quote.d.ts +5 -0
  61. package/dist/utils/tickflow-quote.js +31 -0
  62. package/openclaw.plugin.json +83 -6
  63. package/package.json +6 -6
  64. package/skills/stock-analysis/SKILL.md +39 -20
  65. package/skills/usage-help/SKILL.md +33 -0
@@ -1,6 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { PRE_MARKET_BRIEF_SYSTEM_PROMPT, buildPreMarketBriefUserPrompt, } from "../prompts/analysis/index.js";
3
3
  import { formatChinaDateTime } from "../utils/china-time.js";
4
+ import { extractSectorKeywords } from "./watchlist-profile-service.js";
4
5
  const PRE_MARKET_BRIEF_KEYWORD = "金十数据整理";
5
6
  const PRE_MARKET_READY_TIME = "09:20:00";
6
7
  const PRE_MARKET_SYNC_MAX_PAGES = 12;
@@ -32,6 +33,76 @@ const RISK_KEYWORDS = [
32
33
  "制裁",
33
34
  "关税",
34
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
+ ];
35
106
  export class PreMarketBriefService {
36
107
  watchlistService;
37
108
  jin10McpService;
@@ -80,10 +151,7 @@ export class PreMarketBriefService {
80
151
  matchedWatchlistCount: 0,
81
152
  };
82
153
  }
83
- const matchContexts = flashes.map((flash) => ({
84
- flash,
85
- matchedItems: findMatchedItems(flash, watchlist),
86
- }));
154
+ const matchContexts = flashes.map((flash) => buildFlashMatchContext(flash, watchlist));
87
155
  const matchedWatchlistCount = new Set(matchContexts.flatMap((context) => context.matchedItems.map((item) => item.symbol))).size;
88
156
  const summary = await this.buildSummary(window, watchlist, matchContexts);
89
157
  return {
@@ -128,7 +196,9 @@ export class PreMarketBriefService {
128
196
  watchlist,
129
197
  flashes: matchContexts.map((context) => ({
130
198
  publishedAt: context.flash.published_at,
131
- headline: extractHeadlineFromContent(context.flash.content),
199
+ headline: context.headline,
200
+ summary: context.summary,
201
+ keyPoints: context.keyPoints,
132
202
  content: context.flash.content,
133
203
  url: context.flash.url,
134
204
  matchedSymbols: context.matchedItems.map((item) => item.symbol),
@@ -136,10 +206,13 @@ export class PreMarketBriefService {
136
206
  };
137
207
  if (this.analysisService.isConfigured()) {
138
208
  try {
139
- return await this.analysisService.generateText(PRE_MARKET_BRIEF_SYSTEM_PROMPT, buildPreMarketBriefUserPrompt(promptInput), {
209
+ const generated = await this.analysisService.generateText(PRE_MARKET_BRIEF_SYSTEM_PROMPT, buildPreMarketBriefUserPrompt(promptInput), {
140
210
  maxTokens: 1600,
141
211
  temperature: 0.2,
142
212
  });
213
+ if (!isLowSignalSummary(generated, matchContexts)) {
214
+ return generated;
215
+ }
143
216
  }
144
217
  catch {
145
218
  // Fall through to deterministic fallback so the scheduled push still lands.
@@ -182,74 +255,305 @@ function matchesPreMarketBrief(record) {
182
255
  function findMatchedItems(flash, watchlist) {
183
256
  const normalizedContent = normalizeText(flash.content);
184
257
  return watchlist.filter((item) => {
185
- const directKeywords = [item.symbol, item.symbol.slice(0, 6), item.name];
186
- const boardKeywords = [item.sector ?? "", ...item.themes]
187
- .map((keyword) => keyword.replace(/\s+/g, "").trim())
188
- .filter((keyword) => keyword.length >= 2);
189
- return [...directKeywords, ...boardKeywords]
190
- .map((keyword) => normalizeText(keyword))
191
- .some((keyword) => keyword && normalizedContent.includes(keyword));
258
+ return buildWatchlistKeywordEntries(item)
259
+ .some((entry) => normalizedContent.includes(entry.normalized));
192
260
  });
193
261
  }
262
+ function buildFlashMatchContext(flash, watchlist) {
263
+ const insight = extractFlashInsight(flash.content);
264
+ return {
265
+ flash,
266
+ matchedItems: findMatchedItems(flash, watchlist),
267
+ headline: insight.headline,
268
+ summary: insight.summary,
269
+ keyPoints: insight.keyPoints,
270
+ };
271
+ }
194
272
  function buildFallbackSummary(matchContexts) {
195
- const opportunityContexts = matchContexts.filter((context) => containsAnyKeyword(context.flash.content, OPPORTUNITY_KEYWORDS));
196
- 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);
197
276
  return [
198
277
  formatSectionTitle("🧭", "重大要闻"),
199
- formatFlashBullets(matchContexts, 5),
278
+ formatMajorNewsBullets(topics),
200
279
  "",
201
280
  formatSectionTitle("🎯", "自选相关"),
202
- formatMatchedBullets(matchContexts, 5),
281
+ formatMatchedBullets(matchContexts, topics, 5),
203
282
  "",
204
283
  formatSectionTitle("💡", "潜在机会"),
205
- opportunityContexts.length > 0
206
- ? formatFlashBullets(opportunityContexts, 4)
284
+ opportunityTopics.length > 0
285
+ ? formatOpportunityBullets(opportunityTopics, 4)
207
286
  : "• 未发现基于当前整理快讯可直接确认的新增机会方向。",
208
287
  "",
209
288
  formatSectionTitle("⚠️", "风险提示"),
210
- riskContexts.length > 0
211
- ? formatFlashBullets(riskContexts, 4)
289
+ riskTopics.length > 0
290
+ ? formatRiskBullets(riskTopics, 4)
212
291
  : "• 当前整理快讯中未发现特别突出的新增风险,但仍需留意开盘后的情绪变化。",
213
292
  "",
214
293
  formatSectionTitle("📌", "开盘前关注清单"),
215
- buildFocusBullets(matchContexts),
294
+ buildFocusBullets(topics, matchContexts),
216
295
  ].join("\n");
217
296
  }
218
- function formatFlashBullets(contexts, limit) {
219
- return contexts
220
- .slice(0, limit)
221
- .map((context) => {
222
- const time = context.flash.published_at.slice(11, 16);
223
- return `• [${time}] ${extractHeadlineFromContent(context.flash.content)}`;
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}。`;
352
+ })
353
+ .join("\n");
354
+ }
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}。`;
224
360
  })
225
361
  .join("\n");
226
362
  }
227
- function formatMatchedBullets(contexts, limit) {
228
- const matched = contexts.filter((context) => context.matchedItems.length > 0).slice(0, limit);
229
- if (matched.length === 0) {
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) {
230
387
  return "• 未发现直接命中自选股、行业或题材的盘前整理快讯。";
231
388
  }
232
- return matched.map((context) => {
233
- const labels = context.matchedItems.map((item) => `${item.name}(${item.symbol})`).join("、");
234
- return `• ${labels}: ${extractHeadlineFromContent(context.flash.content)}`;
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}`;
235
395
  }).join("\n");
236
396
  }
237
- function buildFocusBullets(contexts) {
397
+ function buildFocusBullets(topics, contexts) {
238
398
  const bullets = [];
239
- const matchedContexts = contexts.filter((context) => context.matchedItems.length > 0);
240
- for (const context of matchedContexts.slice(0, 3)) {
241
- const labels = context.matchedItems.map((item) => item.name).join("、");
242
- bullets.push(`• 关注 ${labels} 开盘后的量价反馈,核实“${extractHeadlineFromContent(context.flash.content)}”是否继续发酵。`);
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)}”对应方向高开后的承接而非单点冲高。`);
243
409
  }
244
410
  if (bullets.length < 3) {
245
- for (const context of contexts.slice(0, 3 - bullets.length)) {
246
- bullets.push(`• 关注“${extractHeadlineFromContent(context.flash.content)}”对应板块是否出现竞价强化或高开分歧。`);
411
+ for (const context of contexts) {
412
+ addUniqueBullet(bullets, `• 复核“${formatFocusCue(context)}”是否有后续消息或竞价强化,避免仅按标题级线索追高。`);
413
+ if (bullets.length >= 3) {
414
+ break;
415
+ }
247
416
  }
248
417
  }
249
418
  return bullets.slice(0, 5).join("\n");
250
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
+ }
251
554
  function containsAnyKeyword(content, keywords) {
252
- return keywords.some((keyword) => content.includes(keyword));
555
+ const normalizedContent = normalizeText(content);
556
+ return keywords.some((keyword) => normalizedContent.includes(normalizeText(keyword)));
253
557
  }
254
558
  function normalizeText(value) {
255
559
  return value.toLowerCase().replace(/\s+/g, "");
@@ -264,6 +568,160 @@ function extractHeadlineFromContent(content) {
264
568
  function extractHeadlineText(content) {
265
569
  return content.split(/[\n。!!]/)[0]?.trim() ?? "";
266
570
  }
571
+ function extractFlashInsight(content) {
572
+ const headline = extractHeadlineFromContent(content);
573
+ const keyPoints = extractFlashKeyPoints(content, headline);
574
+ if (keyPoints.length === 0) {
575
+ return {
576
+ headline,
577
+ summary: buildTitleOnlySummary(headline),
578
+ keyPoints: [],
579
+ };
580
+ }
581
+ return {
582
+ headline,
583
+ summary: keyPoints.slice(0, 2).join(";"),
584
+ keyPoints,
585
+ };
586
+ }
587
+ function extractFlashKeyPoints(content, headline) {
588
+ const body = stripHeadline(content, headline);
589
+ if (!body) {
590
+ return [];
591
+ }
592
+ const lineCandidates = body
593
+ .replace(/\r/g, "\n")
594
+ .replace(/\n{2,}/g, "\n")
595
+ .replace(/([。;!?!?])\s*(?=\d+\s*[、..))])/g, "$1\n")
596
+ .split(/\n+/)
597
+ .map((segment) => cleanFlashSegment(segment))
598
+ .filter(Boolean);
599
+ const candidates = lineCandidates.length > 1
600
+ ? lineCandidates
601
+ : body
602
+ .split(/[。;!?!?]/)
603
+ .map((segment) => cleanFlashSegment(segment))
604
+ .filter(Boolean);
605
+ const normalizedHeadline = normalizeText(headline);
606
+ const deduped = new Set();
607
+ const keyPoints = [];
608
+ for (const segment of candidates) {
609
+ const normalizedSegment = normalizeText(segment);
610
+ if (!normalizedSegment || normalizedSegment === normalizedHeadline) {
611
+ continue;
612
+ }
613
+ if (normalizedHeadline && normalizedHeadline.includes(normalizedSegment) && segment.length < Math.max(12, headline.length)) {
614
+ continue;
615
+ }
616
+ if (segment.length < 8) {
617
+ continue;
618
+ }
619
+ if (deduped.has(normalizedSegment)) {
620
+ continue;
621
+ }
622
+ deduped.add(normalizedSegment);
623
+ keyPoints.push(truncateText(segment, 88));
624
+ if (keyPoints.length >= FALLBACK_TOPIC_KEY_POINT_LIMIT) {
625
+ break;
626
+ }
627
+ }
628
+ return keyPoints;
629
+ }
630
+ function stripHeadline(content, headline) {
631
+ const trimmed = content.trim();
632
+ if (!headline) {
633
+ return trimmed;
634
+ }
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) {
647
+ return trimmed;
648
+ }
649
+ return trimmed;
650
+ }
651
+ function cleanFlashSegment(segment) {
652
+ return segment
653
+ .trim()
654
+ .replace(/^【?金十数据整理[::]\s*/, "")
655
+ .replace(/^([^】]{2,40})】\s*/, "")
656
+ .replace(/^[-•●▪◦]\s*/, "")
657
+ .replace(/^\d+\s*[、..))]\s*/, "")
658
+ .replace(/^[((]?\d+[))]\s*/, "")
659
+ .replace(/^[::;,。、\s]+/, "")
660
+ .replace(/[::;,。、\s]+$/, "")
661
+ .replace(/\s+/g, " ");
662
+ }
663
+ function buildTitleOnlySummary(headline) {
664
+ const coreHeadline = headline.replace(/^【?金十数据整理[::]\s*/, "").replace(/】$/, "").trim();
665
+ if (!coreHeadline) {
666
+ return "该整理快讯未提取到可用细节,暂只能作为标题级线索参考。";
667
+ }
668
+ return `${coreHeadline},但正文未提取到更具体细节,暂只能作为标题级线索参考。`;
669
+ }
670
+ function formatContextSummary(context) {
671
+ return context.summary || buildTitleOnlySummary(context.headline);
672
+ }
673
+ function formatFocusCue(context) {
674
+ return context.keyPoints[0] ?? context.summary ?? context.headline;
675
+ }
676
+ function truncateText(value, maxLength) {
677
+ return value.length <= maxLength ? value : `${value.slice(0, maxLength)}...`;
678
+ }
679
+ function isLowSignalSummary(summary, contexts) {
680
+ const bulletLines = summary
681
+ .split(/\n+/)
682
+ .map((line) => line.trim())
683
+ .filter((line) => /^(?:•|-|\d+\.)/.test(line));
684
+ if (bulletLines.length === 0) {
685
+ return true;
686
+ }
687
+ const titleOnlyCount = bulletLines.filter((line) => isTitleOnlyBullet(line, contexts)).length;
688
+ return titleOnlyCount >= Math.max(2, Math.ceil(bulletLines.length / 3));
689
+ }
690
+ function isTitleOnlyBullet(line, contexts) {
691
+ const candidates = normalizeBulletForComparison(line);
692
+ if (candidates.length === 0) {
693
+ return false;
694
+ }
695
+ return contexts.some((context) => {
696
+ const headlineForms = [
697
+ normalizeDigestText(context.headline),
698
+ normalizeDigestText(stripDigestPrefix(context.headline)),
699
+ ].filter(Boolean);
700
+ return headlineForms.some((headline) => candidates.some((candidate) => candidate.includes(headline) && candidate.length <= headline.length + 6));
701
+ });
702
+ }
703
+ function normalizeBulletForComparison(line) {
704
+ const cleaned = line
705
+ .replace(/^(?:•|-|\d+\.)\s*/, "")
706
+ .replace(/^\[[0-9:]+\]\s*/, "");
707
+ const normalizedVariants = [
708
+ cleaned,
709
+ cleaned.split(/[::]/).at(-1) ?? cleaned,
710
+ ]
711
+ .map((item) => normalizeDigestText(item))
712
+ .filter(Boolean);
713
+ return [...new Set(normalizedVariants)];
714
+ }
715
+ function stripDigestPrefix(value) {
716
+ return value.replace(/^【?金十数据整理[::]\s*/, "").replace(/】$/, "").trim();
717
+ }
718
+ function normalizeDigestText(value) {
719
+ return value
720
+ .toLowerCase()
721
+ .replace(/[【】[\]()()"'“”‘’]/g, "")
722
+ .replace(/[::]/g, "")
723
+ .replace(/\s+/g, "");
724
+ }
267
725
  function toFlashRecord(item) {
268
726
  const published = new Date(item.time);
269
727
  if (Number.isNaN(published.getTime())) {
@@ -1,4 +1,4 @@
1
- import type { TickFlowBalanceSheetRecord, TickFlowCashFlowRecord, TickFlowFinancialMetricsRecord, TickFlowFinancialQueryOptions, TickFlowIncomeRecord, TickFlowInstrument, TickFlowQuote } from "../types/tickflow.js";
1
+ import type { TickFlowBalanceSheetRecord, TickFlowCashFlowRecord, TickFlowFinancialMetricsRecord, TickFlowFinancialQueryOptions, TickFlowIncomeRecord, TickFlowInstrument, TickFlowQuote, TickFlowUniverseDetail, TickFlowUniverseSummary } from "../types/tickflow.js";
2
2
  export declare class TickFlowClientError extends Error {
3
3
  constructor(message: string);
4
4
  }
@@ -10,6 +10,9 @@ export declare class TickFlowClient {
10
10
  getApiKey(): string;
11
11
  fetchInstruments(symbols: string[]): Promise<TickFlowInstrument[]>;
12
12
  fetchQuotes(symbols: string[]): Promise<TickFlowQuote[]>;
13
+ listUniverses(): Promise<TickFlowUniverseSummary[]>;
14
+ fetchUniverse(id: string): Promise<TickFlowUniverseDetail | null>;
15
+ fetchUniverseBatch(ids: string[]): Promise<Record<string, TickFlowUniverseDetail>>;
13
16
  fetchKlinesBatch<T = unknown>(symbols: string[], params?: {
14
17
  period?: string;
15
18
  count?: number;
@@ -39,6 +39,38 @@ export class TickFlowClient {
39
39
  });
40
40
  return response.data ?? [];
41
41
  }
42
+ async listUniverses() {
43
+ const url = new URL("/v1/universes", this.baseUrl);
44
+ const response = await this.requestJson(url.toString(), {
45
+ method: "GET",
46
+ });
47
+ return response.data ?? [];
48
+ }
49
+ async fetchUniverse(id) {
50
+ const normalizedId = String(id ?? "").trim();
51
+ if (!normalizedId) {
52
+ return null;
53
+ }
54
+ const url = new URL(`/v1/universes/${encodeURIComponent(normalizedId)}`, this.baseUrl);
55
+ const response = await this.requestJson(url.toString(), {
56
+ method: "GET",
57
+ });
58
+ return response.data ?? null;
59
+ }
60
+ async fetchUniverseBatch(ids) {
61
+ const normalizedIds = ids
62
+ .map((id) => String(id ?? "").trim())
63
+ .filter(Boolean);
64
+ if (normalizedIds.length === 0) {
65
+ return {};
66
+ }
67
+ const url = new URL("/v1/universes/batch", this.baseUrl);
68
+ const response = await this.requestJson(url.toString(), {
69
+ method: "POST",
70
+ body: JSON.stringify({ ids: normalizedIds }),
71
+ });
72
+ return response.data ?? {};
73
+ }
42
74
  async fetchKlinesBatch(symbols, params = {}) {
43
75
  if (symbols.length === 0) {
44
76
  return { data: {} };