tickflow-assist 0.3.2 → 0.3.3

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.2` 为金十快讯新增夜间静默配置并将默认值改为“开启静默”,同时把个股关联快讯与市场概览快讯接入收盘复盘上下文。完整发布记录见 <https://github.com/robinspt/tickflow-assist/blob/main/CHANGELOG.md>。
5
+ 最近更新:`v0.3.3` 修复监控告警图片在去重前高频生成与重复告警风险,并收敛价格告警触发噪声。完整发布记录见 <https://github.com/robinspt/tickflow-assist/blob/main/CHANGELOG.md>。
6
6
 
7
7
  当前主线按 OpenClaw `v2026.3.31+` 对齐,并已验证社区安装在 `v2026.4.5` 上兼容。
8
8
 
@@ -45,8 +45,15 @@ export declare class MonitorService {
45
45
  setExpectedStop(expectedStop: boolean): Promise<void>;
46
46
  recordHeartbeat(runtimeHost?: "plugin_service" | "fallback_process"): Promise<void>;
47
47
  recordLoopError(error: unknown): Promise<void>;
48
+ private tryAcquireRunLease;
49
+ private removeStaleRunLock;
48
50
  private trySendAlert;
51
+ private trySendCandidate;
49
52
  private buildAlertDelivery;
50
53
  private loadIntradayRows;
51
54
  private buildVolumeAlert;
55
+ private hasSentAlert;
56
+ private sendAlertAndCleanupMedia;
57
+ private cleanupAlertMedia;
58
+ private getRunLockFilePath;
52
59
  }
@@ -1,4 +1,4 @@
1
- import { mkdir, readFile, writeFile } from "node:fs/promises";
1
+ import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { formatChinaDateTime } from "../utils/china-time.js";
4
4
  import { calculateProfitPct, formatCostPrice } from "../utils/cost-price.js";
@@ -20,6 +20,7 @@ const DEFAULT_STATE = {
20
20
  sessionNotificationsSent: [],
21
21
  };
22
22
  const INTRADAY_PERIOD = "1m";
23
+ const MONITOR_RUN_LOCK_MIN_STALE_MS = 90_000;
23
24
  export class MonitorService {
24
25
  baseDir;
25
26
  requestInterval;
@@ -178,50 +179,60 @@ export class MonitorService {
178
179
  return lines.join("\n");
179
180
  }
180
181
  async runMonitorOnce() {
181
- await this.alertMediaService.maybeCleanupExpired();
182
- const phase = await this.tradingCalendarService.getTradingPhase();
183
- let alertCount = await this.maybeSendSessionNotification(phase);
184
- if (phase !== "trading") {
185
- return alertCount;
186
- }
187
- const watchlist = await this.watchlistService.list();
188
- if (watchlist.length === 0) {
189
- return alertCount;
182
+ const runLease = await this.tryAcquireRunLease();
183
+ if (!runLease) {
184
+ return 0;
190
185
  }
191
- const quotes = await this.quoteService.fetchQuotes(watchlist.map((item) => item.symbol));
192
- const quoteMap = new Map(quotes.map((quote) => [quote.symbol, quote]));
193
- for (const item of watchlist) {
194
- const quote = quoteMap.get(item.symbol);
195
- if (!quote || !(Number(quote.last_price) > 0)) {
196
- continue;
186
+ try {
187
+ await this.alertMediaService.maybeCleanupExpired();
188
+ const phase = await this.tradingCalendarService.getTradingPhase();
189
+ let alertCount = await this.maybeSendSessionNotification(phase);
190
+ if (phase !== "trading") {
191
+ return alertCount;
197
192
  }
198
- const levels = await this.keyLevelsRepository.getBySymbol(item.symbol);
199
- let intradayRowsPromise = null;
200
- const getIntradayRows = () => {
201
- intradayRowsPromise ??= this.loadIntradayRows(item.symbol);
202
- return intradayRowsPromise;
203
- };
204
- if (levels) {
205
- for (const candidate of buildPriceAlerts(item, quote, levels, this.alertService)) {
206
- const delivery = await this.buildAlertDelivery(item, quote, candidate, levels, getIntradayRows);
207
- if (await this.trySendAlert(item.symbol, candidate.ruleName, delivery)) {
193
+ const watchlist = await this.watchlistService.list();
194
+ if (watchlist.length === 0) {
195
+ return alertCount;
196
+ }
197
+ const quotes = await this.quoteService.fetchQuotes(watchlist.map((item) => item.symbol));
198
+ const quoteMap = new Map(quotes.map((quote) => [quote.symbol, quote]));
199
+ for (const item of watchlist) {
200
+ const quote = quoteMap.get(item.symbol);
201
+ if (!quote || !(Number(quote.last_price) > 0)) {
202
+ continue;
203
+ }
204
+ const levels = await this.keyLevelsRepository.getBySymbol(item.symbol);
205
+ let intradayRowsPromise = null;
206
+ const getIntradayRows = () => {
207
+ intradayRowsPromise ??= this.loadIntradayRows(item.symbol);
208
+ return intradayRowsPromise;
209
+ };
210
+ const priceAlert = levels
211
+ ? selectPrimaryAlertCandidate(buildPriceAlerts(item, quote, levels, this.alertService))
212
+ : null;
213
+ if (priceAlert) {
214
+ if (await this.trySendCandidate(item, quote, priceAlert, levels, getIntradayRows)) {
208
215
  alertCount += 1;
209
216
  }
217
+ continue;
210
218
  }
211
- }
212
- const changeAlert = buildChangeAlert(item, quote, levels, this.alertService);
213
- if (changeAlert) {
214
- const delivery = await this.buildAlertDelivery(item, quote, changeAlert, levels, getIntradayRows);
215
- if (await this.trySendAlert(item.symbol, changeAlert.ruleName, delivery)) {
219
+ const changeAlert = buildChangeAlert(item, quote, levels, this.alertService);
220
+ if (changeAlert) {
221
+ if (await this.trySendCandidate(item, quote, changeAlert, levels, getIntradayRows)) {
222
+ alertCount += 1;
223
+ }
224
+ continue;
225
+ }
226
+ const volumeAlert = await this.buildVolumeAlert(item, quote, levels);
227
+ if (volumeAlert && (await this.trySendAlert(item.symbol, volumeAlert.ruleName, volumeAlert.message))) {
216
228
  alertCount += 1;
217
229
  }
218
230
  }
219
- const volumeAlert = await this.buildVolumeAlert(item, quote, levels);
220
- if (volumeAlert && (await this.trySendAlert(item.symbol, volumeAlert.ruleName, volumeAlert.message))) {
221
- alertCount += 1;
222
- }
231
+ return alertCount;
232
+ }
233
+ finally {
234
+ await runLease.release();
223
235
  }
224
- return alertCount;
225
236
  }
226
237
  async maybeSendSessionNotification(phase) {
227
238
  const now = formatChinaDateTime();
@@ -357,18 +368,61 @@ export class MonitorService {
357
368
  lastLoopErrorAt: formatChinaDateTime(),
358
369
  });
359
370
  }
371
+ async tryAcquireRunLease() {
372
+ const lockPath = this.getRunLockFilePath();
373
+ await mkdir(path.dirname(lockPath), { recursive: true });
374
+ for (let attempt = 0; attempt < 2; attempt += 1) {
375
+ try {
376
+ await writeFile(lockPath, JSON.stringify({
377
+ pid: process.pid,
378
+ acquiredAt: formatChinaDateTime(),
379
+ }), { flag: "wx" });
380
+ return {
381
+ release: async () => {
382
+ await rm(lockPath, { force: true });
383
+ },
384
+ };
385
+ }
386
+ catch (error) {
387
+ const code = error.code;
388
+ if (code !== "EEXIST") {
389
+ throw error;
390
+ }
391
+ const cleared = await this.removeStaleRunLock(lockPath);
392
+ if (!cleared) {
393
+ return null;
394
+ }
395
+ }
396
+ }
397
+ return null;
398
+ }
399
+ async removeStaleRunLock(lockPath) {
400
+ try {
401
+ const lockStat = await stat(lockPath);
402
+ const staleMs = Math.max(this.requestInterval * 4 * 1000, MONITOR_RUN_LOCK_MIN_STALE_MS);
403
+ if (Date.now() - lockStat.mtimeMs <= staleMs) {
404
+ return false;
405
+ }
406
+ await rm(lockPath, { force: true });
407
+ return true;
408
+ }
409
+ catch (error) {
410
+ if (error.code === "ENOENT") {
411
+ return true;
412
+ }
413
+ throw error;
414
+ }
415
+ }
360
416
  async trySendAlert(symbol, ruleName, input) {
361
417
  const sessionKey = getSessionKey();
362
418
  if (await this.alertLogRepository.isSentThisSession(symbol, ruleName, sessionKey)) {
419
+ await this.cleanupAlertMedia(input);
363
420
  return false;
364
421
  }
365
- const result = await this.alertService.sendWithResult(input);
422
+ const result = await this.sendAlertAndCleanupMedia(input);
366
423
  if (!result.ok) {
367
424
  return false;
368
425
  }
369
- if (typeof input !== "string" && input.mediaPath && result.mediaDelivered) {
370
- await this.alertMediaService.removeFile(input.mediaPath);
371
- }
372
426
  const message = typeof input === "string" ? input : input.message;
373
427
  await this.alertLogRepository.append({
374
428
  symbol,
@@ -379,6 +433,13 @@ export class MonitorService {
379
433
  });
380
434
  return true;
381
435
  }
436
+ async trySendCandidate(item, quote, candidate, levels, getIntradayRows) {
437
+ if (await this.hasSentAlert(item.symbol, candidate.ruleName)) {
438
+ return false;
439
+ }
440
+ const delivery = await this.buildAlertDelivery(item, quote, candidate, levels, getIntradayRows);
441
+ return this.trySendAlert(item.symbol, candidate.ruleName, delivery);
442
+ }
382
443
  async buildAlertDelivery(item, quote, candidate, levels, getIntradayRows) {
383
444
  if (!candidate.image || !levels) {
384
445
  return candidate.message;
@@ -473,6 +534,26 @@ export class MonitorService {
473
534
  }),
474
535
  };
475
536
  }
537
+ async hasSentAlert(symbol, ruleName) {
538
+ return this.alertLogRepository.isSentThisSession(symbol, ruleName, getSessionKey());
539
+ }
540
+ async sendAlertAndCleanupMedia(input) {
541
+ try {
542
+ return await this.alertService.sendWithResult(input);
543
+ }
544
+ finally {
545
+ await this.cleanupAlertMedia(input);
546
+ }
547
+ }
548
+ async cleanupAlertMedia(input) {
549
+ if (typeof input === "string" || !input.mediaPath) {
550
+ return;
551
+ }
552
+ await this.alertMediaService.removeFile(input.mediaPath).catch(() => { });
553
+ }
554
+ getRunLockFilePath() {
555
+ return path.join(this.baseDir, "monitor-run.lock");
556
+ }
476
557
  }
477
558
  function formatRunningState(state, requestInterval) {
478
559
  const heartbeat = getHeartbeatStatus(state, requestInterval);
@@ -658,23 +739,58 @@ function buildPriceAlerts(item, quote, levels, alertService) {
658
739
  if (levels.stop_loss && currentPrice <= levels.stop_loss) {
659
740
  push("stop_loss_hit", "⛔ 触及止损", "价格已触及止损位,建议立即执行止损", levels.stop_loss);
660
741
  }
661
- else if (levels.stop_loss && currentPrice <= levels.stop_loss * (1 + buffer)) {
742
+ else if (levels.stop_loss
743
+ && currentPrice > levels.stop_loss
744
+ && isWithinPriceBuffer(currentPrice, levels.stop_loss, buffer)) {
662
745
  push("stop_loss_near", "⚠️ 接近止损", "价格接近止损位,请保持警惕", levels.stop_loss);
663
746
  }
747
+ if (levels.take_profit && currentPrice >= levels.take_profit) {
748
+ push("take_profit_hit", "💰 触及止盈", "价格已达止盈位,建议分批止盈", levels.take_profit);
749
+ }
664
750
  if (levels.breakthrough && currentPrice >= levels.breakthrough) {
665
751
  push("breakthrough_hit", "🚀 突破", "价格已突破关键压力位,可能开启新行情", levels.breakthrough);
666
752
  }
667
- if (levels.support && currentPrice <= levels.support * (1 + buffer)) {
753
+ if (levels.support && isWithinPriceBuffer(currentPrice, levels.support, buffer)) {
668
754
  push("support_near", "📉 触及支撑", "价格接近支撑位,关注是否企稳", levels.support);
669
755
  }
670
- if (levels.resistance && currentPrice >= levels.resistance * (1 - buffer)) {
756
+ if (levels.resistance && isWithinPriceBuffer(currentPrice, levels.resistance, buffer)) {
671
757
  push("resistance_near", "📈 接近压力", "价格接近压力位,关注能否突破", levels.resistance);
672
758
  }
673
- if (levels.take_profit && currentPrice >= levels.take_profit) {
674
- push("take_profit_hit", "💰 触及止盈", "价格已达止盈位,建议分批止盈", levels.take_profit);
675
- }
676
759
  return alerts;
677
760
  }
761
+ function selectPrimaryAlertCandidate(candidates) {
762
+ let best = null;
763
+ let bestPriority = Number.NEGATIVE_INFINITY;
764
+ for (const candidate of candidates) {
765
+ const priority = getAlertPriority(candidate.ruleName);
766
+ if (priority > bestPriority) {
767
+ best = candidate;
768
+ bestPriority = priority;
769
+ }
770
+ }
771
+ return best;
772
+ }
773
+ function getAlertPriority(ruleName) {
774
+ switch (ruleName) {
775
+ case "stop_loss_hit":
776
+ return 600;
777
+ case "take_profit_hit":
778
+ return 500;
779
+ case "breakthrough_hit":
780
+ return 400;
781
+ case "stop_loss_near":
782
+ return 300;
783
+ case "support_near":
784
+ return 200;
785
+ case "resistance_near":
786
+ return 100;
787
+ default:
788
+ return 0;
789
+ }
790
+ }
791
+ function isWithinPriceBuffer(currentPrice, levelPrice, buffer) {
792
+ return currentPrice >= levelPrice * (1 - buffer) && currentPrice <= levelPrice * (1 + buffer);
793
+ }
678
794
  function buildChangeAlert(item, quote, levels, alertService) {
679
795
  const currentPrice = Number(quote.last_price);
680
796
  const prevClose = Number(quote.prev_close ?? 0);
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "tickflow-assist",
3
3
  "name": "TickFlow Assist",
4
- "version": "0.3.2",
4
+ "version": "0.3.3",
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.2",
3
+ "version": "0.3.3",
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",
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",
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.2` 为金十快讯新增夜间静默配置并将默认值改为“开启静默”,同时把个股关联快讯与市场概览快讯接入收盘复盘上下文。完整发布记录见 <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.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",
76
76
  "readmeFilename": "README.md"
77
77
  }