tickflow-assist 0.3.5 → 0.3.7

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 (49) hide show
  1. package/README.md +9 -40
  2. package/dist/analysis/types/composite-analysis.d.ts +27 -0
  3. package/dist/bootstrap.js +19 -5
  4. package/dist/config/tickflow-access.d.ts +2 -1
  5. package/dist/config/tickflow-access.js +10 -3
  6. package/dist/dev/tickflow-assist-cli.js +4 -3
  7. package/dist/dev/validate-mx-search.js +10 -2
  8. package/dist/plugin.js +4 -6
  9. package/dist/prompts/analysis/kline-analysis-user-prompt.js +2 -1
  10. package/dist/prompts/analysis/post-close-review-user-prompt.js +40 -1
  11. package/dist/prompts/analysis/pre-market-brief-prompt.d.ts +3 -1
  12. package/dist/prompts/analysis/pre-market-brief-prompt.js +5 -1
  13. package/dist/services/alert-service.d.ts +8 -0
  14. package/dist/services/alert-service.js +327 -45
  15. package/dist/services/industry-peer-service.d.ts +9 -0
  16. package/dist/services/industry-peer-service.js +152 -0
  17. package/dist/services/jin10-flash-monitor-service.js +2 -1
  18. package/dist/services/monitor-service.d.ts +4 -1
  19. package/dist/services/monitor-service.js +51 -20
  20. package/dist/services/post-close-review-service.d.ts +11 -4
  21. package/dist/services/post-close-review-service.js +113 -10
  22. package/dist/services/pre-market-brief-service.js +165 -11
  23. package/dist/services/tickflow-client.d.ts +4 -1
  24. package/dist/services/tickflow-client.js +32 -0
  25. package/dist/services/tickflow-universe-service.d.ts +26 -0
  26. package/dist/services/tickflow-universe-service.js +213 -0
  27. package/dist/services/watchlist-profile-service.d.ts +4 -1
  28. package/dist/services/watchlist-profile-service.js +58 -29
  29. package/dist/services/watchlist-service.js +1 -1
  30. package/dist/storage/repositories/universe-membership-repo.d.ts +11 -0
  31. package/dist/storage/repositories/universe-membership-repo.js +38 -0
  32. package/dist/storage/repositories/universe-repo.d.ts +17 -0
  33. package/dist/storage/repositories/universe-repo.js +62 -0
  34. package/dist/storage/schemas.d.ts +2 -0
  35. package/dist/storage/schemas.js +13 -0
  36. package/dist/tools/add-stock.tool.d.ts +2 -1
  37. package/dist/tools/add-stock.tool.js +10 -1
  38. package/dist/tools/query-database.tool.js +6 -0
  39. package/dist/tools/refresh-watchlist-profiles.tool.d.ts +2 -1
  40. package/dist/tools/refresh-watchlist-profiles.tool.js +11 -1
  41. package/dist/tools/test-alert.tool.js +58 -16
  42. package/dist/types/tickflow.d.ts +12 -0
  43. package/dist/utils/alert-diagnostic-log.d.ts +12 -0
  44. package/dist/utils/alert-diagnostic-log.js +60 -0
  45. package/dist/utils/tickflow-quote.d.ts +5 -0
  46. package/dist/utils/tickflow-quote.js +31 -0
  47. package/openclaw.plugin.json +108 -2
  48. package/package.json +10 -5
  49. package/skills/stock-analysis/SKILL.md +9 -20
@@ -1,4 +1,5 @@
1
1
  import type { MonitorState } from "../types/monitor.js";
2
+ import { AlertDiagnosticLogger } from "../utils/alert-diagnostic-log.js";
2
3
  import { QuoteService } from "./quote-service.js";
3
4
  import { TradingCalendarService } from "./trading-calendar-service.js";
4
5
  import { WatchlistService } from "./watchlist-service.js";
