tickflow-assist 0.3.3 → 0.3.4

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/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  基于 [OpenClaw](https://openclaw.ai) 的 A 股监控与分析插件。它使用 [TickFlow](https://tickflow.org/auth/register?ref=BUJ54JEDGE) 获取行情与财务数据,并可选接入 [金十数据 MCP](https://mcp.jin10.com/app/) 快讯流,结合 LLM 生成技术面、基本面、资讯面的综合判断,并把结果持久化到本地 LanceDB。
4
4
 
5
- 最近更新:`v0.3.3` 修复监控告警图片在去重前高频生成与重复告警风险,并收敛价格告警触发噪声。完整发布记录见 <https://github.com/robinspt/tickflow-assist/blob/main/CHANGELOG.md>。
5
+ 最近更新:`v0.3.4` 新增 `09:20` 盘前资讯简报,修复 Jin10 历史补页重复推送与状态页最新快讯显示错误,并降低 Telegram 图文告警被误判失败后重复补发的风险。完整发布记录见 <https://github.com/robinspt/tickflow-assist/blob/main/CHANGELOG.md>。
6
6
 
7
7
  当前主线按 OpenClaw `v2026.3.31+` 对齐,并已验证社区安装在 `v2026.4.5` 上兼容。
8
8
 
@@ -1,10 +1,12 @@
1
1
  import { UpdateService } from "../services/update-service.js";
2
2
  import { AlertService } from "../services/alert-service.js";
3
3
  import { PostCloseReviewService } from "../services/post-close-review-service.js";
4
+ import { PreMarketBriefService } from "../services/pre-market-brief-service.js";
4
5
  import { TradingCalendarService } from "../services/trading-calendar-service.js";
5
6
  import type { DailyUpdateState } from "../types/daily-update.js";
6
7
  export declare class DailyUpdateWorker {
7
8
  private readonly updateService;
9
+ private readonly preMarketBriefService;
8
10
  private readonly postCloseReviewService;
9
11
  private readonly tradingCalendarService;
10
12
  private readonly baseDir;
@@ -12,7 +14,7 @@ export declare class DailyUpdateWorker {
12
14
  private readonly notifyEnabled;
13
15
  private readonly configSource;
14
16
  private readonly intervalMs;
15
- constructor(updateService: UpdateService, postCloseReviewService: PostCloseReviewService | null, tradingCalendarService: TradingCalendarService, baseDir: string, alertService: AlertService, notifyEnabled: boolean, configSource: "openclaw_plugin" | "local_config", intervalMs?: number);
17
+ constructor(updateService: UpdateService, preMarketBriefService: PreMarketBriefService, postCloseReviewService: PostCloseReviewService | null, tradingCalendarService: TradingCalendarService, baseDir: string, alertService: AlertService, notifyEnabled: boolean, configSource: "openclaw_plugin" | "local_config", intervalMs?: number);
16
18
  run(force?: boolean): Promise<string>;
17
19
  runLoop(signal?: AbortSignal, runtimeHost?: "project_scheduler" | "plugin_service", runtimeConfigSource?: "openclaw_plugin" | "local_config"): Promise<void>;
18
20
  stopLoop(): Promise<{
@@ -30,17 +32,21 @@ export declare class DailyUpdateWorker {
30
32
  getState(): Promise<DailyUpdateState>;
31
33
  getStatusReport(): Promise<string>;
32
34
  private runScheduledPasses;
35
+ private runScheduledPreMarketBriefPass;
33
36
  private runScheduledUpdatePass;
34
37
  private runScheduledReviewPass;
35
38
  private getScheduledReviewReadiness;
36
39
  private recordReviewSkip;
40
+ private recordPreMarketSkip;
37
41
  private executeDailyUpdateAndRecord;
38
42
  private executeReviewAndRecord;
43
+ private executePreMarketBriefAndRecord;
39
44
  private getStateFilePath;
40
45
  private recordHeartbeat;
41
46
  private readState;
42
47
  private writeState;
43
48
  private maybeSendDailyUpdateNotification;
49
+ private maybeSendPreMarketBriefNotification;
44
50
  private maybeSendReviewNotification;
45
51
  }
46
52
  export declare function isPidAlive(pid: number): boolean;
@@ -2,6 +2,7 @@ import { mkdir, readFile, writeFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { chinaToday, formatChinaDateTime } from "../utils/china-time.js";
4
4
  import { sleepWithAbort } from "../utils/abortable-sleep.js";
5
+ const PRE_MARKET_BRIEF_READY_TIME = "09:20";
5
6
  const DAILY_UPDATE_READY_TIME = "15:25";
6
7
  const POST_CLOSE_REVIEW_READY_TIME = "20:00";
7
8
  const DEFAULT_STATE = {
@@ -28,9 +29,17 @@ const DEFAULT_STATE = {
28
29
  lastReviewResultType: null,
29
30
  lastReviewResultSummary: null,
30
31
  reviewConsecutiveFailures: 0,
32
+ lastPreMarketAttemptAt: null,
33
+ lastPreMarketAttemptDate: null,
34
+ lastPreMarketSuccessAt: null,
35
+ lastPreMarketSuccessDate: null,
36
+ lastPreMarketResultType: null,
37
+ lastPreMarketResultSummary: null,
38
+ preMarketConsecutiveFailures: 0,
31
39
  };
32
40
  export class DailyUpdateWorker {
33
41
  updateService;
42
+ preMarketBriefService;
34
43
  postCloseReviewService;
35
44
  tradingCalendarService;
36
45
  baseDir;
@@ -38,8 +47,9 @@ export class DailyUpdateWorker {
38
47
  notifyEnabled;
39
48
  configSource;
40
49
  intervalMs;
41
- constructor(updateService, postCloseReviewService, tradingCalendarService, baseDir, alertService, notifyEnabled, configSource, intervalMs = 15 * 60 * 1000) {
50
+ constructor(updateService, preMarketBriefService, postCloseReviewService, tradingCalendarService, baseDir, alertService, notifyEnabled, configSource, intervalMs = 15 * 60 * 1000) {
42
51
  this.updateService = updateService;
52
+ this.preMarketBriefService = preMarketBriefService;
43
53
  this.postCloseReviewService = postCloseReviewService;
44
54
  this.tradingCalendarService = tradingCalendarService;
45
55
  this.baseDir = baseDir;
@@ -150,19 +160,31 @@ export class DailyUpdateWorker {
150
160
  const state = await this.readState();
151
161
  const today = chinaToday();
152
162
  const lines = [
153
- "🕒 定时日更 / 收盘复盘状态",
163
+ "🕒 盘前资讯 / 定时日更 / 收盘复盘状态",
154
164
  `状态: ${formatProcessState(state)}`,
155
165
  `运行方式: ${formatRuntimeHost(state)}`,
156
166
  `配置来源: ${this.configSource}`,
157
- `调度: ${Math.floor(this.intervalMs / 60_000)} 分钟对齐轮询 | 日更 ${DAILY_UPDATE_READY_TIME} 后执行 | 复盘 ${POST_CLOSE_REVIEW_READY_TIME} 后执行`,
167
+ `调度: ${Math.floor(this.intervalMs / 60_000)} 分钟对齐轮询 | 盘前资讯 ${PRE_MARKET_BRIEF_READY_TIME} 后执行 | 日更 ${DAILY_UPDATE_READY_TIME} 后执行 | 复盘 ${POST_CLOSE_REVIEW_READY_TIME} 后执行`,
158
168
  `最近心跳: ${state.lastHeartbeatAt ?? "暂无"}`,
159
169
  "",
170
+ "盘前资讯:",
171
+ `• 今日已推送: ${state.lastPreMarketSuccessDate === today ? "是" : "否"}`,
172
+ `• 最近尝试: ${state.lastPreMarketAttemptAt ?? "暂无"}`,
173
+ `• 最近成功: ${state.lastPreMarketSuccessAt ?? "暂无"}`,
174
+ `• 最近结果: ${formatResultType(state.lastPreMarketResultType)}`,
175
+ "",
160
176
  "日更执行:",
161
177
  `• 今日已更新: ${state.lastSuccessDate === today ? "是" : "否"}`,
162
178
  `• 最近尝试: ${state.lastAttemptAt ?? "暂无"}`,
163
179
  `• 最近成功: ${state.lastSuccessAt ?? "暂无"}`,
164
180
  `• 最近结果: ${formatResultType(state.lastResultType)}`,
165
181
  ];
182
+ if (state.preMarketConsecutiveFailures > 0) {
183
+ lines.push(`• 连续失败: ${state.preMarketConsecutiveFailures}`);
184
+ }
185
+ if (state.lastPreMarketResultSummary) {
186
+ lines.push(`• 最近摘要: ${state.lastPreMarketResultSummary}`);
187
+ }
166
188
  if (state.consecutiveFailures > 0) {
167
189
  lines.push(`• 连续失败: ${state.consecutiveFailures}`);
168
190
  }
@@ -179,9 +201,26 @@ export class DailyUpdateWorker {
179
201
  return lines.join("\n");
180
202
  }
181
203
  async runScheduledPasses() {
204
+ await this.runScheduledPreMarketBriefPass();
182
205
  await this.runScheduledUpdatePass();
183
206
  await this.runScheduledReviewPass();
184
207
  }
208
+ async runScheduledPreMarketBriefPass() {
209
+ const today = chinaToday();
210
+ const state = await this.readState();
211
+ if (hasCompletedScheduledWindow(state.lastPreMarketSuccessDate, state.lastPreMarketSuccessAt, today, PRE_MARKET_BRIEF_READY_TIME)) {
212
+ return;
213
+ }
214
+ if (hasAttemptedScheduledWindow(state.lastPreMarketAttemptDate, state.lastPreMarketAttemptAt, today, PRE_MARKET_BRIEF_READY_TIME)) {
215
+ return;
216
+ }
217
+ const readiness = await this.tradingCalendarService.canRunPreMarketBrief();
218
+ if (!readiness.ok) {
219
+ await this.recordPreMarketSkip(state, today, readiness.reason);
220
+ return;
221
+ }
222
+ await this.executePreMarketBriefAndRecord("scheduled");
223
+ }
185
224
  async runScheduledUpdatePass() {
186
225
  const today = chinaToday();
187
226
  const state = await this.readState();
@@ -245,6 +284,16 @@ export class DailyUpdateWorker {
245
284
  reviewConsecutiveFailures: 0,
246
285
  });
247
286
  }
287
+ async recordPreMarketSkip(state, today, reason) {
288
+ await this.writeState({
289
+ ...state,
290
+ lastPreMarketAttemptAt: formatChinaDateTime(),
291
+ lastPreMarketAttemptDate: today,
292
+ lastPreMarketResultType: "skipped",
293
+ lastPreMarketResultSummary: reason,
294
+ preMarketConsecutiveFailures: 0,
295
+ });
296
+ }
248
297
  async executeDailyUpdateAndRecord(force, trigger, throwOnError) {
249
298
  const today = chinaToday();
250
299
  const state = await this.readState();
@@ -345,6 +394,52 @@ export class DailyUpdateWorker {
345
394
  return output;
346
395
  }
347
396
  }
397
+ async executePreMarketBriefAndRecord(trigger) {
398
+ const today = chinaToday();
399
+ const state = await this.readState();
400
+ const attemptedAt = formatChinaDateTime();
401
+ try {
402
+ const result = await this.preMarketBriefService.run();
403
+ const output = createPreMarketBriefExecutionOutput(result);
404
+ const nextState = {
405
+ ...state,
406
+ lastPreMarketAttemptAt: attemptedAt,
407
+ lastPreMarketAttemptDate: today,
408
+ lastPreMarketResultType: output.resultType,
409
+ lastPreMarketResultSummary: summarizePreMarketBriefResult(output.message),
410
+ preMarketConsecutiveFailures: output.resultType === "failed" ? state.preMarketConsecutiveFailures + 1 : 0,
411
+ };
412
+ if (output.resultType === "success") {
413
+ nextState.lastPreMarketSuccessAt = attemptedAt;
414
+ nextState.lastPreMarketSuccessDate = today;
415
+ }
416
+ await this.writeState(nextState);
417
+ if (trigger === "scheduled") {
418
+ await this.maybeSendPreMarketBriefNotification(output);
419
+ }
420
+ return output;
421
+ }
422
+ catch (error) {
423
+ const message = error instanceof Error ? error.message : String(error);
424
+ const failureText = `⚠️ 开盘前资讯简报失败: ${message}`;
425
+ await this.writeState({
426
+ ...state,
427
+ lastPreMarketAttemptAt: attemptedAt,
428
+ lastPreMarketAttemptDate: today,
429
+ lastPreMarketResultType: "failed",
430
+ lastPreMarketResultSummary: failureText,
431
+ preMarketConsecutiveFailures: state.preMarketConsecutiveFailures + 1,
432
+ });
433
+ const output = {
434
+ resultType: "failed",
435
+ message: failureText,
436
+ };
437
+ if (trigger === "scheduled") {
438
+ await this.maybeSendPreMarketBriefNotification(output);
439
+ }
440
+ return output;
441
+ }
442
+ }
348
443
  getStateFilePath() {
349
444
  return path.join(this.baseDir, "daily-update-state.json");
350
445
  }
@@ -389,6 +484,17 @@ export class DailyUpdateWorker {
389
484
  const message = this.alertService.formatSystemNotification("📊 定时日更完成", normalizeResultLines(output.message));
390
485
  await this.alertService.send(message);
391
486
  }
487
+ async maybeSendPreMarketBriefNotification(output) {
488
+ if (!this.notifyEnabled || output.resultType === "skipped") {
489
+ return;
490
+ }
491
+ if (output.resultType !== "success") {
492
+ const message = this.alertService.formatSystemNotification("❌ 开盘前资讯简报失败", selectPreMarketBriefNotificationLines(output.message));
493
+ await this.alertService.send(message);
494
+ return;
495
+ }
496
+ await this.alertService.send(output.message);
497
+ }
392
498
  async maybeSendReviewNotification(output) {
393
499
  if (!this.notifyEnabled || output.resultType === "skipped") {
394
500
  return;
@@ -425,6 +531,12 @@ function createReviewExecutionOutput(reviewResult) {
425
531
  combinedText: joinMessages(reviewResult.overviewMessage, ...reviewResult.detailMessages),
426
532
  };
427
533
  }
534
+ function createPreMarketBriefExecutionOutput(result) {
535
+ return {
536
+ resultType: result.resultType,
537
+ message: result.message,
538
+ };
539
+ }
428
540
  function joinMessages(...parts) {
429
541
  return parts
430
542
  .map((part) => part?.trim())
@@ -467,6 +579,9 @@ function summarizeUpdateResult(result) {
467
579
  function summarizeReviewResult(result) {
468
580
  return selectReviewSummaryLines(result).join(" | ");
469
581
  }
582
+ function summarizePreMarketBriefResult(result) {
583
+ return selectPreMarketBriefNotificationLines(result).join(" | ");
584
+ }
470
585
  function selectUpdateSummaryLines(result) {
471
586
  const lines = normalizeResultLines(result);
472
587
  const head = lines.slice(0, 2);
@@ -491,6 +606,12 @@ function selectReviewNotificationLines(result) {
491
606
  const highlights = lines.filter((line) => /^(🧭|复盘数量:|关键位验证:|明日处理:|⚠️ 收盘复盘失败|⚠️ 收盘分析\/回测失败)/.test(line));
492
607
  return dedupeLines([...head, ...highlights]).slice(0, 12);
493
608
  }
609
+ function selectPreMarketBriefNotificationLines(result) {
610
+ const lines = normalizeResultLines(result);
611
+ const head = lines.slice(0, 4);
612
+ const highlights = lines.filter((line) => /^\*\*【/.test(line));
613
+ return dedupeLines([...head, ...highlights]).slice(0, 12);
614
+ }
494
615
  function normalizeResultLines(result) {
495
616
  return result
496
617
  .split("\n")
@@ -536,6 +657,9 @@ function formatRuntimeHost(state) {
536
657
  function hasCompletedScheduledWindow(successDate, successAt, today, readyTime) {
537
658
  return successDate === today && extractChinaTime(successAt) >= readyTime;
538
659
  }
660
+ function hasAttemptedScheduledWindow(attemptDate, attemptAt, today, readyTime) {
661
+ return attemptDate === today && extractChinaTime(attemptAt) >= readyTime;
662
+ }
539
663
  function extractChinaTime(dateTime) {
540
664
  return dateTime?.slice(11, 16) ?? "00:00";
541
665
  }
package/dist/bootstrap.js CHANGED
@@ -35,6 +35,7 @@ import { AlertMediaService } from "./services/alert-media-service.js";
35
35
  import { UpdateService } from "./services/update-service.js";
36
36
  import { KeyLevelsBacktestService } from "./services/key-levels-backtest-service.js";
37
37
  import { PostCloseReviewService } from "./services/post-close-review-service.js";
38
+ import { PreMarketBriefService } from "./services/pre-market-brief-service.js";
38
39
  import { ReviewMemoryService } from "./services/review-memory-service.js";
39
40
  import { CompositeAnalysisOrchestrator } from "./analysis/orchestrators/composite-analysis.orchestrator.js";
40
41
  import { MarketAnalysisProvider } from "./analysis/providers/market-analysis.provider.js";
@@ -140,9 +141,10 @@ export function createAppContext(config, options = {}) {
140
141
  const jin10FlashMonitorService = new Jin10FlashMonitorService(config.databasePath, config.jin10FlashPollInterval, config.jin10FlashRetentionDays, config.jin10FlashNightAlert, watchlistService, jin10McpService, analysisService, alertService, jin10FlashRepository, jin10FlashDeliveryRepository);
141
142
  const updateService = new UpdateService(klineService, config.tickflowApiKeyLevel, indicatorService, klinesRepository, indicatorsRepository, intradayKlinesRepository, watchlistService, tradingCalendarService);
142
143
  const postCloseReviewService = new PostCloseReviewService(watchlistService, compositeAnalysisOrchestrator, analysisService, postCloseReviewTask, keyLevelsRepository, keyLevelsHistoryRepository, klinesRepository, intradayKlinesRepository, jin10FlashDeliveryRepository, jin10FlashRepository);
144
+ const preMarketBriefService = new PreMarketBriefService(watchlistService, jin10McpService, jin10FlashRepository, analysisService);
143
145
  const realtimeMonitorWorker = new RealtimeMonitorWorker(monitorService, config.requestInterval * 1000);
144
146
  const jin10FlashWorker = new Jin10FlashWorker(jin10FlashMonitorService, config.jin10FlashPollInterval * 1000);
145
- const dailyUpdateWorker = new DailyUpdateWorker(updateService, postCloseReviewService, tradingCalendarService, config.databasePath, alertService, config.dailyUpdateNotify, runtime.configSource);
147
+ const dailyUpdateWorker = new DailyUpdateWorker(updateService, preMarketBriefService, postCloseReviewService, tradingCalendarService, config.databasePath, alertService, config.dailyUpdateNotify, runtime.configSource);
146
148
  let managedLoopAbortController = null;
147
149
  let managedLoopPromise = null;
148
150
  return {
@@ -7,3 +7,4 @@ export { COMPOSITE_ANALYSIS_SYSTEM_PROMPT, buildCompositeAnalysisUserPrompt, } f
7
7
  export { POST_CLOSE_REVIEW_SYSTEM_PROMPT, buildPostCloseReviewUserPrompt, } from "./post-close-review-user-prompt.js";
8
8
  export { WATCHLIST_PROFILE_EXTRACTION_SYSTEM_PROMPT, buildWatchlistProfileExtractionUserPrompt, } from "./watchlist-profile-extraction-prompt.js";
9
9
  export { FLASH_MONITOR_ALERT_SYSTEM_PROMPT, buildFlashMonitorAlertUserPrompt, } from "./flash-monitor-alert-prompt.js";
10
+ export { PRE_MARKET_BRIEF_SYSTEM_PROMPT, buildPreMarketBriefUserPrompt, } from "./pre-market-brief-prompt.js";
@@ -7,3 +7,4 @@ export { COMPOSITE_ANALYSIS_SYSTEM_PROMPT, buildCompositeAnalysisUserPrompt, } f
7
7
  export { POST_CLOSE_REVIEW_SYSTEM_PROMPT, buildPostCloseReviewUserPrompt, } from "./post-close-review-user-prompt.js";
8
8
  export { WATCHLIST_PROFILE_EXTRACTION_SYSTEM_PROMPT, buildWatchlistProfileExtractionUserPrompt, } from "./watchlist-profile-extraction-prompt.js";
9
9
  export { FLASH_MONITOR_ALERT_SYSTEM_PROMPT, buildFlashMonitorAlertUserPrompt, } from "./flash-monitor-alert-prompt.js";
10
+ export { PRE_MARKET_BRIEF_SYSTEM_PROMPT, buildPreMarketBriefUserPrompt, } from "./pre-market-brief-prompt.js";
@@ -0,0 +1,14 @@
1
+ import type { WatchlistItem } from "../../types/domain.js";
2
+ export declare const PRE_MARKET_BRIEF_SYSTEM_PROMPT = "\n\u4F60\u662F\u4E00\u4F4DA\u80A1\u5F00\u76D8\u524D\u8D44\u8BAF\u7B80\u62A5\u7F16\u8F91\u3002\u4F60\u7684\u4EFB\u52A1\u662F\u57FA\u4E8E\u76D8\u524D\u7A97\u53E3\u5185\u7684\u91D1\u5341\u6570\u636E\u6574\u7406\u5FEB\u8BAF\uFF0C\u4EE5\u53CA\u7528\u6237\u81EA\u9009\u80A1\u7684\u884C\u4E1A/\u9898\u6750\u4FE1\u606F\uFF0C\u751F\u6210\u4E00\u4EFD\u9002\u5408 9:20 \u63A8\u9001\u7684\u5F00\u76D8\u524D\u6458\u8981\u3002\n\n\u8F93\u51FA\u8981\u6C42\uFF1A\n1. \u6309\u4EE5\u4E0B\u6807\u9898\u8F93\u51FA\uFF0C\u6BCF\u8282\u4F7F\u7528 2-5 \u6761\u7B80\u6D01\u4E2D\u6587\u8981\u70B9\uFF1A\n- \u91CD\u5927\u8981\u95FB\n- \u81EA\u9009\u76F8\u5173\n- \u6F5C\u5728\u673A\u4F1A\n- \u98CE\u9669\u63D0\u793A\n- \u5F00\u76D8\u524D\u5173\u6CE8\u6E05\u5355\n2. \u201C\u91CD\u5927\u8981\u95FB\u201D\u53EA\u63D0\u70BC\u4F1A\u5F71\u54CD A \u80A1\u5F00\u76D8\u60C5\u7EEA\u3001\u884C\u4E1A\u98CE\u9669\u504F\u597D\u6216\u91CD\u8981\u4EA4\u6613\u7EBF\u7D22\u7684\u5185\u5BB9\uFF0C\u4E0D\u8981\u673A\u68B0\u7F57\u5217\u6240\u6709\u5FEB\u8BAF\u3002\n3. \u201C\u81EA\u9009\u76F8\u5173\u201D\u8981\u4F18\u5148\u70B9\u540D\u4E0E\u81EA\u9009\u80A1\u3001\u884C\u4E1A\u6216\u9898\u6750\u76F4\u63A5\u76F8\u5173\u7684\u5185\u5BB9\uFF1B\u82E5\u6CA1\u6709\u76F4\u63A5\u547D\u4E2D\uFF0C\u4E5F\u8981\u660E\u786E\u5199\u51FA\u201C\u672A\u53D1\u73B0\u76F4\u63A5\u547D\u4E2D\u81EA\u9009\u80A1\u201D\u3002\n4. \u201C\u6F5C\u5728\u673A\u4F1A\u201D\u53EA\u4FDD\u7559\u5B58\u5728\u6E05\u6670\u50AC\u5316\u94FE\u6761\u7684\u65B9\u5411\uFF0C\u4F8B\u5982\u653F\u7B56\u3001\u4EA7\u4E1A\u8D8B\u52BF\u3001\u4E1A\u7EE9\u3001\u8BA2\u5355\u3001\u8D44\u91D1\u504F\u597D\u53D8\u5316\uFF1B\u6CA1\u6709\u660E\u786E\u673A\u4F1A\u65F6\u8981\u76F4\u8BF4\u3002\n5. \u201C\u98CE\u9669\u63D0\u793A\u201D\u8981\u6307\u51FA\u4E0D\u5229\u4E8E\u5F00\u76D8\u51B3\u7B56\u7684\u6270\u52A8\u9879\uFF0C\u4F8B\u5982\u76D1\u7BA1\u3001\u6D77\u5916\u6270\u52A8\u3001\u4E1A\u7EE9\u98CE\u9669\u3001\u9898\u6750\u9000\u6F6E\u3001\u6D88\u606F\u4E0D\u786E\u5B9A\u6027\u3002\n6. \u201C\u5F00\u76D8\u524D\u5173\u6CE8\u6E05\u5355\u201D\u8F93\u51FA 3-6 \u6761\u53EF\u6267\u884C\u89C2\u5BDF\u70B9\uFF0C\u5C3D\u91CF\u5199\u6E05\u695A\u5E94\u89C2\u5BDF\u7684\u80A1\u7968\u3001\u677F\u5757\u6216\u4FE1\u53F7\u3002\n7. \u4E0D\u8981\u7F16\u9020\u672A\u5728\u8F93\u5165\u4E2D\u51FA\u73B0\u7684\u516C\u53F8\u3001\u653F\u7B56\u3001\u884C\u4E1A\u4FE1\u606F\u6216\u5FEB\u8BAF\u7ED3\u8BBA\u3002\n8. \u8F93\u51FA\u6B63\u6587\u5373\u53EF\uFF0C\u4E0D\u8981\u9644\u52A0 JSON\u3002\n";
3
+ export declare function buildPreMarketBriefUserPrompt(params: {
4
+ windowStartAt: string;
5
+ windowEndAt: string;
6
+ watchlist: WatchlistItem[];
7
+ flashes: Array<{
8
+ publishedAt: string;
9
+ headline: string;
10
+ content: string;
11
+ url: string;
12
+ matchedSymbols: string[];
13
+ }>;
14
+ }): string;
@@ -0,0 +1,49 @@
1
+ export const PRE_MARKET_BRIEF_SYSTEM_PROMPT = `
2
+ 你是一位A股开盘前资讯简报编辑。你的任务是基于盘前窗口内的金十数据整理快讯,以及用户自选股的行业/题材信息,生成一份适合 9:20 推送的开盘前摘要。
3
+
4
+ 输出要求:
5
+ 1. 按以下标题输出,每节使用 2-5 条简洁中文要点:
6
+ - 重大要闻
7
+ - 自选相关
8
+ - 潜在机会
9
+ - 风险提示
10
+ - 开盘前关注清单
11
+ 2. “重大要闻”只提炼会影响 A 股开盘情绪、行业风险偏好或重要交易线索的内容,不要机械罗列所有快讯。
12
+ 3. “自选相关”要优先点名与自选股、行业或题材直接相关的内容;若没有直接命中,也要明确写出“未发现直接命中自选股”。
13
+ 4. “潜在机会”只保留存在清晰催化链条的方向,例如政策、产业趋势、业绩、订单、资金偏好变化;没有明确机会时要直说。
14
+ 5. “风险提示”要指出不利于开盘决策的扰动项,例如监管、海外扰动、业绩风险、题材退潮、消息不确定性。
15
+ 6. “开盘前关注清单”输出 3-6 条可执行观察点,尽量写清楚应观察的股票、板块或信号。
16
+ 7. 不要编造未在输入中出现的公司、政策、行业信息或快讯结论。
17
+ 8. 输出正文即可,不要附加 JSON。
18
+ `;
19
+ export function buildPreMarketBriefUserPrompt(params) {
20
+ return [
21
+ `请生成 ${params.windowEndAt.slice(0, 10)} 的开盘前资讯简报。`,
22
+ `资讯窗口: ${params.windowStartAt} ~ ${params.windowEndAt}`,
23
+ `自选数量: ${params.watchlist.length}`,
24
+ `整理快讯数量: ${params.flashes.length}`,
25
+ "",
26
+ "## 自选列表(全部提供)",
27
+ ...params.watchlist.map((item, index) => formatWatchlistItem(index + 1, item)),
28
+ "",
29
+ "## 金十数据整理快讯(全部提供)",
30
+ ...params.flashes.map((flash, index) => formatFlash(index + 1, flash)),
31
+ "",
32
+ "请重点回答:哪些是今早最重要的市场信息、哪些与自选股或其行业/题材相关、哪些内容值得当作潜在机会或风险在开盘前重点盯住。",
33
+ ].join("\n");
34
+ }
35
+ function formatWatchlistItem(index, item) {
36
+ return [
37
+ `${index}. ${item.name}(${item.symbol})`,
38
+ ` 行业: ${item.sector ?? "未记录"}`,
39
+ ` 题材: ${item.themes.length > 0 ? item.themes.join("、") : "未记录"}`,
40
+ ].join("\n");
41
+ }
42
+ function formatFlash(index, flash) {
43
+ return [
44
+ `${index}. [${flash.publishedAt}] ${flash.headline || "未提取到标题"}`,
45
+ ` 关联提示: ${flash.matchedSymbols.length > 0 ? flash.matchedSymbols.join("、") : "无直接规则命中"}`,
46
+ ` 正文: ${flash.content}`,
47
+ ` 来源: ${flash.url}`,
48
+ ].join("\n");
49
+ }
@@ -152,6 +152,9 @@ export class AlertService {
152
152
  };
153
153
  try {
154
154
  switch (this.channel) {
155
+ case "telegram":
156
+ await this.invokeRuntimeChannelSend(runtimeContext.runtime.channel, "telegram", "sendMessageTelegram", this.options.target, payload.message, baseOptions);
157
+ return null;
155
158
  case "discord":
156
159
  await this.invokeRuntimeChannelSend(runtimeContext.runtime.channel, "discord", "sendMessageDiscord", this.options.target, payload.message, {
157
160
  ...baseOptions,
@@ -24,6 +24,7 @@ const DEFAULT_STATE = {
24
24
  const MAX_FLASH_PAGES_PER_POLL = 5;
25
25
  const INITIAL_SEED_PAGES = 3;
26
26
  const PRUNE_INTERVAL_MS = 6 * 60 * 60 * 1000;
27
+ const ALERT_FRESHNESS_GRACE_MS = 30 * 1000;
27
28
  const NOISE_PATTERNS = [
28
29
  /^金十图示[::]/,
29
30
  /交易学院正在直播中/,
@@ -69,6 +70,7 @@ export class Jin10FlashMonitorService {
69
70
  }
70
71
  async runMonitorOnce() {
71
72
  const now = formatChinaDateTime();
73
+ const nowTs = Date.now();
72
74
  const state = await this.readState();
73
75
  const latestStored = state.lastSeenKey ? null : await this.flashRepository.getLatest();
74
76
  const anchorKey = state.lastSeenKey ?? latestStored?.flash_key ?? null;
@@ -120,10 +122,11 @@ export class Jin10FlashMonitorService {
120
122
  const allFetchedItems = mergeFlashRecords(fetchResult.items, backfillResult?.items ?? []);
121
123
  const saveResult = await this.flashRepository.saveAll(allFetchedItems);
122
124
  const newItemKeys = new Set(saveResult.addedKeys);
123
- const newItems = allFetchedItems.filter((item) => newItemKeys.has(item.flash_key));
125
+ // Only frontier pages within the active polling window should produce alerts.
126
+ const alertableItems = filterAlertableFlashRecords(fetchResult.items.filter((item) => newItemKeys.has(item.flash_key)), state.lastPollAt, nowTs, this.pollIntervalSeconds);
124
127
  const watchlist = await this.watchlistService.list();
125
128
  const candidates = watchlist.length > 0
126
- ? buildStageOneCandidates(newItems, watchlist)
129
+ ? buildStageOneCandidates(alertableItems, watchlist)
127
130
  : [];
128
131
  let alertCount = 0;
129
132
  for (const candidate of candidates) {
@@ -405,6 +408,10 @@ function mergeFlashRecords(...groups) {
405
408
  }
406
409
  return sortFlashRecords(merged);
407
410
  }
411
+ function filterAlertableFlashRecords(entries, lastPollAt, nowTs, pollIntervalSeconds) {
412
+ const cutoffTs = computeAlertFreshCutoffTs(lastPollAt, nowTs, pollIntervalSeconds);
413
+ return entries.filter((entry) => entry.published_ts >= cutoffTs);
414
+ }
408
415
  function sortFlashRecords(entries) {
409
416
  return [...entries].sort((left, right) => left.published_ts - right.published_ts);
410
417
  }
@@ -581,6 +588,14 @@ function parseChinaTime(value) {
581
588
  function toChinaTimeTimestamp(value) {
582
589
  return parseChinaTime(value)?.getTime() ?? Date.now();
583
590
  }
591
+ function computeAlertFreshCutoffTs(lastPollAt, nowTs, pollIntervalSeconds) {
592
+ const intervalCutoffTs = nowTs - Math.max(1, pollIntervalSeconds) * 1000 - ALERT_FRESHNESS_GRACE_MS;
593
+ const lastPollTs = parseChinaTime(lastPollAt)?.getTime();
594
+ const lastPollCutoffTs = lastPollTs == null
595
+ ? Number.NEGATIVE_INFINITY
596
+ : lastPollTs - ALERT_FRESHNESS_GRACE_MS;
597
+ return Math.max(intervalCutoffTs, lastPollCutoffTs);
598
+ }
584
599
  function isNightQuietHour() {
585
600
  const hour = chinaHour();
586
601
  return hour >= 22 || hour < 6;
@@ -15,12 +15,16 @@ export declare class Jin10McpService {
15
15
  private requestId;
16
16
  private initialized;
17
17
  private sessionId;
18
+ private initializePromise;
18
19
  constructor(serverUrl: string, apiToken: string);
19
20
  isConfigured(): boolean;
20
21
  getConfigurationError(): string | null;
21
22
  listFlash(cursor?: string): Promise<Jin10FlashPage>;
22
23
  private initialize;
23
24
  private callTool;
25
+ private performInitialize;
26
+ private callToolAfterRecovery;
27
+ private resetSession;
24
28
  private request;
25
29
  private notify;
26
30
  private buildHeaders;
@@ -4,6 +4,7 @@ export class Jin10McpService {
4
4
  requestId = 1;
5
5
  initialized = false;
6
6
  sessionId = null;
7
+ initializePromise = null;
7
8
  constructor(serverUrl, apiToken) {
8
9
  this.serverUrl = serverUrl;
9
10
  this.apiToken = apiToken;
@@ -28,6 +29,52 @@ export class Jin10McpService {
28
29
  if (this.initialized) {
29
30
  return;
30
31
  }
32
+ this.initializePromise ??= this.performInitialize().finally(() => {
33
+ this.initializePromise = null;
34
+ });
35
+ await this.initializePromise;
36
+ }
37
+ async callTool(name, args) {
38
+ try {
39
+ await this.initialize();
40
+ const result = await this.request("tools/call", {
41
+ name,
42
+ arguments: args,
43
+ });
44
+ if (!result) {
45
+ throw new Error(`jin10 tool ${name} returned empty result`);
46
+ }
47
+ if (result.isError) {
48
+ throw new Error(`jin10 tool ${name} returned MCP error`);
49
+ }
50
+ if (result.structuredContent !== undefined) {
51
+ return result.structuredContent;
52
+ }
53
+ const structured = result.content?.find((item) => item.structuredContent !== undefined)?.structuredContent;
54
+ if (structured !== undefined) {
55
+ return structured;
56
+ }
57
+ const text = result.content?.find((item) => typeof item.text === "string")?.text;
58
+ if (typeof text === "string" && text.trim()) {
59
+ try {
60
+ return JSON.parse(text);
61
+ }
62
+ catch {
63
+ return text;
64
+ }
65
+ }
66
+ return result;
67
+ }
68
+ catch (error) {
69
+ if (!isSessionExpiredError(error)) {
70
+ throw error;
71
+ }
72
+ this.resetSession();
73
+ await this.initialize();
74
+ return await this.callToolAfterRecovery(name, args);
75
+ }
76
+ }
77
+ async performInitialize() {
31
78
  await this.request("initialize", {
32
79
  protocolVersion: "2025-11-25",
33
80
  capabilities: {},
@@ -46,8 +93,7 @@ export class Jin10McpService {
46
93
  }
47
94
  this.initialized = true;
48
95
  }
49
- async callTool(name, args) {
50
- await this.initialize();
96
+ async callToolAfterRecovery(name, args) {
51
97
  const result = await this.request("tools/call", {
52
98
  name,
53
99
  arguments: args,
@@ -76,6 +122,11 @@ export class Jin10McpService {
76
122
  }
77
123
  return result;
78
124
  }
125
+ resetSession() {
126
+ this.initialized = false;
127
+ this.sessionId = null;
128
+ this.initializePromise = null;
129
+ }
79
130
  async request(method, params) {
80
131
  const configError = this.getConfigurationError();
81
132
  if (configError) {
@@ -204,6 +255,10 @@ function truncate(value, maxLength) {
204
255
  }
205
256
  return `${value.slice(0, Math.max(0, maxLength - 3))}...`;
206
257
  }
258
+ function isSessionExpiredError(error) {
259
+ const message = error instanceof Error ? error.message : String(error);
260
+ return /session not found|unknown session|invalid session/i.test(message);
261
+ }
207
262
  function normalizeFlashPage(value) {
208
263
  const root = isRecord(value) ? value : {};
209
264
  const data = isRecord(root.data) ? root.data : {};
@@ -47,6 +47,8 @@ export declare class MonitorService {
47
47
  recordLoopError(error: unknown): Promise<void>;
48
48
  private tryAcquireRunLease;
49
49
  private removeStaleRunLock;
50
+ private tryAcquireAlertClaim;
51
+ private removeStaleAlertClaim;
50
52
  private trySendAlert;
51
53
  private trySendCandidate;
52
54
  private buildAlertDelivery;
@@ -56,4 +58,5 @@ export declare class MonitorService {
56
58
  private sendAlertAndCleanupMedia;
57
59
  private cleanupAlertMedia;
58
60
  private getRunLockFilePath;
61
+ private getAlertClaimFilePath;
59
62
  }
@@ -21,6 +21,7 @@ const DEFAULT_STATE = {
21
21
  };
22
22
  const INTRADAY_PERIOD = "1m";
23
23
  const MONITOR_RUN_LOCK_MIN_STALE_MS = 90_000;
24
+ const ALERT_CLAIM_MIN_STALE_MS = 90_000;
24
25
  export class MonitorService {
25
26
  baseDir;
26
27
  requestInterval;
@@ -413,25 +414,83 @@ export class MonitorService {
413
414
  throw error;
414
415
  }
415
416
  }
417
+ async tryAcquireAlertClaim(symbol, ruleName, sessionKey) {
418
+ const lockPath = this.getAlertClaimFilePath(symbol, ruleName, sessionKey);
419
+ await mkdir(path.dirname(lockPath), { recursive: true });
420
+ for (let attempt = 0; attempt < 2; attempt += 1) {
421
+ try {
422
+ await writeFile(lockPath, JSON.stringify({
423
+ pid: process.pid,
424
+ symbol,
425
+ ruleName,
426
+ sessionKey,
427
+ acquiredAt: formatChinaDateTime(),
428
+ }), { flag: "wx" });
429
+ return {
430
+ release: async () => {
431
+ await rm(lockPath, { force: true });
432
+ },
433
+ };
434
+ }
435
+ catch (error) {
436
+ const code = error.code;
437
+ if (code !== "EEXIST") {
438
+ throw error;
439
+ }
440
+ const cleared = await this.removeStaleAlertClaim(lockPath);
441
+ if (!cleared) {
442
+ return null;
443
+ }
444
+ }
445
+ }
446
+ return null;
447
+ }
448
+ async removeStaleAlertClaim(lockPath) {
449
+ try {
450
+ const lockStat = await stat(lockPath);
451
+ const staleMs = Math.max(this.requestInterval * 4 * 1000, ALERT_CLAIM_MIN_STALE_MS);
452
+ if (Date.now() - lockStat.mtimeMs <= staleMs) {
453
+ return false;
454
+ }
455
+ await rm(lockPath, { force: true });
456
+ return true;
457
+ }
458
+ catch (error) {
459
+ if (error.code === "ENOENT") {
460
+ return true;
461
+ }
462
+ throw error;
463
+ }
464
+ }
416
465
  async trySendAlert(symbol, ruleName, input) {
417
466
  const sessionKey = getSessionKey();
418
- if (await this.alertLogRepository.isSentThisSession(symbol, ruleName, sessionKey)) {
467
+ const claim = await this.tryAcquireAlertClaim(symbol, ruleName, sessionKey);
468
+ if (!claim) {
419
469
  await this.cleanupAlertMedia(input);
420
470
  return false;
421
471
  }
422
- const result = await this.sendAlertAndCleanupMedia(input);
423
- if (!result.ok) {
424
- return false;
472
+ try {
473
+ if (await this.alertLogRepository.isSentThisSession(symbol, ruleName, sessionKey)) {
474
+ await this.cleanupAlertMedia(input);
475
+ return false;
476
+ }
477
+ const result = await this.sendAlertAndCleanupMedia(input);
478
+ if (!result.ok) {
479
+ return false;
480
+ }
481
+ const message = typeof input === "string" ? input : input.message;
482
+ await this.alertLogRepository.append({
483
+ symbol,
484
+ alert_date: sessionKey,
485
+ rule_name: ruleName,
486
+ message,
487
+ triggered_at: formatChinaDateTime(),
488
+ });
489
+ return true;
490
+ }
491
+ finally {
492
+ await claim.release();
425
493
  }
426
- const message = typeof input === "string" ? input : input.message;
427
- await this.alertLogRepository.append({
428
- symbol,
429
- alert_date: sessionKey,
430
- rule_name: ruleName,
431
- message,
432
- triggered_at: formatChinaDateTime(),
433
- });
434
- return true;
435
494
  }
436
495
  async trySendCandidate(item, quote, candidate, levels, getIntradayRows) {
437
496
  if (await this.hasSentAlert(item.symbol, candidate.ruleName)) {
@@ -554,6 +613,9 @@ export class MonitorService {
554
613
  getRunLockFilePath() {
555
614
  return path.join(this.baseDir, "monitor-run.lock");
556
615
  }
616
+ getAlertClaimFilePath(symbol, ruleName, sessionKey) {
617
+ return path.join(this.baseDir, "alert-claims", `${sanitizeAlertClaimPart(sessionKey)}_${sanitizeAlertClaimPart(symbol)}_${sanitizeAlertClaimPart(ruleName)}.lock`);
618
+ }
557
619
  }
558
620
  function formatRunningState(state, requestInterval) {
559
621
  const heartbeat = getHeartbeatStatus(state, requestInterval);
@@ -604,6 +666,9 @@ function parseChinaDateTime(value) {
604
666
  const [, year, month, day, hour, minute, second] = match;
605
667
  return Date.UTC(Number(year), Number(month) - 1, Number(day), Number(hour) - 8, Number(minute), Number(second));
606
668
  }
669
+ function sanitizeAlertClaimPart(value) {
670
+ return value.replace(/[^a-zA-Z0-9._-]+/g, "_");
671
+ }
607
672
  function formatRuntimeHost(state) {
608
673
  return state.runtimeHost === "plugin_service"
609
674
  ? "plugin_service"
@@ -0,0 +1,21 @@
1
+ import { Jin10FlashRepository } from "../storage/repositories/jin10-flash-repo.js";
2
+ import type { DailyUpdateResultType } from "../types/daily-update.js";
3
+ import { AnalysisService } from "./analysis-service.js";
4
+ import { Jin10McpService } from "./jin10-mcp-service.js";
5
+ import { WatchlistService } from "./watchlist-service.js";
6
+ export interface PreMarketBriefRunResult {
7
+ resultType: DailyUpdateResultType;
8
+ message: string;
9
+ sourceCount: number;
10
+ matchedWatchlistCount: number;
11
+ }
12
+ export declare class PreMarketBriefService {
13
+ private readonly watchlistService;
14
+ private readonly jin10McpService;
15
+ private readonly flashRepository;
16
+ private readonly analysisService;
17
+ constructor(watchlistService: WatchlistService, jin10McpService: Jin10McpService, flashRepository: Jin10FlashRepository, analysisService: AnalysisService);
18
+ run(now?: Date): Promise<PreMarketBriefRunResult>;
19
+ private syncWindow;
20
+ private buildSummary;
21
+ }
@@ -0,0 +1,289 @@
1
+ import { createHash } from "node:crypto";
2
+ import { PRE_MARKET_BRIEF_SYSTEM_PROMPT, buildPreMarketBriefUserPrompt, } from "../prompts/analysis/index.js";
3
+ import { formatChinaDateTime } from "../utils/china-time.js";
4
+ const PRE_MARKET_BRIEF_KEYWORD = "金十数据整理";
5
+ const PRE_MARKET_READY_TIME = "09:20:00";
6
+ const PRE_MARKET_SYNC_MAX_PAGES = 12;
7
+ const OPPORTUNITY_KEYWORDS = [
8
+ "政策",
9
+ "订单",
10
+ "中标",
11
+ "业绩",
12
+ "回购",
13
+ "增持",
14
+ "涨价",
15
+ "算力",
16
+ "并购",
17
+ "并购重组",
18
+ "AI",
19
+ "人工智能",
20
+ "机器人",
21
+ ];
22
+ const RISK_KEYWORDS = [
23
+ "减持",
24
+ "监管",
25
+ "问询",
26
+ "处罚",
27
+ "停牌",
28
+ "复牌",
29
+ "下调",
30
+ "风险",
31
+ "不确定",
32
+ "制裁",
33
+ "关税",
34
+ ];
35
+ export class PreMarketBriefService {
36
+ watchlistService;
37
+ jin10McpService;
38
+ flashRepository;
39
+ analysisService;
40
+ constructor(watchlistService, jin10McpService, flashRepository, analysisService) {
41
+ this.watchlistService = watchlistService;
42
+ this.jin10McpService = jin10McpService;
43
+ this.flashRepository = flashRepository;
44
+ this.analysisService = analysisService;
45
+ }
46
+ async run(now = new Date()) {
47
+ const watchlist = await this.watchlistService.list();
48
+ if (watchlist.length === 0) {
49
+ return {
50
+ resultType: "skipped",
51
+ message: "🚫 开盘前资讯简报已跳过:关注列表为空。",
52
+ sourceCount: 0,
53
+ matchedWatchlistCount: 0,
54
+ };
55
+ }
56
+ const configError = this.jin10McpService.getConfigurationError();
57
+ if (configError) {
58
+ return {
59
+ resultType: "skipped",
60
+ message: `🚫 开盘前资讯简报已跳过:${configError}`,
61
+ sourceCount: 0,
62
+ matchedWatchlistCount: 0,
63
+ };
64
+ }
65
+ const window = buildPreMarketWindow(now);
66
+ await this.syncWindow(window);
67
+ const flashes = (await this.flashRepository.listByPublishedRange(window.startTs, window.endTs))
68
+ .filter((record) => matchesPreMarketBrief(record));
69
+ if (flashes.length === 0) {
70
+ return {
71
+ resultType: "success",
72
+ message: [
73
+ `**🌅 开盘前资讯简报|${window.endAt.slice(0, 10)}**`,
74
+ `信息窗口: ${window.startAt} ~ ${window.endAt}`,
75
+ `整理快讯: 0 条 | 自选: ${watchlist.length} 只`,
76
+ "",
77
+ `本窗口未检索到标题含“${PRE_MARKET_BRIEF_KEYWORD}”的快讯,今日无新增盘前整理摘要。`,
78
+ ].join("\n"),
79
+ sourceCount: 0,
80
+ matchedWatchlistCount: 0,
81
+ };
82
+ }
83
+ const matchContexts = flashes.map((flash) => ({
84
+ flash,
85
+ matchedItems: findMatchedItems(flash, watchlist),
86
+ }));
87
+ const matchedWatchlistCount = new Set(matchContexts.flatMap((context) => context.matchedItems.map((item) => item.symbol))).size;
88
+ const summary = await this.buildSummary(window, watchlist, matchContexts);
89
+ return {
90
+ resultType: "success",
91
+ message: [
92
+ `**🌅 开盘前资讯简报|${window.endAt.slice(0, 10)}**`,
93
+ `信息窗口: ${window.startAt} ~ ${window.endAt}`,
94
+ `整理快讯: ${flashes.length} 条 | 自选: ${watchlist.length} 只 | 规则命中: ${matchedWatchlistCount} 只`,
95
+ "",
96
+ summary.trim(),
97
+ ].join("\n"),
98
+ sourceCount: flashes.length,
99
+ matchedWatchlistCount,
100
+ };
101
+ }
102
+ async syncWindow(window) {
103
+ let cursor;
104
+ const collected = [];
105
+ for (let pageIndex = 0; pageIndex < PRE_MARKET_SYNC_MAX_PAGES; pageIndex += 1) {
106
+ const page = await this.jin10McpService.listFlash(cursor);
107
+ const records = page.items
108
+ .map((item) => toFlashRecord(item))
109
+ .filter((item) => item != null);
110
+ if (records.length === 0) {
111
+ break;
112
+ }
113
+ collected.push(...records);
114
+ const oldestPublishedTs = records[records.length - 1]?.published_ts ?? Number.MAX_SAFE_INTEGER;
115
+ if (oldestPublishedTs < window.startTs || !page.hasMore || !page.nextCursor) {
116
+ break;
117
+ }
118
+ cursor = page.nextCursor;
119
+ }
120
+ if (collected.length > 0) {
121
+ await this.flashRepository.saveAll(collected);
122
+ }
123
+ }
124
+ async buildSummary(window, watchlist, matchContexts) {
125
+ const promptInput = {
126
+ windowStartAt: window.startAt,
127
+ windowEndAt: window.endAt,
128
+ watchlist,
129
+ flashes: matchContexts.map((context) => ({
130
+ publishedAt: context.flash.published_at,
131
+ headline: extractHeadlineFromContent(context.flash.content),
132
+ content: context.flash.content,
133
+ url: context.flash.url,
134
+ matchedSymbols: context.matchedItems.map((item) => item.symbol),
135
+ })),
136
+ };
137
+ if (this.analysisService.isConfigured()) {
138
+ try {
139
+ return await this.analysisService.generateText(PRE_MARKET_BRIEF_SYSTEM_PROMPT, buildPreMarketBriefUserPrompt(promptInput), {
140
+ maxTokens: 1600,
141
+ temperature: 0.2,
142
+ });
143
+ }
144
+ catch {
145
+ // Fall through to deterministic fallback so the scheduled push still lands.
146
+ }
147
+ }
148
+ return buildFallbackSummary(matchContexts);
149
+ }
150
+ }
151
+ function buildPreMarketWindow(now) {
152
+ const chinaToday = formatChinaDate(now);
153
+ const previousDay = formatChinaDate(new Date(toChinaTimestamp(`${chinaToday} ${PRE_MARKET_READY_TIME}`) - 24 * 60 * 60 * 1000));
154
+ const startAt = `${previousDay} 17:00:00`;
155
+ const endAt = `${chinaToday} ${PRE_MARKET_READY_TIME}`;
156
+ return {
157
+ startAt,
158
+ endAt,
159
+ startTs: toChinaTimestamp(startAt),
160
+ endTs: toChinaTimestamp(endAt),
161
+ };
162
+ }
163
+ function toChinaTimestamp(value) {
164
+ return new Date(`${value.replace(" ", "T")}+08:00`).getTime();
165
+ }
166
+ function formatChinaDate(date) {
167
+ const formatter = new Intl.DateTimeFormat("zh-CN", {
168
+ timeZone: "Asia/Shanghai",
169
+ year: "numeric",
170
+ month: "2-digit",
171
+ day: "2-digit",
172
+ });
173
+ const map = Object.fromEntries(formatter
174
+ .formatToParts(date)
175
+ .filter((part) => part.type !== "literal")
176
+ .map((part) => [part.type, part.value]));
177
+ return `${map.year}-${map.month}-${map.day}`;
178
+ }
179
+ function matchesPreMarketBrief(record) {
180
+ return extractHeadlineText(record.content).includes(PRE_MARKET_BRIEF_KEYWORD);
181
+ }
182
+ function findMatchedItems(flash, watchlist) {
183
+ const normalizedContent = normalizeText(flash.content);
184
+ 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));
192
+ });
193
+ }
194
+ 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));
197
+ return [
198
+ formatSectionTitle("🧭", "重大要闻"),
199
+ formatFlashBullets(matchContexts, 5),
200
+ "",
201
+ formatSectionTitle("🎯", "自选相关"),
202
+ formatMatchedBullets(matchContexts, 5),
203
+ "",
204
+ formatSectionTitle("💡", "潜在机会"),
205
+ opportunityContexts.length > 0
206
+ ? formatFlashBullets(opportunityContexts, 4)
207
+ : "• 未发现基于当前整理快讯可直接确认的新增机会方向。",
208
+ "",
209
+ formatSectionTitle("⚠️", "风险提示"),
210
+ riskContexts.length > 0
211
+ ? formatFlashBullets(riskContexts, 4)
212
+ : "• 当前整理快讯中未发现特别突出的新增风险,但仍需留意开盘后的情绪变化。",
213
+ "",
214
+ formatSectionTitle("📌", "开盘前关注清单"),
215
+ buildFocusBullets(matchContexts),
216
+ ].join("\n");
217
+ }
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)}`;
224
+ })
225
+ .join("\n");
226
+ }
227
+ function formatMatchedBullets(contexts, limit) {
228
+ const matched = contexts.filter((context) => context.matchedItems.length > 0).slice(0, limit);
229
+ if (matched.length === 0) {
230
+ return "• 未发现直接命中自选股、行业或题材的盘前整理快讯。";
231
+ }
232
+ return matched.map((context) => {
233
+ const labels = context.matchedItems.map((item) => `${item.name}(${item.symbol})`).join("、");
234
+ return `• ${labels}: ${extractHeadlineFromContent(context.flash.content)}`;
235
+ }).join("\n");
236
+ }
237
+ function buildFocusBullets(contexts) {
238
+ 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)}”是否继续发酵。`);
243
+ }
244
+ if (bullets.length < 3) {
245
+ for (const context of contexts.slice(0, 3 - bullets.length)) {
246
+ bullets.push(`• 关注“${extractHeadlineFromContent(context.flash.content)}”对应板块是否出现竞价强化或高开分歧。`);
247
+ }
248
+ }
249
+ return bullets.slice(0, 5).join("\n");
250
+ }
251
+ function containsAnyKeyword(content, keywords) {
252
+ return keywords.some((keyword) => content.includes(keyword));
253
+ }
254
+ function normalizeText(value) {
255
+ return value.toLowerCase().replace(/\s+/g, "");
256
+ }
257
+ function formatSectionTitle(icon, title) {
258
+ return `**【${icon} ${title}】**`;
259
+ }
260
+ function extractHeadlineFromContent(content) {
261
+ const firstLine = extractHeadlineText(content);
262
+ return firstLine.length > 72 ? `${firstLine.slice(0, 72)}...` : firstLine;
263
+ }
264
+ function extractHeadlineText(content) {
265
+ return content.split(/[\n。!!]/)[0]?.trim() ?? "";
266
+ }
267
+ function toFlashRecord(item) {
268
+ const published = new Date(item.time);
269
+ if (Number.isNaN(published.getTime())) {
270
+ return null;
271
+ }
272
+ return {
273
+ flash_key: buildFlashKey(item.url, item.time, item.content),
274
+ published_at: formatChinaDateTime(published),
275
+ published_ts: published.getTime(),
276
+ content: item.content.trim(),
277
+ url: item.url.trim(),
278
+ ingested_at: formatChinaDateTime(),
279
+ raw: item.raw,
280
+ };
281
+ }
282
+ function buildFlashKey(url, time, content) {
283
+ if (url.trim()) {
284
+ return url.trim();
285
+ }
286
+ return createHash("sha1")
287
+ .update(`${time}\n${content}`)
288
+ .digest("hex");
289
+ }
@@ -13,6 +13,10 @@ export declare class TradingCalendarService {
13
13
  ok: boolean;
14
14
  reason: string;
15
15
  }>;
16
+ canRunPreMarketBrief(date?: Date): Promise<{
17
+ ok: boolean;
18
+ reason: string;
19
+ }>;
16
20
  getRecentTradingDays(limit: number, endDate?: Date): Promise<string[]>;
17
21
  private loadDays;
18
22
  private toChinaDate;
@@ -51,6 +51,17 @@ export class TradingCalendarService {
51
51
  }
52
52
  return { ok: true, reason: "交易日已完成收盘且达到复盘时间" };
53
53
  }
54
+ async canRunPreMarketBrief(date = new Date()) {
55
+ if (!(await this.isTradingDay(date))) {
56
+ return { ok: false, reason: `${this.toChinaDate(date)} 非交易日` };
57
+ }
58
+ const china = this.toChinaParts(date);
59
+ const hhmm = `${china.hour}:${china.minute}`;
60
+ if (hhmm < "09:20") {
61
+ return { ok: false, reason: `当前 ${hhmm},须等到 09:20 后执行盘前资讯简报` };
62
+ }
63
+ return { ok: true, reason: "交易日且达到盘前资讯简报时间" };
64
+ }
54
65
  async getRecentTradingDays(limit, endDate = new Date()) {
55
66
  if (!Number.isInteger(limit) || limit <= 0) {
56
67
  throw new Error(`limit must be a positive integer, got ${limit}`);
@@ -10,6 +10,7 @@ export declare class Jin10FlashRepository {
10
10
  }>;
11
11
  getLatest(): Promise<Jin10FlashRecord | null>;
12
12
  countSincePublishedTs(publishedTs: number): Promise<number>;
13
+ listByPublishedRange(startPublishedTs: number, endPublishedTs: number): Promise<Jin10FlashRecord[]>;
13
14
  searchByContentKeywords(keywords: string[], datePrefix: string): Promise<Jin10FlashRecord[]>;
14
15
  pruneOlderThanPublishedTs(publishedTs: number): Promise<void>;
15
16
  private listExistingKeys;
@@ -45,7 +45,16 @@ export class Jin10FlashRepository {
45
45
  if (rows.length === 0) {
46
46
  return null;
47
47
  }
48
- return fromFlashRow(rows[rows.length - 1]);
48
+ let latestRow = rows[0];
49
+ let latestTs = Number(rows[0]?.published_ts ?? 0);
50
+ for (const row of rows.slice(1)) {
51
+ const publishedTs = Number(row.published_ts ?? 0);
52
+ if (publishedTs >= latestTs) {
53
+ latestRow = row;
54
+ latestTs = publishedTs;
55
+ }
56
+ }
57
+ return latestRow ? fromFlashRow(latestRow) : null;
49
58
  }
50
59
  async countSincePublishedTs(publishedTs) {
51
60
  if (!(await this.db.hasTable(JIN10_FLASH_TABLE))) {
@@ -54,6 +63,19 @@ export class Jin10FlashRepository {
54
63
  const table = await this.db.openTable(JIN10_FLASH_TABLE);
55
64
  return table.countRows(`published_ts >= ${Math.trunc(publishedTs)}`);
56
65
  }
66
+ async listByPublishedRange(startPublishedTs, endPublishedTs) {
67
+ if (!(await this.db.hasTable(JIN10_FLASH_TABLE))) {
68
+ return [];
69
+ }
70
+ const table = await this.db.openTable(JIN10_FLASH_TABLE);
71
+ const rows = (await table
72
+ .query()
73
+ .where(`published_ts >= ${Math.trunc(startPublishedTs)} AND published_ts <= ${Math.trunc(endPublishedTs)}`)
74
+ .toArray());
75
+ return rows
76
+ .map((row) => fromFlashRow(row))
77
+ .sort((left, right) => left.published_ts - right.published_ts);
78
+ }
57
79
  async searchByContentKeywords(keywords, datePrefix) {
58
80
  if (keywords.length === 0 || !(await this.db.hasTable(JIN10_FLASH_TABLE))) {
59
81
  return [];
@@ -23,4 +23,11 @@ export interface DailyUpdateState {
23
23
  lastReviewResultType: DailyUpdateResultType | null;
24
24
  lastReviewResultSummary: string | null;
25
25
  reviewConsecutiveFailures: number;
26
+ lastPreMarketAttemptAt: string | null;
27
+ lastPreMarketAttemptDate: string | null;
28
+ lastPreMarketSuccessAt: string | null;
29
+ lastPreMarketSuccessDate: string | null;
30
+ lastPreMarketResultType: DailyUpdateResultType | null;
31
+ lastPreMarketResultSummary: string | null;
32
+ preMarketConsecutiveFailures: number;
26
33
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "tickflow-assist",
3
3
  "name": "TickFlow Assist",
4
- "version": "0.3.3",
4
+ "version": "0.3.4",
5
5
  "description": "A-share watchlist analysis, monitoring, and alert delivery powered by TickFlow and OpenClaw.",
6
6
  "skills": [
7
7
  "skills"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tickflow-assist",
3
- "version": "0.3.3",
3
+ "version": "0.3.4",
4
4
  "description": "面向 A 股投资与盯盘场景的 OpenClaw 智能股票插件,基于 TickFlow API 提供实时监控、收盘后复盘、多维综合分析、关键价位跟踪与告警能力。OpenClaw smart stock plugin for A-share investing and watchlist workflows, powered by TickFlow API for realtime monitoring, post-close review, multi-dimensional analysis, key level tracking, and alerts.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -37,7 +37,7 @@
37
37
  "dev": "tsc -p tsconfig.json --watch",
38
38
  "prepack": "npm run build && node ./scripts/prepare-package-assets.mjs",
39
39
  "postpack": "node ./scripts/restore-package-assets.mjs",
40
- "test": "npm run build && node --test dist/plugin-registration.test.js dist/tools/test-alert.tool.test.js dist/services/jin10-mcp-service.test.js dist/services/monitor-service.test.js",
40
+ "test": "npm run build && node --test dist/plugin-registration.test.js dist/tools/test-alert.tool.test.js dist/services/jin10-mcp-service.test.js dist/services/jin10-flash-monitor-service.test.js dist/services/pre-market-brief-service.test.js dist/services/monitor-service.test.js",
41
41
  "community-setup": "node dist/dev/tickflow-assist-cli.js configure-openclaw",
42
42
  "tool": "node dist/dev/run-tool.js",
43
43
  "monitor-loop": "node dist/dev/run-monitor-loop.js",
@@ -72,6 +72,6 @@
72
72
  "openclaw": "^2026.4.5",
73
73
  "typescript": "^5.8.2"
74
74
  },
75
- "readme": "# TickFlow Assist\n\n基于 [OpenClaw](https://openclaw.ai) 的 A 股监控与分析插件。它使用 [TickFlow](https://tickflow.org/auth/register?ref=BUJ54JEDGE) 获取行情与财务数据,并可选接入 [金十数据 MCP](https://mcp.jin10.com/app/) 快讯流,结合 LLM 生成技术面、基本面、资讯面的综合判断,并把结果持久化到本地 LanceDB。\n\n最近更新:`v0.3.3` 修复监控告警图片在去重前高频生成与重复告警风险,并收敛价格告警触发噪声。完整发布记录见 <https://github.com/robinspt/tickflow-assist/blob/main/CHANGELOG.md>。\n\n当前主线按 OpenClaw `v2026.3.31+` 对齐,并已验证社区安装在 `v2026.4.5` 上兼容。\n\n## 安装\n\n社区安装:\n\n```bash\nopenclaw plugins install tickflow-assist\nnpx -y tickflow-assist configure-openclaw\ncd ~/.openclaw/extensions/tickflow-assist/python && uv sync\nopenclaw plugins enable tickflow-assist\nopenclaw config validate\nopenclaw gateway restart\n```\n\n安装阶段允许先落插件,再通过第二条命令写入 `tickflowApiKey`、`llmApiKey` 等正式配置。\n`configure-openclaw` 会写入 `~/.openclaw/openclaw.json` 中的 `plugins.entries[\"tickflow-assist\"].config`,并打印后续建议执行的命令;它不再自动执行 `openclaw`、`uv` 或系统包安装命令。\n如果检测到 `plugins.installs[\"tickflow-assist\"]` 来自 `clawhub`,向导还会把被旧版本钉死的 `spec` 归一化为 `clawhub:tickflow-assist`,避免后续升级继续锁在旧版本。\n\n如果你希望先审阅配置,再只打印最少的后续步骤,可使用:\n\n```bash\nnpx -y tickflow-assist configure-openclaw --no-enable --no-restart\n```\n\n如果你在 Linux 或 macOS 上需要 PNG 告警卡正常显示中文,请额外手动安装 `fontconfig` 与 Noto CJK 一类中文字体,例如:\n\n```bash\n# Debian / Ubuntu\nsudo apt-get update\nsudo apt-get install -y fontconfig fonts-noto-cjk\nfc-cache -fv\n\n# RHEL / Fedora / Rocky / AlmaLinux\nsudo dnf install -y fontconfig google-noto-sans-cjk-ttc-fonts\nfc-cache -fv\n\n# Arch / Manjaro\nsudo pacman -Sy --noconfirm fontconfig noto-fonts-cjk\nfc-cache -fv\n\n# Alpine\nsudo apk add fontconfig font-noto-cjk\nfc-cache -fv\n\n# macOS (Homebrew)\nbrew install fontconfig\nbrew install --cask font-noto-sans-cjk\nfc-cache -fv\n```\n\n社区安装后的升级方式:\n\n```bash\nopenclaw plugins update tickflow-assist\nopenclaw gateway restart\n```\n\n## 配置\n\n插件正式运行读取:\n\n```text\n~/.openclaw/openclaw.json\n```\n\n配置路径:\n\n```text\nplugins.entries[\"tickflow-assist\"].config\n```\n\n建议按完整功能显式填写以下字段,不要只填 API Key:\n\n- 核心运行:`tickflowApiKey`、`llmApiKey`、`llmBaseUrl`、`llmModel`\n- 本地数据:`databasePath`、`calendarFile`\n- 告警投递:`alertChannel`、`alertTarget`、`alertAccount`\n- 能力补充:`mxSearchApiKey`、`jin10ApiToken`\n\n其中,`mxSearchApiKey` 用于 `mx_search`、`mx_select_stock` 以及非 `Expert` 财务链路的 lite 补充;`jin10ApiToken` 用于 24 小时金十数据快讯监控;`jin10FlashNightAlert` 默认 `false`(开启夜间静默),设为 `true` 可恢复 24 小时快讯告警;`alertTarget`、`alertAccount` 建议在准备启用 `test_alert`、实时监控告警、金十数据快讯告警和定时通知前一并配好,避免配置不完整导致功能缺失。\n\n## 功能\n\n- 自选股管理、日 K / 分钟 K 抓取与指标计算\n- 技术面、财务面、资讯面的综合分析\n- 实时监控、定时日更、收盘后复盘\n- 金十数据 24 小时快讯监控与自选关联提醒\n- 本地 LanceDB 数据留痕与分析结果查看\n\n## 运行说明\n\n- 插件会在本地 `databasePath` 下持久化 LanceDB 数据。\n- 后台服务会按配置执行定时日更、实时监控与金十数据快讯监控。\n- Python 子模块仅用于技术指标计算,不承担主业务流程。\n\n## 依赖与可选能力\n\n- [TickFlow](https://tickflow.org/auth/register?ref=BUJ54JEDGE):提供日线、分钟线、实时行情与财务数据接口。\n- [金十数据 MCP](https://mcp.jin10.com/app/):可选,用于 24 小时快讯流接入、自选关联筛选与事件驱动告警。\n- [东方财富妙想 Skills](https://marketing.dfcfs.com/views/finskillshub/):可选,用于 `mx_search`、`mx_select_stock` 与非 `Expert` 财务链路的 lite 补充。\n\n## 仓库\n\n- GitHub: <https://github.com/robinspt/tickflow-assist>\n",
75
+ "readme": "# TickFlow Assist\n\n基于 [OpenClaw](https://openclaw.ai) 的 A 股监控与分析插件。它使用 [TickFlow](https://tickflow.org/auth/register?ref=BUJ54JEDGE) 获取行情与财务数据,并可选接入 [金十数据 MCP](https://mcp.jin10.com/app/) 快讯流,结合 LLM 生成技术面、基本面、资讯面的综合判断,并把结果持久化到本地 LanceDB。\n\n最近更新:`v0.3.4` 新增 `09:20` 盘前资讯简报,修复 Jin10 历史补页重复推送与状态页最新快讯显示错误,并降低 Telegram 图文告警被误判失败后重复补发的风险。完整发布记录见 <https://github.com/robinspt/tickflow-assist/blob/main/CHANGELOG.md>。\n\n当前主线按 OpenClaw `v2026.3.31+` 对齐,并已验证社区安装在 `v2026.4.5` 上兼容。\n\n## 安装\n\n社区安装:\n\n```bash\nopenclaw plugins install tickflow-assist\nnpx -y tickflow-assist configure-openclaw\ncd ~/.openclaw/extensions/tickflow-assist/python && uv sync\nopenclaw plugins enable tickflow-assist\nopenclaw config validate\nopenclaw gateway restart\n```\n\n安装阶段允许先落插件,再通过第二条命令写入 `tickflowApiKey`、`llmApiKey` 等正式配置。\n`configure-openclaw` 会写入 `~/.openclaw/openclaw.json` 中的 `plugins.entries[\"tickflow-assist\"].config`,并打印后续建议执行的命令;它不再自动执行 `openclaw`、`uv` 或系统包安装命令。\n如果检测到 `plugins.installs[\"tickflow-assist\"]` 来自 `clawhub`,向导还会把被旧版本钉死的 `spec` 归一化为 `clawhub:tickflow-assist`,避免后续升级继续锁在旧版本。\n\n如果你希望先审阅配置,再只打印最少的后续步骤,可使用:\n\n```bash\nnpx -y tickflow-assist configure-openclaw --no-enable --no-restart\n```\n\n如果你在 Linux 或 macOS 上需要 PNG 告警卡正常显示中文,请额外手动安装 `fontconfig` 与 Noto CJK 一类中文字体,例如:\n\n```bash\n# Debian / Ubuntu\nsudo apt-get update\nsudo apt-get install -y fontconfig fonts-noto-cjk\nfc-cache -fv\n\n# RHEL / Fedora / Rocky / AlmaLinux\nsudo dnf install -y fontconfig google-noto-sans-cjk-ttc-fonts\nfc-cache -fv\n\n# Arch / Manjaro\nsudo pacman -Sy --noconfirm fontconfig noto-fonts-cjk\nfc-cache -fv\n\n# Alpine\nsudo apk add fontconfig font-noto-cjk\nfc-cache -fv\n\n# macOS (Homebrew)\nbrew install fontconfig\nbrew install --cask font-noto-sans-cjk\nfc-cache -fv\n```\n\n社区安装后的升级方式:\n\n```bash\nopenclaw plugins update tickflow-assist\nopenclaw gateway restart\n```\n\n## 配置\n\n插件正式运行读取:\n\n```text\n~/.openclaw/openclaw.json\n```\n\n配置路径:\n\n```text\nplugins.entries[\"tickflow-assist\"].config\n```\n\n建议按完整功能显式填写以下字段,不要只填 API Key:\n\n- 核心运行:`tickflowApiKey`、`llmApiKey`、`llmBaseUrl`、`llmModel`\n- 本地数据:`databasePath`、`calendarFile`\n- 告警投递:`alertChannel`、`alertTarget`、`alertAccount`\n- 能力补充:`mxSearchApiKey`、`jin10ApiToken`\n\n其中,`mxSearchApiKey` 用于 `mx_search`、`mx_select_stock` 以及非 `Expert` 财务链路的 lite 补充;`jin10ApiToken` 用于 24 小时金十数据快讯监控;`jin10FlashNightAlert` 默认 `false`(开启夜间静默),设为 `true` 可恢复 24 小时快讯告警;`alertTarget`、`alertAccount` 建议在准备启用 `test_alert`、实时监控告警、金十数据快讯告警和定时通知前一并配好,避免配置不完整导致功能缺失。\n\n## 功能\n\n- 自选股管理、日 K / 分钟 K 抓取与指标计算\n- 技术面、财务面、资讯面的综合分析\n- 实时监控、定时日更、收盘后复盘\n- 金十数据 24 小时快讯监控与自选关联提醒\n- 本地 LanceDB 数据留痕与分析结果查看\n\n## 运行说明\n\n- 插件会在本地 `databasePath` 下持久化 LanceDB 数据。\n- 后台服务会按配置执行定时日更、实时监控与金十数据快讯监控。\n- Python 子模块仅用于技术指标计算,不承担主业务流程。\n\n## 依赖与可选能力\n\n- [TickFlow](https://tickflow.org/auth/register?ref=BUJ54JEDGE):提供日线、分钟线、实时行情与财务数据接口。\n- [金十数据 MCP](https://mcp.jin10.com/app/):可选,用于 24 小时快讯流接入、自选关联筛选与事件驱动告警。\n- [东方财富妙想 Skills](https://marketing.dfcfs.com/views/finskillshub/):可选,用于 `mx_search`、`mx_select_stock` 与非 `Expert` 财务链路的 lite 补充。\n\n## 仓库\n\n- GitHub: <https://github.com/robinspt/tickflow-assist>\n",
76
76
  "readmeFilename": "README.md"
77
77
  }