@@ -23,7 +24,8 @@ export declare class MonitorService {
23
24
  private readonly klineService;
24
25
  private readonly alertService;
25
26
  private readonly alertMediaService;
26
- constructor(baseDir: string, requestInterval: number, alertChannel: string, watchlistService: WatchlistService, quoteService: QuoteService, tradingCalendarService: TradingCalendarService, keyLevelsRepository: KeyLevelsRepository, alertLogRepository: AlertLogRepository, klinesRepository: KlinesRepository, intradayKlinesRepository: IntradayKlinesRepository, klineService: KlineService, alertService: AlertService, alertMediaService: AlertMediaService);
27
+ private readonly diagnosticLogger?;
28
+ constructor(baseDir: string, requestInterval: number, alertChannel: string, watchlistService: WatchlistService, quoteService: QuoteService, tradingCalendarService: TradingCalendarService, keyLevelsRepository: KeyLevelsRepository, alertLogRepository: AlertLogRepository, klinesRepository: KlinesRepository, intradayKlinesRepository: IntradayKlinesRepository, klineService: KlineService, alertService: AlertService, alertMediaService: AlertMediaService, diagnosticLogger?: AlertDiagnosticLogger | undefined);
27
29
  start(): Promise<string>;
28
30
  stop(): Promise<string>;
29
31
  enableManagedLoop(): Promise<{
@@ -59,4 +61,5 @@ export declare class MonitorService {
59
61
  private cleanupAlertMedia;
60
62
  private getRunLockFilePath;
61
63
  private getAlertClaimFilePath;
64
+ private logDiagnostic;
62
65
  }
@@ -1,7 +1,9 @@
1
1
  import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
+ import { basenameOrUndefined, buildAlertMessageHash, truncateDiagnosticText, } from "../utils/alert-diagnostic-log.js";
3
4
  import { formatChinaDateTime } from "../utils/china-time.js";
4
5
  import { calculateProfitPct, formatCostPrice } from "../utils/cost-price.js";
6
+ import { resolveTickFlowQuoteChangePct } from "../utils/tickflow-quote.js";
5
7
  const DEFAULT_STATE = {
6
8
  running: false,
7
9
  startedAt: null,
@@ -36,7 +38,8 @@ export class MonitorService {
36
38
  klineService;
37
39
  alertService;
38
40
  alertMediaService;
39
- constructor(baseDir, requestInterval, alertChannel, watchlistService, quoteService, tradingCalendarService, keyLevelsRepository, alertLogRepository, klinesRepository, intradayKlinesRepository, klineService, alertService, alertMediaService) {
41
+ diagnosticLogger;
42
+ constructor(baseDir, requestInterval, alertChannel, watchlistService, quoteService, tradingCalendarService, keyLevelsRepository, alertLogRepository, klinesRepository, intradayKlinesRepository, klineService, alertService, alertMediaService, diagnosticLogger) {
40
43
  this.baseDir = baseDir;
41
44
  this.requestInterval = requestInterval;
42
45
  this.alertChannel = alertChannel;
@@ -50,6 +53,7 @@ export class MonitorService {
50
53
  this.klineService = klineService;
51
54
  this.alertService = alertService;
52
55
  this.alertMediaService = alertMediaService;
56
+ this.diagnosticLogger = diagnosticLogger;
53
57
  }
54
58
  async start() {
55
59
  const watchlist = await this.watchlistService.list();
@@ -464,21 +468,54 @@ export class MonitorService {
464
468
  }
465
469
  async trySendAlert(symbol, ruleName, input) {
466
470
  const sessionKey = getSessionKey();
471
+ const message = typeof input === "string" ? input : input.message;
472
+ const messageHash = buildAlertMessageHash(message);
473
+ const hasMedia = typeof input !== "string" && Boolean(input.mediaPath);
474
+ await this.logDiagnostic("try_send_alert_enter", {
475
+ symbol,
476
+ ruleName,
477
+ sessionKey,
478
+ messageHash,
479
+ hasMedia,
480
+ mediaFile: typeof input === "string" ? undefined : basenameOrUndefined(input.mediaPath),
481
+ });
467
482
  const claim = await this.tryAcquireAlertClaim(symbol, ruleName, sessionKey);
468
483
  if (!claim) {
484
+ await this.logDiagnostic("try_send_alert_claim_busy", {
485
+ symbol,
486
+ ruleName,
487
+ sessionKey,
488
+ messageHash,
489
+ });
469
490
  await this.cleanupAlertMedia(input);
470
491
  return false;
471
492
  }
472
493
  try {
473
494
  if (await this.alertLogRepository.isSentThisSession(symbol, ruleName, sessionKey)) {
495
+ await this.logDiagnostic("try_send_alert_already_sent", {
496
+ symbol,
497
+ ruleName,
498
+ sessionKey,
499
+ messageHash,
500
+ });
474
501
  await this.cleanupAlertMedia(input);
475
502
  return false;
476
503
  }
477
504
  const result = await this.sendAlertAndCleanupMedia(input);
478
- if (!result.ok) {
505
+ await this.logDiagnostic("try_send_alert_result", {
506
+ symbol,
507
+ ruleName,
508
+ sessionKey,
509
+ messageHash,
510
+ ok: result.ok,
511
+ mediaAttempted: result.mediaAttempted,
512
+ mediaDelivered: result.mediaDelivered,
513
+ deliveryUncertain: result.deliveryUncertain === true,
514
+ error: result.error ? truncateDiagnosticText(result.error) : null,
515
+ });
516
+ if (!result.ok && !result.deliveryUncertain) {
479
517
  return false;
480
518
  }
481
- const message = typeof input === "string" ? input : input.message;
482
519
  await this.alertLogRepository.append({
483
520
  symbol,
484
521
  alert_date: sessionKey,
@@ -486,6 +523,12 @@ export class MonitorService {
486
523
  message,
487
524
  triggered_at: formatChinaDateTime(),
488
525
  });
526
+ await this.logDiagnostic("try_send_alert_logged", {
527
+ symbol,
528
+ ruleName,
529
+ sessionKey,
530
+ messageHash,
531
+ });
489
532
  return true;
490
533
  }
491
534
  finally {
@@ -616,6 +659,9 @@ export class MonitorService {
616
659
  getAlertClaimFilePath(symbol, ruleName, sessionKey) {
617
660
  return path.join(this.baseDir, "alert-claims", `${sanitizeAlertClaimPart(sessionKey)}_${sanitizeAlertClaimPart(symbol)}_${sanitizeAlertClaimPart(ruleName)}.lock`);
618
661
  }
662
+ async logDiagnostic(event, details) {
663
+ await this.diagnosticLogger?.append("monitor_service", event, details);
664
+ }
619
665
  }
620
666
  function formatRunningState(state, requestInterval) {
621
667
  const heartbeat = getHeartbeatStatus(state, requestInterval);
@@ -692,13 +738,7 @@ function formatTradingPhase(phase) {
692
738
  }
693
739
  function formatQuoteLine(item, quote) {
694
740
  const lastPrice = Number(quote.last_price ?? 0);
695
- const prevClose = Number(quote.prev_close ?? 0);
696
- const tickflowChangePct = quote.ext?.change_pct;
697
- const changePct = tickflowChangePct != null
698
- ? Number(tickflowChangePct) * 100
699
- : prevClose > 0
700
- ? ((lastPrice - prevClose) / prevClose) * 100
701
- : null;
741
+ const changePct = resolveTickFlowQuoteChangePct(quote);
702
742
  const quoteTime = formatQuoteTimestamp(quote.timestamp);
703
743
  const profitPct = calculateProfitPct(lastPrice, item.costPrice);
704
744
  let line = `• ${item.name}(${item.symbol}) ${lastPrice.toFixed(2)}`;
@@ -962,14 +1002,5 @@ function resolveAlertImageLabel(ruleName, fallbackTitle) {
962
1002
  }
963
1003
  }
964
1004
  function getQuoteChangePct(quote) {
965
- const tickflowChangePct = quote.ext?.change_pct;
966
- if (tickflowChangePct != null && Number.isFinite(Number(tickflowChangePct))) {
967
- return Number(tickflowChangePct) * 100;
968
- }
969
- const lastPrice = Number(quote.last_price ?? 0);
970
- const prevClose = Number(quote.prev_close ?? 0);
971
- if (!(prevClose > 0)) {
972
- return null;
973
- }
974
- return ((lastPrice - prevClose) / prevClose) * 100;
1005
+ return resolveTickFlowQuoteChangePct(quote);
975
1006
  }
@@ -1,6 +1,6 @@
1
1
  import type { WatchlistItem } from "../types/domain.js";
2
2
  import { CompositeAnalysisOrchestrator } from "../analysis/orchestrators/composite-analysis.orchestrator.js";
3
- import type { CompositeAnalysisResult, PostCloseReviewResult, PriorKeyLevelValidationContext } from "../analysis/types/composite-analysis.js";
3
+ import type { CompositeAnalysisResult, IndustryPeerContext, PostCloseReviewResult, PriorKeyLevelValidationContext } from "../analysis/types/composite-analysis.js";
4
4
  import { PostCloseReviewTask } from "../analysis/tasks/post-close-review.task.js";
5
5
  import { WatchlistService } from "./watchlist-service.js";
6
6
  import { AnalysisService } from "./analysis-service.js";
@@ -10,6 +10,11 @@ import { KlinesRepository } from "../storage/repositories/klines-repo.js";
10
10
  import { IntradayKlinesRepository } from "../storage/repositories/intraday-klines-repo.js";
11
11
  import { Jin10FlashDeliveryRepository } from "../storage/repositories/jin10-flash-delivery-repo.js";
12
12
  import { Jin10FlashRepository } from "../storage/repositories/jin10-flash-repo.js";
13
+ import { IndustryPeerService } from "./industry-peer-service.js";
14
+ interface ReviewMarketSummary {
15
+ latestClose: number | null;
16
+ dailyChangePct: number | null;
17
+ }
13
18
  export interface PostCloseReviewRunResult {
14
19
  overviewMessage: string;
15
20
  detailMessages: string[];
@@ -26,7 +31,8 @@ export declare class PostCloseReviewService {
26
31
  private readonly intradayKlinesRepository;
27
32
  private readonly flashDeliveryRepository;
28
33
  private readonly flashRepository;
29
- constructor(watchlistService: WatchlistService, compositeAnalysisOrchestrator: CompositeAnalysisOrchestrator, analysisService: AnalysisService, postCloseReviewTask: PostCloseReviewTask, keyLevelsRepository: KeyLevelsRepository, keyLevelsHistoryRepository: KeyLevelsHistoryRepository, klinesRepository: KlinesRepository, intradayKlinesRepository: IntradayKlinesRepository, flashDeliveryRepository: Jin10FlashDeliveryRepository, flashRepository: Jin10FlashRepository);
34
+ private readonly industryPeerService;
35
+ constructor(watchlistService: WatchlistService, compositeAnalysisOrchestrator: CompositeAnalysisOrchestrator, analysisService: AnalysisService, postCloseReviewTask: PostCloseReviewTask, keyLevelsRepository: KeyLevelsRepository, keyLevelsHistoryRepository: KeyLevelsHistoryRepository, klinesRepository: KlinesRepository, intradayKlinesRepository: IntradayKlinesRepository, flashDeliveryRepository: Jin10FlashDeliveryRepository, flashRepository: Jin10FlashRepository, industryPeerService: IndustryPeerService);
30
36
  run(): Promise<PostCloseReviewRunResult>;
31
37
  private persistReview;
32
38
  private persistFallbackCompositeReview;
@@ -36,5 +42,6 @@ export declare class PostCloseReviewService {
36
42
  private formatDetailMessage;
37
43
  private formatFailureMessage;
38
44
  }
39
- export declare function formatPostCloseReviewDetailMessage(item: WatchlistItem, validation: PriorKeyLevelValidationContext, review: PostCloseReviewResult): string;
40
- export declare function formatPostCloseReviewFailureMessage(item: WatchlistItem, errorMessage: string, compositeResult: CompositeAnalysisResult | null): string;
45
+ export declare function formatPostCloseReviewDetailMessage(item: WatchlistItem, validation: PriorKeyLevelValidationContext, review: PostCloseReviewResult, marketSummary?: ReviewMarketSummary | null, peerContext?: IndustryPeerContext | null): string;
46
+ export declare function formatPostCloseReviewFailureMessage(item: WatchlistItem, errorMessage: string, compositeResult: CompositeAnalysisResult | null, marketSummary?: ReviewMarketSummary | null): string;
47
+ export {};
@@ -1,4 +1,6 @@
1
1
  import { formatChinaDateTime } from "../utils/china-time.js";
2
+ import { formatCostPrice } from "../utils/cost-price.js";
3
+ import { normalizeTickFlowChangePct, resolveTickFlowKlineChangePct } from "../utils/tickflow-quote.js";
2
4
  const LEVEL_BUFFER = 0.005;
3
5
  const INTRADAY_PERIOD = "1m";
4
6
  const MARKET_OVERVIEW_FLASH_KEYWORDS = [
@@ -17,7 +19,8 @@ export class PostCloseReviewService {
17
19
  intradayKlinesRepository;
18
20
  flashDeliveryRepository;
19
21
  flashRepository;
20
- constructor(watchlistService, compositeAnalysisOrchestrator, analysisService, postCloseReviewTask, keyLevelsRepository, keyLevelsHistoryRepository, klinesRepository, intradayKlinesRepository, flashDeliveryRepository, flashRepository) {
22
+ industryPeerService;
23
+ constructor(watchlistService, compositeAnalysisOrchestrator, analysisService, postCloseReviewTask, keyLevelsRepository, keyLevelsHistoryRepository, klinesRepository, intradayKlinesRepository, flashDeliveryRepository, flashRepository, industryPeerService) {
21
24
  this.watchlistService = watchlistService;
22
25
  this.compositeAnalysisOrchestrator = compositeAnalysisOrchestrator;
23
26
  this.analysisService = analysisService;
@@ -28,6 +31,7 @@ export class PostCloseReviewService {
28
31
  this.intradayKlinesRepository = intradayKlinesRepository;
29
32
  this.flashDeliveryRepository = flashDeliveryRepository;
30
33
  this.flashRepository = flashRepository;
34
+ this.industryPeerService = industryPeerService;
31
35
  }
32
36
  async run() {
33
37
  const watchlist = await this.watchlistService.list();
@@ -44,11 +48,35 @@ export class PostCloseReviewService {
44
48
  let marketOverview = null;
45
49
  for (const item of watchlist) {
46
50
  let compositeResult = null;
51
+ let marketSummary = null;
47
52
  try {
48
53
  const input = await this.compositeAnalysisOrchestrator.buildInput(item.symbol);
54
+ marketSummary = buildReviewMarketSummary(input.market.klines, input.market.realtimeQuote);
49
55
  marketOverview ??= input.market.marketOverview;
50
56
  const tradeDate = input.market.klines[input.market.klines.length - 1]?.trade_date ?? formatChinaDateTime().slice(0, 10);
51
57
  const validation = await this.buildValidationContext(item.symbol, tradeDate);
58
+ const peerContext = await this.industryPeerService.buildContext(item.symbol)
59
+ .catch((error) => ({
60
+ available: false,
61
+ summary: "未获取到申万三级同业表现。",
62
+ sw1Name: null,
63
+ sw2Name: null,
64
+ sw3Name: null,
65
+ sw3UniverseId: null,
66
+ peerCount: 0,
67
+ otherStockCount: 0,
68
+ advanceCount: 0,
69
+ declineCount: 0,
70
+ flatCount: 0,
71
+ averageChangePct: null,
72
+ medianChangePct: null,
73
+ targetChangePct: null,
74
+ targetRank: null,
75
+ targetPercentile: null,
76
+ leaders: [],
77
+ laggards: [],
78
+ note: error instanceof Error ? error.message : String(error),
79
+ }));
52
80
  compositeResult = await this.compositeAnalysisOrchestrator.analyzeInput(input);
53
81
  const flashContext = await this.buildFlashContext(item.symbol, tradeDate);
54
82
  const review = await this.analysisService.runTask(this.postCloseReviewTask, {
@@ -56,8 +84,9 @@ export class PostCloseReviewService {
56
84
  compositeResult,
57
85
  validation,
58
86
  flashContext,
87
+ peerContext,
59
88
  });
60
- const message = this.formatDetailMessage(item, validation, review);
89
+ const message = this.formatDetailMessage(item, validation, review, marketSummary, peerContext);
61
90
  await this.persistReview(item.symbol, message, review);
62
91
  entries.push({
63
92
  ok: true,
@@ -73,7 +102,7 @@ export class PostCloseReviewService {
73
102
  await this.persistFallbackCompositeReview(item.symbol, compositeResult);
74
103
  }
75
104
  entries.push({ ok: false, item, errorMessage: message });
76
- detailMessages.push(this.formatFailureMessage(item, message, compositeResult));
105
+ detailMessages.push(this.formatFailureMessage(item, message, compositeResult, marketSummary));
77
106
  }
78
107
  }
79
108
  const overviewMessage = this.formatOverviewMessage(marketOverview, entries);
@@ -207,17 +236,20 @@ export class PostCloseReviewService {
207
236
  ];
208
237
  return `**🧭 收盘复盘总览**\n\n${lines.join("\n")}`.trim();
209
238
  }
210
- formatDetailMessage(item, validation, review) {
211
- return formatPostCloseReviewDetailMessage(item, validation, review);
239
+ formatDetailMessage(item, validation, review, marketSummary, peerContext = null) {
240
+ return formatPostCloseReviewDetailMessage(item, validation, review, marketSummary, peerContext);
212
241
  }
213
- formatFailureMessage(item, errorMessage, compositeResult) {
214
- return formatPostCloseReviewFailureMessage(item, errorMessage, compositeResult);
242
+ formatFailureMessage(item, errorMessage, compositeResult, marketSummary) {
243
+ return formatPostCloseReviewFailureMessage(item, errorMessage, compositeResult, marketSummary);
215
244
  }
216
245
  }
217
- export function formatPostCloseReviewDetailMessage(item, validation, review) {
246
+ export function formatPostCloseReviewDetailMessage(item, validation, review, marketSummary = null, peerContext = null) {
247
+ const marketMeta = formatReviewMarketMeta(item, marketSummary);
248
+ const industryPosition = formatIndustryPosition(peerContext);
218
249
  const lines = [
219
250
  `**📘 收盘复盘|${item.name}(${item.symbol})**`,
220
251
  `${formatValidationVerdictBadge(validation.verdict)} 昨日验证:${formatValidationVerdictLabel(validation.verdict)} | ${formatDecisionBadge(review.decision)} 明日处理:${formatDecisionLabel(review.decision)}`,
252
+ ...(marketMeta ? [marketMeta] : []),
221
253
  "",
222
254
  formatSectionTitle("📍", "昨日关键位验证"),
223
255
  `• 结论:${validation.summary}`,
@@ -227,7 +259,11 @@ export function formatPostCloseReviewDetailMessage(item, validation, review) {
227
259
  review.sessionSummary || "未生成盘面一句话总结。",
228
260
  "",
229
261
  formatSectionTitle("🌐", "大盘与板块"),
230
- `• 风向:大盘 ${formatMarketBiasBadge(review.marketBias)}${formatMarketBiasLabel(review.marketBias)} | 板块 ${formatMarketBiasBadge(review.sectorBias)}${formatMarketBiasLabel(review.sectorBias)}`,
262
+ [
263
+ `• 风向:大盘 ${formatMarketBiasBadge(review.marketBias)}${formatMarketBiasLabel(review.marketBias)}`,
264
+ `板块 ${formatMarketBiasBadge(review.sectorBias)}${formatMarketBiasLabel(review.sectorBias)}`,
265
+ industryPosition ? `同业 ${industryPosition}` : null,
266
+ ].filter(Boolean).join(" | "),
231
267
  review.marketSectorSummary || "未生成大盘/板块总结。",
232
268
  "",
233
269
  formatSectionTitle("📰", "新闻与公告"),
@@ -255,12 +291,14 @@ export function formatPostCloseReviewDetailMessage(item, validation, review) {
255
291
  lines.push(`• 支撑 ${formatMaybePrice(review.levels.support)} | 压力 ${formatMaybePrice(review.levels.resistance)} | 突破 ${formatMaybePrice(review.levels.breakthrough)}`, `• 止损 ${formatMaybePrice(review.levels.stop_loss)} | 止盈 ${formatMaybePrice(review.levels.take_profit)} | 评分 ${review.levels.score}/10`, ...(levelRail ? [`• 价位框架:${levelRail}`] : []), "", formatSectionTitle("✅", "操作建议"), review.actionAdvice || "按关键位和次日量价配合再决定是否执行。");
256
292
  return lines.join("\n");
257
293
  }
258
- export function formatPostCloseReviewFailureMessage(item, errorMessage, compositeResult) {
294
+ export function formatPostCloseReviewFailureMessage(item, errorMessage, compositeResult, marketSummary = null) {
259
295
  const fallback = compositeResult?.levels
260
296
  ? "已保留综合分析生成的关键位,可稍后用 view_analysis 或 analyze 复核。"
261
297
  : "本轮未生成可用关键位。";
298
+ const marketMeta = formatReviewMarketMeta(item, marketSummary);
262
299
  return [
263
300
  `**⚠️ 收盘复盘|${item.name}(${item.symbol})**`,
301
+ ...(marketMeta ? ["", marketMeta] : []),
264
302
  "",
265
303
  formatSectionTitle("❌", "失败原因"),
266
304
  errorMessage,
@@ -289,6 +327,71 @@ function toHistoryEntry(symbol, analysisText, levels) {
289
327
  score: levels.score,
290
328
  };
291
329
  }
330
+ function buildReviewMarketSummary(klines, realtimeQuote) {
331
+ const latestKline = klines[klines.length - 1] ?? null;
332
+ if (!latestKline && !realtimeQuote) {
333
+ return null;
334
+ }
335
+ const latestClose = latestKline?.close ?? realtimeQuote?.last_price ?? null;
336
+ const dailyChangePct = normalizeTickFlowChangePct(realtimeQuote?.ext?.change_pct)
337
+ ?? resolveTickFlowKlineChangePct(latestKline);
338
+ return {
339
+ latestClose,
340
+ dailyChangePct,
341
+ };
342
+ }
343
+ function formatReviewMarketMeta(item, marketSummary) {
344
+ const parts = [];
345
+ if (marketSummary?.latestClose != null && Number.isFinite(marketSummary.latestClose)) {
346
+ parts.push(`• 收盘 ${marketSummary.latestClose.toFixed(2)}`);
347
+ }
348
+ if (marketSummary?.dailyChangePct != null && Number.isFinite(marketSummary.dailyChangePct)) {
349
+ parts.push(`当日 ${formatSignedPct(marketSummary.dailyChangePct)}`);
350
+ }
351
+ if (item.costPrice != null && Number.isFinite(item.costPrice) && item.costPrice > 0) {
352
+ parts.push(`成本 ${formatCostPrice(item.costPrice)}`);
353
+ }
354
+ return parts.length > 0 ? parts.join(" | ") : null;
355
+ }
356
+ function formatSignedPct(value) {
357
+ return `${value >= 0 ? "+" : ""}${value.toFixed(2)}%`;
358
+ }
359
+ function formatIndustryPosition(context) {
360
+ if (!context?.available || !context.targetRank || !(context.peerCount > 0)) {
361
+ return null;
362
+ }
363
+ return `${classifyIndustryPosition(context)}(${context.targetRank}/${context.peerCount})`;
364
+ }
365
+ function classifyIndustryPosition(context) {
366
+ const { targetRank, peerCount } = context;
367
+ if (!targetRank || !(peerCount > 0)) {
368
+ return "位置未知";
369
+ }
370
+ if (targetRank === 1) {
371
+ return "领涨";
372
+ }
373
+ if (targetRank === peerCount) {
374
+ return "领跌";
375
+ }
376
+ if (peerCount <= 3) {
377
+ return "中游";
378
+ }
379
+ const percentile = context.targetPercentile
380
+ ?? (peerCount > 1 ? 1 - ((targetRank - 1) / (peerCount - 1)) : 1);
381
+ if (percentile >= 0.8) {
382
+ return "领涨区";
383
+ }
384
+ if (percentile >= 0.6) {
385
+ return "偏强";
386
+ }
387
+ if (percentile > 0.4) {
388
+ return "中游";
389
+ }
390
+ if (percentile > 0.2) {
391
+ return "偏弱";
392
+ }
393
+ return "领跌区";
394
+ }
292
395
  function evaluateSupport(snapshot, row) {
293
396
  if (!(snapshot.support != null && snapshot.support > 0)) {
294
397
  return "支撑: 昨日未设置支撑位。";
@@ -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;
@@ -80,10 +81,7 @@ export class PreMarketBriefService {
80
81
  matchedWatchlistCount: 0,
81
82
  };
82
83
  }
83
- const matchContexts = flashes.map((flash) => ({
84
- flash,
85
- matchedItems: findMatchedItems(flash, watchlist),
86
- }));
84
+ const matchContexts = flashes.map((flash) => buildFlashMatchContext(flash, watchlist));
87
85
  const matchedWatchlistCount = new Set(matchContexts.flatMap((context) => context.matchedItems.map((item) => item.symbol))).size;
88
86
  const summary = await this.buildSummary(window, watchlist, matchContexts);
89
87
  return {
@@ -128,7 +126,9 @@ export class PreMarketBriefService {
128
126
  watchlist,
129
127
  flashes: matchContexts.map((context) => ({
130
128
  publishedAt: context.flash.published_at,
131
- headline: extractHeadlineFromContent(context.flash.content),
129
+ headline: context.headline,
130
+ summary: context.summary,
131
+ keyPoints: context.keyPoints,
132
132
  content: context.flash.content,
133
133
  url: context.flash.url,
134
134
  matchedSymbols: context.matchedItems.map((item) => item.symbol),
@@ -136,10 +136,13 @@ export class PreMarketBriefService {
136
136
  };
137
137
  if (this.analysisService.isConfigured()) {
138
138
  try {
139
- return await this.analysisService.generateText(PRE_MARKET_BRIEF_SYSTEM_PROMPT, buildPreMarketBriefUserPrompt(promptInput), {
139
+ const generated = await this.analysisService.generateText(PRE_MARKET_BRIEF_SYSTEM_PROMPT, buildPreMarketBriefUserPrompt(promptInput), {
140
140
  maxTokens: 1600,
141
141
  temperature: 0.2,
142
142
  });
143
+ if (!isLowSignalSummary(generated, matchContexts)) {
144
+ return generated;
145
+ }
143
146
  }
144
147
  catch {
145
148
  // Fall through to deterministic fallback so the scheduled push still lands.
@@ -183,7 +186,7 @@ function findMatchedItems(flash, watchlist) {
183
186
  const normalizedContent = normalizeText(flash.content);
184
187
  return watchlist.filter((item) => {
185
188
  const directKeywords = [item.symbol, item.symbol.slice(0, 6), item.name];
186
- const boardKeywords = [item.sector ?? "", ...item.themes]
189
+ const boardKeywords = [...extractSectorKeywords(item.sector), ...item.themes]
187
190
  .map((keyword) => keyword.replace(/\s+/g, "").trim())
188
191
  .filter((keyword) => keyword.length >= 2);
189
192
  return [...directKeywords, ...boardKeywords]
@@ -191,6 +194,16 @@ function findMatchedItems(flash, watchlist) {
191
194
  .some((keyword) => keyword && normalizedContent.includes(keyword));
192
195
  });
193
196
  }
197
+ function buildFlashMatchContext(flash, watchlist) {
198
+ const insight = extractFlashInsight(flash.content);
199
+ return {
200
+ flash,
201
+ matchedItems: findMatchedItems(flash, watchlist),
202
+ headline: insight.headline,
203
+ summary: insight.summary,
204
+ keyPoints: insight.keyPoints,
205
+ };
206
+ }
194
207
  function buildFallbackSummary(matchContexts) {
195
208
  const opportunityContexts = matchContexts.filter((context) => containsAnyKeyword(context.flash.content, OPPORTUNITY_KEYWORDS));
196
209
  const riskContexts = matchContexts.filter((context) => containsAnyKeyword(context.flash.content, RISK_KEYWORDS));
@@ -220,7 +233,7 @@ function formatFlashBullets(contexts, limit) {
220
233
  .slice(0, limit)
221
234
  .map((context) => {
222
235
  const time = context.flash.published_at.slice(11, 16);
223
- return `• [${time}] ${extractHeadlineFromContent(context.flash.content)}`;
236
+ return `• [${time}] ${formatContextSummary(context)}`;
224
237
  })
225
238
  .join("\n");
226
239
  }
@@ -231,7 +244,7 @@ function formatMatchedBullets(contexts, limit) {
231
244
  }
232
245
  return matched.map((context) => {
233
246
  const labels = context.matchedItems.map((item) => `${item.name}(${item.symbol})`).join("、");
234
- return `• ${labels}: ${extractHeadlineFromContent(context.flash.content)}`;
247
+ return `• ${labels}: ${formatContextSummary(context)}`;
235
248
  }).join("\n");
236
249
  }
237
250
  function buildFocusBullets(contexts) {
@@ -239,11 +252,11 @@ function buildFocusBullets(contexts) {
239
252
  const matchedContexts = contexts.filter((context) => context.matchedItems.length > 0);
240
253
  for (const context of matchedContexts.slice(0, 3)) {
241
254
  const labels = context.matchedItems.map((item) => item.name).join("、");
242
- bullets.push(`• 关注 ${labels} 开盘后的量价反馈,核实“${extractHeadlineFromContent(context.flash.content)}”是否继续发酵。`);
255
+ bullets.push(`• 关注 ${labels} 开盘后的量价反馈,重点核实“${formatFocusCue(context)}”是否继续发酵。`);
243
256
  }
244
257
  if (bullets.length < 3) {
245
258
  for (const context of contexts.slice(0, 3 - bullets.length)) {
246
- bullets.push(`• 关注“${extractHeadlineFromContent(context.flash.content)}”对应板块是否出现竞价强化或高开分歧。`);
259
+ bullets.push(`• 关注“${formatFocusCue(context)}”对应板块是否出现竞价强化或高开分歧。`);
247
260
  }
248
261
  }
249
262
  return bullets.slice(0, 5).join("\n");
@@ -264,6 +277,147 @@ function extractHeadlineFromContent(content) {
264
277
  function extractHeadlineText(content) {
265
278
  return content.split(/[\n。!!]/)[0]?.trim() ?? "";
266
279
  }
280
+ function extractFlashInsight(content) {
281
+ const headline = extractHeadlineFromContent(content);
282
+ const keyPoints = extractFlashKeyPoints(content, headline);
283
+ if (keyPoints.length === 0) {
284
+ return {
285
+ headline,
286
+ summary: buildTitleOnlySummary(headline),
287
+ keyPoints: [],
288
+ };
289
+ }
290
+ return {
291
+ headline,
292
+ summary: keyPoints.slice(0, 2).join(";"),
293
+ keyPoints,
294
+ };
295
+ }
296
+ function extractFlashKeyPoints(content, headline) {
297
+ const body = stripHeadline(content, headline);
298
+ if (!body) {
299
+ return [];
300
+ }
301
+ const lineCandidates = body
302
+ .replace(/\r/g, "\n")
303
+ .replace(/\n{2,}/g, "\n")
304
+ .replace(/([。;!?!?])\s*(?=\d+\s*[、..))])/g, "$1\n")
305
+ .split(/\n+/)
306
+ .map((segment) => cleanFlashSegment(segment))
307
+ .filter(Boolean);
308
+ const candidates = lineCandidates.length > 1
309
+ ? lineCandidates
310
+ : body
311
+ .split(/[。;!?!?]/)
312
+ .map((segment) => cleanFlashSegment(segment))
313
+ .filter(Boolean);
314
+ const normalizedHeadline = normalizeText(headline);
315
+ const deduped = new Set();
316
+ const keyPoints = [];
317
+ for (const segment of candidates) {
318
+ const normalizedSegment = normalizeText(segment);
319
+ if (!normalizedSegment || normalizedSegment === normalizedHeadline) {
320
+ continue;
321
+ }
322
+ if (normalizedHeadline && normalizedHeadline.includes(normalizedSegment) && segment.length < Math.max(12, headline.length)) {
323
+ continue;
324
+ }
325
+ if (segment.length < 8) {
326
+ continue;
327
+ }
328
+ if (deduped.has(normalizedSegment)) {
329
+ continue;
330
+ }
331
+ deduped.add(normalizedSegment);
332
+ keyPoints.push(truncateText(segment, 88));
333
+ if (keyPoints.length >= 3) {
334
+ break;
335
+ }
336
+ }
337
+ return keyPoints;
338
+ }
339
+ function stripHeadline(content, headline) {
340
+ const trimmed = content.trim();
341
+ if (!headline) {
342
+ return trimmed;
343
+ }
344
+ if (!trimmed.startsWith(headline)) {
345
+ return trimmed;
346
+ }
347
+ return trimmed.slice(headline.length).replace(/^[::。;,、\s-]+/, "").trim();
348
+ }
349
+ function cleanFlashSegment(segment) {
350
+ return segment
351
+ .trim()
352
+ .replace(/^[-•●▪◦]\s*/, "")
353
+ .replace(/^\d+\s*[、..))]\s*/, "")
354
+ .replace(/^[((]?\d+[))]\s*/, "")
355
+ .replace(/^[::;,。、\s]+/, "")
356
+ .replace(/[::;,。、\s]+$/, "")
357
+ .replace(/\s+/g, " ");
358
+ }
359
+ function buildTitleOnlySummary(headline) {
360
+ const coreHeadline = headline.replace(/^【?金十数据整理[::]\s*/, "").replace(/】$/, "").trim();
361
+ if (!coreHeadline) {
362
+ return "该整理快讯未提取到可用细节,暂只能作为标题级线索参考。";
363
+ }
364
+ return `${coreHeadline},但正文未提取到更具体细节,暂只能作为标题级线索参考。`;
365
+ }
366
+ function formatContextSummary(context) {
367
+ return context.summary || buildTitleOnlySummary(context.headline);
368
+ }
369
+ function formatFocusCue(context) {
370
+ return context.keyPoints[0] ?? context.summary ?? context.headline;
371
+ }
372
+ function truncateText(value, maxLength) {
373
+ return value.length <= maxLength ? value : `${value.slice(0, maxLength)}...`;
374
+ }
375
+ function isLowSignalSummary(summary, contexts) {
376
+ const bulletLines = summary
377
+ .split(/\n+/)
378
+ .map((line) => line.trim())
379
+ .filter((line) => /^(?:•|-|\d+\.)/.test(line));
380
+ if (bulletLines.length === 0) {
381
+ return true;
382
+ }
383
+ const titleOnlyCount = bulletLines.filter((line) => isTitleOnlyBullet(line, contexts)).length;
384
+ return titleOnlyCount >= Math.max(2, Math.ceil(bulletLines.length / 3));
385
+ }
386
+ function isTitleOnlyBullet(line, contexts) {
387
+ const candidates = normalizeBulletForComparison(line);
388
+ if (candidates.length === 0) {
389
+ return false;
390
+ }
391
+ return contexts.some((context) => {
392
+ const headlineForms = [
393
+ normalizeDigestText(context.headline),
394
+ normalizeDigestText(stripDigestPrefix(context.headline)),
395
+ ].filter(Boolean);
396
+ return headlineForms.some((headline) => candidates.some((candidate) => candidate.includes(headline) && candidate.length <= headline.length + 6));
397
+ });
398
+ }
399
+ function normalizeBulletForComparison(line) {
400
+ const cleaned = line
401
+ .replace(/^(?:•|-|\d+\.)\s*/, "")
402
+ .replace(/^\[[0-9:]+\]\s*/, "");
403
+ const normalizedVariants = [
404
+ cleaned,
405
+ cleaned.split(/[::]/).at(-1) ?? cleaned,
406
+ ]
407
+ .map((item) => normalizeDigestText(item))
408
+ .filter(Boolean);
409
+ return [...new Set(normalizedVariants)];
410
+ }
411
+ function stripDigestPrefix(value) {
412
+ return value.replace(/^【?金十数据整理[::]\s*/, "").replace(/】$/, "").trim();
413
+ }
414
+ function normalizeDigestText(value) {
415
+ return value
416
+ .toLowerCase()
417
+ .replace(/[【】[\]()()"'“”‘’]/g, "")
418
+ .replace(/[::]/g, "")
419
+ .replace(/\s+/g, "");
420
+ }
267
421
  function toFlashRecord(item) {
268
422
  const published = new Date(item.time);
269
423
  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;