tickflow-assist 0.2.16 → 0.2.18
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 +33 -7
- package/dist/dev/tickflow-assist-cli.js +47 -4
- package/dist/plugin-commands.js +36 -57
- package/dist/services/alert-image-service.js +86 -33
- package/dist/tools/start-monitor.tool.js +37 -3
- package/dist/tools/test-alert.tool.js +4 -2
- package/openclaw.plugin.json +1 -1
- package/package.json +5 -3
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) 获取行情与财务数据,结合 LLM 生成技术面、基本面、资讯面的综合判断,并把结果持久化到本地 LanceDB。
|
|
4
4
|
|
|
5
|
-
最近更新:`v0.2.
|
|
5
|
+
最近更新:`v0.2.18` 调整 PNG 告警卡的日内时间轴与午间衔接逻辑,修复测试图与 demo 图的时间展示不一致;同时修正社区版配置字段说明,并补强 npm 打包脚本对包页 README 元数据的处理。
|
|
6
6
|
|
|
7
7
|
当前主线按 OpenClaw `v2026.3.31+` 对齐。
|
|
8
8
|
|
|
@@ -21,6 +21,7 @@ openclaw gateway restart
|
|
|
21
21
|
|
|
22
22
|
安装阶段允许先落插件,再通过第二条命令写入 `tickflowApiKey`、`llmApiKey` 等正式配置。
|
|
23
23
|
`configure-openclaw` 会写入 `~/.openclaw/openclaw.json` 中的 `plugins.entries["tickflow-assist"].config`,并打印后续建议执行的命令;它不再自动执行 `openclaw`、`uv` 或系统包安装命令。
|
|
24
|
+
如果检测到 `plugins.installs["tickflow-assist"]` 来自 `clawhub`,向导还会把被旧版本钉死的 `spec` 归一化为 `clawhub:tickflow-assist`,避免后续升级继续锁在旧版本。
|
|
24
25
|
|
|
25
26
|
如果你希望先审阅配置,再只打印最少的后续步骤,可使用:
|
|
26
27
|
|
|
@@ -28,7 +29,31 @@ openclaw gateway restart
|
|
|
28
29
|
npx -y tickflow-assist configure-openclaw --no-enable --no-restart
|
|
29
30
|
```
|
|
30
31
|
|
|
31
|
-
如果你在 Linux 上需要 PNG 告警卡正常显示中文,请额外手动安装 `fontconfig` 与 Noto CJK
|
|
32
|
+
如果你在 Linux 或 macOS 上需要 PNG 告警卡正常显示中文,请额外手动安装 `fontconfig` 与 Noto CJK 一类中文字体,例如:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
# Debian / Ubuntu
|
|
36
|
+
sudo apt-get update
|
|
37
|
+
sudo apt-get install -y fontconfig fonts-noto-cjk
|
|
38
|
+
fc-cache -fv
|
|
39
|
+
|
|
40
|
+
# RHEL / Fedora / Rocky / AlmaLinux
|
|
41
|
+
sudo dnf install -y fontconfig google-noto-sans-cjk-ttc-fonts
|
|
42
|
+
fc-cache -fv
|
|
43
|
+
|
|
44
|
+
# Arch / Manjaro
|
|
45
|
+
sudo pacman -Sy --noconfirm fontconfig noto-fonts-cjk
|
|
46
|
+
fc-cache -fv
|
|
47
|
+
|
|
48
|
+
# Alpine
|
|
49
|
+
sudo apk add fontconfig font-noto-cjk
|
|
50
|
+
fc-cache -fv
|
|
51
|
+
|
|
52
|
+
# macOS (Homebrew)
|
|
53
|
+
brew install fontconfig
|
|
54
|
+
brew install --cask font-noto-sans-cjk
|
|
55
|
+
fc-cache -fv
|
|
56
|
+
```
|
|
32
57
|
|
|
33
58
|
社区安装后的升级方式:
|
|
34
59
|
|
|
@@ -51,13 +76,14 @@ openclaw gateway restart
|
|
|
51
76
|
plugins.entries["tickflow-assist"].config
|
|
52
77
|
```
|
|
53
78
|
|
|
54
|
-
|
|
79
|
+
建议按完整功能显式填写以下字段,不要只填 API Key:
|
|
55
80
|
|
|
56
|
-
-
|
|
57
|
-
-
|
|
58
|
-
-
|
|
81
|
+
- 核心运行:`tickflowApiKey`、`llmApiKey`、`llmBaseUrl`、`llmModel`
|
|
82
|
+
- 本地数据:`databasePath`、`calendarFile`
|
|
83
|
+
- 告警投递:`alertChannel`、`alertTarget`、`alertAccount`
|
|
84
|
+
- 能力补充:`mxSearchApiKey`
|
|
59
85
|
|
|
60
|
-
|
|
86
|
+
其中,`mxSearchApiKey` 用于 `mx_search`、`mx_select_stock` 以及非 `Expert` 财务链路的 lite 补充;`alertTarget`、`alertAccount` 建议在准备启用 `test_alert`、实时监控告警和定时通知前一并配好,避免配置不完整导致功能缺失。
|
|
61
87
|
|
|
62
88
|
## 功能
|
|
63
89
|
|
|
@@ -38,7 +38,7 @@ Options:
|
|
|
38
38
|
--no-enable Do not print 'openclaw plugins enable' in next steps
|
|
39
39
|
--no-restart Do not print 'openclaw gateway restart' in next steps
|
|
40
40
|
--no-python-setup Do not print Python dependency setup guidance
|
|
41
|
-
--no-font-setup Do not print
|
|
41
|
+
--no-font-setup Do not print Chinese font setup guidance
|
|
42
42
|
--openclaw-bin <path> OpenClaw CLI binary name used in printed next steps
|
|
43
43
|
--tickflow-api-key <key>
|
|
44
44
|
--tickflow-api-key-level <Free|Start|Pro|Expert>
|
|
@@ -501,6 +501,34 @@ function assertRequired(config) {
|
|
|
501
501
|
throw new Error("llmApiKey is required");
|
|
502
502
|
}
|
|
503
503
|
}
|
|
504
|
+
function normalizeCommunityInstallSpec(root) {
|
|
505
|
+
const plugins = root.plugins;
|
|
506
|
+
if (typeof plugins !== "object" || plugins === null || Array.isArray(plugins)) {
|
|
507
|
+
return false;
|
|
508
|
+
}
|
|
509
|
+
const installs = plugins.installs;
|
|
510
|
+
if (typeof installs !== "object" || installs === null || Array.isArray(installs)) {
|
|
511
|
+
return false;
|
|
512
|
+
}
|
|
513
|
+
const installEntry = installs[PLUGIN_ID];
|
|
514
|
+
if (typeof installEntry !== "object" || installEntry === null || Array.isArray(installEntry)) {
|
|
515
|
+
return false;
|
|
516
|
+
}
|
|
517
|
+
const source = stringValue(installEntry.source).toLowerCase();
|
|
518
|
+
if (source !== "clawhub") {
|
|
519
|
+
return false;
|
|
520
|
+
}
|
|
521
|
+
const canonicalSpec = `clawhub:${PLUGIN_ID}`;
|
|
522
|
+
const currentSpec = stringValue(installEntry.spec);
|
|
523
|
+
if (currentSpec === canonicalSpec) {
|
|
524
|
+
return false;
|
|
525
|
+
}
|
|
526
|
+
if (!currentSpec || currentSpec.startsWith(`${canonicalSpec}@`)) {
|
|
527
|
+
installEntry.spec = canonicalSpec;
|
|
528
|
+
return true;
|
|
529
|
+
}
|
|
530
|
+
return false;
|
|
531
|
+
}
|
|
504
532
|
async function ensurePathNotice(targetPath, label) {
|
|
505
533
|
try {
|
|
506
534
|
await access(targetPath);
|
|
@@ -644,6 +672,13 @@ function getManualFontCommands(distro) {
|
|
|
644
672
|
];
|
|
645
673
|
}
|
|
646
674
|
}
|
|
675
|
+
function getManualMacosFontCommands() {
|
|
676
|
+
return [
|
|
677
|
+
"brew install fontconfig",
|
|
678
|
+
"brew install --cask font-noto-sans-cjk",
|
|
679
|
+
"fc-cache -fv",
|
|
680
|
+
];
|
|
681
|
+
}
|
|
647
682
|
function printNextSteps(options, config) {
|
|
648
683
|
console.log("");
|
|
649
684
|
console.log("接下来的命令需要你手动执行。");
|
|
@@ -654,9 +689,13 @@ function printNextSteps(options, config) {
|
|
|
654
689
|
console.log(" uv sync");
|
|
655
690
|
step += 1;
|
|
656
691
|
}
|
|
657
|
-
if (options.fontSetup && process.platform === "linux") {
|
|
658
|
-
|
|
659
|
-
|
|
692
|
+
if (options.fontSetup && (process.platform === "linux" || process.platform === "darwin")) {
|
|
693
|
+
const commands = process.platform === "darwin"
|
|
694
|
+
? getManualMacosFontCommands()
|
|
695
|
+
: getManualFontCommands(detectLinuxDistro());
|
|
696
|
+
const platformLabel = process.platform === "darwin" ? "macOS" : "Linux 发行版";
|
|
697
|
+
console.log(`${step}. 如需 PNG 告警卡正常显示中文,请按你的 ${platformLabel} 安装字体`);
|
|
698
|
+
for (const command of commands) {
|
|
660
699
|
console.log(` ${command}`);
|
|
661
700
|
}
|
|
662
701
|
step += 1;
|
|
@@ -685,6 +724,7 @@ async function configureOpenClaw(options) {
|
|
|
685
724
|
await ensurePathNotice(config.calendarFile, "calendarFile");
|
|
686
725
|
await ensurePathNotice(config.pythonWorkdir, "pythonWorkdir");
|
|
687
726
|
applyPluginConfig(root, config, target);
|
|
727
|
+
const normalizedInstallSpec = normalizeCommunityInstallSpec(root);
|
|
688
728
|
const backupPath = await writeConfig(configPath, root);
|
|
689
729
|
console.log("");
|
|
690
730
|
console.log(`Updated OpenClaw config: ${configPath}`);
|
|
@@ -693,6 +733,9 @@ async function configureOpenClaw(options) {
|
|
|
693
733
|
}
|
|
694
734
|
console.log(`Plugin dir: ${pluginDir}`);
|
|
695
735
|
console.log(`Allowlist target: ${target.type === "global" ? "global tools" : `agent:${target.id}`}`);
|
|
736
|
+
if (normalizedInstallSpec) {
|
|
737
|
+
console.log(`Community install spec normalized: clawhub:${PLUGIN_ID}`);
|
|
738
|
+
}
|
|
696
739
|
printNextSteps(options, config);
|
|
697
740
|
}
|
|
698
741
|
async function main() {
|
package/dist/plugin-commands.js
CHANGED
|
@@ -36,6 +36,19 @@ function parseRequiredSymbol(args, usage) {
|
|
|
36
36
|
async function runToolText(tool, rawInput) {
|
|
37
37
|
return tool.run({ rawInput });
|
|
38
38
|
}
|
|
39
|
+
function formatCommandError(error) {
|
|
40
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
41
|
+
const normalized = message.trim() || "未知错误";
|
|
42
|
+
return `⚠️ ${normalized}`;
|
|
43
|
+
}
|
|
44
|
+
async function runCommandText(task) {
|
|
45
|
+
try {
|
|
46
|
+
return { text: await task() };
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
return { text: formatCommandError(error) };
|
|
50
|
+
}
|
|
51
|
+
}
|
|
39
52
|
async function renderWatchlistDebug(app) {
|
|
40
53
|
const snapshot = await buildWatchlistDebugSnapshot(app);
|
|
41
54
|
const lines = [
|
|
@@ -79,148 +92,114 @@ export function registerPluginCommands(api, tools, app) {
|
|
|
79
92
|
description: "添加自选股,不经过 AI 对话。用法: /ta_addstock <symbol> [costPrice] [count]",
|
|
80
93
|
acceptsArgs: true,
|
|
81
94
|
requireAuth: true,
|
|
82
|
-
handler: async ({ args }) => (
|
|
83
|
-
text: await runToolText(addStock, parseAddStockArgs(args)),
|
|
84
|
-
}),
|
|
95
|
+
handler: async ({ args }) => runCommandText(() => runToolText(addStock, parseAddStockArgs(args))),
|
|
85
96
|
},
|
|
86
97
|
{
|
|
87
98
|
name: "ta_rmstock",
|
|
88
99
|
description: "删除自选股,不经过 AI 对话。用法: /ta_rmstock <symbol>",
|
|
89
100
|
acceptsArgs: true,
|
|
90
101
|
requireAuth: true,
|
|
91
|
-
handler: async ({ args }) => ({
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
}),
|
|
95
|
-
}),
|
|
102
|
+
handler: async ({ args }) => runCommandText(() => runToolText(removeStock, {
|
|
103
|
+
symbol: parseRequiredSymbol(args, "/ta_rmstock <symbol>"),
|
|
104
|
+
})),
|
|
96
105
|
},
|
|
97
106
|
{
|
|
98
107
|
name: "ta_analyze",
|
|
99
108
|
description: "分析单只股票,不经过 AI 对话。用法: /ta_analyze <symbol>",
|
|
100
109
|
acceptsArgs: true,
|
|
101
110
|
requireAuth: true,
|
|
102
|
-
handler: async ({ args }) => ({
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
}),
|
|
106
|
-
}),
|
|
111
|
+
handler: async ({ args }) => runCommandText(() => runToolText(analyze, {
|
|
112
|
+
symbol: parseRequiredSymbol(args, "/ta_analyze <symbol>"),
|
|
113
|
+
})),
|
|
107
114
|
},
|
|
108
115
|
{
|
|
109
116
|
name: "ta_backtest",
|
|
110
117
|
description: "回测活动价位,不经过 AI 对话。用法: /ta_backtest [symbol] [recentLimit]",
|
|
111
118
|
acceptsArgs: true,
|
|
112
119
|
requireAuth: true,
|
|
113
|
-
handler: async ({ args }) => (
|
|
114
|
-
text: await runToolText(backtestKeyLevels, args?.trim() || undefined),
|
|
115
|
-
}),
|
|
120
|
+
handler: async ({ args }) => runCommandText(() => runToolText(backtestKeyLevels, args?.trim() || undefined)),
|
|
116
121
|
},
|
|
117
122
|
{
|
|
118
123
|
name: "ta_viewanalysis",
|
|
119
124
|
description: "查看单只股票最近一次保存的分析结果,不经过 AI 对话。用法: /ta_viewanalysis <symbol>",
|
|
120
125
|
acceptsArgs: true,
|
|
121
126
|
requireAuth: true,
|
|
122
|
-
handler: async ({ args }) => ({
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
}),
|
|
126
|
-
}),
|
|
127
|
+
handler: async ({ args }) => runCommandText(() => runToolText(viewAnalysis, {
|
|
128
|
+
symbol: parseRequiredSymbol(args, "/ta_viewanalysis <symbol>"),
|
|
129
|
+
})),
|
|
127
130
|
},
|
|
128
131
|
{
|
|
129
132
|
name: "ta_watchlist",
|
|
130
133
|
description: "查看当前自选列表,不经过 AI 对话。",
|
|
131
134
|
requireAuth: true,
|
|
132
|
-
handler: async () => (
|
|
133
|
-
text: await runToolText(listWatchlist),
|
|
134
|
-
}),
|
|
135
|
+
handler: async () => runCommandText(() => runToolText(listWatchlist)),
|
|
135
136
|
},
|
|
136
137
|
{
|
|
137
138
|
name: "ta_refreshnames",
|
|
138
139
|
description: "刷新自选股名称,不经过 AI 对话。",
|
|
139
140
|
requireAuth: true,
|
|
140
|
-
handler: async () => (
|
|
141
|
-
text: await runToolText(refreshWatchlistNames),
|
|
142
|
-
}),
|
|
141
|
+
handler: async () => runCommandText(() => runToolText(refreshWatchlistNames)),
|
|
143
142
|
},
|
|
144
143
|
{
|
|
145
144
|
name: "ta_refreshprofiles",
|
|
146
145
|
description: "刷新自选股行业分类与概念板块,不经过 AI 对话。用法: /ta_refreshprofiles [symbol]",
|
|
147
146
|
acceptsArgs: true,
|
|
148
147
|
requireAuth: true,
|
|
149
|
-
handler: async ({ args }) => (
|
|
150
|
-
text: await runToolText(refreshWatchlistProfiles, args?.trim() || undefined),
|
|
151
|
-
}),
|
|
148
|
+
handler: async ({ args }) => runCommandText(() => runToolText(refreshWatchlistProfiles, args?.trim() || undefined)),
|
|
152
149
|
},
|
|
153
150
|
{
|
|
154
151
|
name: "ta_startmonitor",
|
|
155
152
|
description: "启动实时监控,不经过 AI 对话。",
|
|
156
153
|
requireAuth: true,
|
|
157
|
-
handler: async () => (
|
|
158
|
-
text: await runToolText(startMonitor),
|
|
159
|
-
}),
|
|
154
|
+
handler: async () => runCommandText(() => runToolText(startMonitor)),
|
|
160
155
|
},
|
|
161
156
|
{
|
|
162
157
|
name: "ta_stopmonitor",
|
|
163
158
|
description: "停止实时监控,不经过 AI 对话。",
|
|
164
159
|
requireAuth: true,
|
|
165
|
-
handler: async () => (
|
|
166
|
-
text: await runToolText(stopMonitor),
|
|
167
|
-
}),
|
|
160
|
+
handler: async () => runCommandText(() => runToolText(stopMonitor)),
|
|
168
161
|
},
|
|
169
162
|
{
|
|
170
163
|
name: "ta_monitorstatus",
|
|
171
164
|
description: "查看实时监控状态,不经过 AI 对话。",
|
|
172
165
|
requireAuth: true,
|
|
173
|
-
handler: async () => (
|
|
174
|
-
text: await runToolText(monitorStatus),
|
|
175
|
-
}),
|
|
166
|
+
handler: async () => runCommandText(() => runToolText(monitorStatus)),
|
|
176
167
|
},
|
|
177
168
|
{
|
|
178
169
|
name: "ta_startdailyupdate",
|
|
179
170
|
description: "启动定时日更任务,不经过 AI 对话。",
|
|
180
171
|
requireAuth: true,
|
|
181
|
-
handler: async () => (
|
|
182
|
-
text: await runToolText(startDailyUpdate),
|
|
183
|
-
}),
|
|
172
|
+
handler: async () => runCommandText(() => runToolText(startDailyUpdate)),
|
|
184
173
|
},
|
|
185
174
|
{
|
|
186
175
|
name: "ta_stopdailyupdate",
|
|
187
176
|
description: "停止定时日更任务,不经过 AI 对话。",
|
|
188
177
|
requireAuth: true,
|
|
189
|
-
handler: async () => (
|
|
190
|
-
text: await runToolText(stopDailyUpdate),
|
|
191
|
-
}),
|
|
178
|
+
handler: async () => runCommandText(() => runToolText(stopDailyUpdate)),
|
|
192
179
|
},
|
|
193
180
|
{
|
|
194
181
|
name: "ta_updateall",
|
|
195
182
|
description: "立即执行一次完整日更,不经过 AI 对话。",
|
|
196
183
|
requireAuth: true,
|
|
197
|
-
handler: async () => (
|
|
198
|
-
text: await runToolText(updateAll),
|
|
199
|
-
}),
|
|
184
|
+
handler: async () => runCommandText(() => runToolText(updateAll)),
|
|
200
185
|
},
|
|
201
186
|
{
|
|
202
187
|
name: "ta_dailyupdatestatus",
|
|
203
188
|
description: "查看定时日更状态,不经过 AI 对话。",
|
|
204
189
|
requireAuth: true,
|
|
205
|
-
handler: async () => (
|
|
206
|
-
text: await runToolText(dailyUpdateStatus),
|
|
207
|
-
}),
|
|
190
|
+
handler: async () => runCommandText(() => runToolText(dailyUpdateStatus)),
|
|
208
191
|
},
|
|
209
192
|
{
|
|
210
193
|
name: "ta_testalert",
|
|
211
194
|
description: "发送一条文本 + PNG 测试告警,不经过 AI 对话。",
|
|
212
195
|
requireAuth: true,
|
|
213
|
-
handler: async () => (
|
|
214
|
-
text: await runToolText(testAlert),
|
|
215
|
-
}),
|
|
196
|
+
handler: async () => runCommandText(() => runToolText(testAlert)),
|
|
216
197
|
},
|
|
217
198
|
{
|
|
218
199
|
name: "ta_debug",
|
|
219
200
|
description: "查看 TickFlow 插件运行时调试信息。",
|
|
220
201
|
requireAuth: true,
|
|
221
|
-
handler: async () => (
|
|
222
|
-
text: await renderWatchlistDebug(app),
|
|
223
|
-
}),
|
|
202
|
+
handler: async () => runCommandText(() => renderWatchlistDebug(app)),
|
|
224
203
|
},
|
|
225
204
|
];
|
|
226
205
|
for (const command of commands) {
|
|
@@ -1,10 +1,19 @@
|
|
|
1
1
|
import sharp from "sharp";
|
|
2
2
|
const WIDTH = 960;
|
|
3
3
|
const HEIGHT = 640;
|
|
4
|
+
const MARKET_OPEN_MINUTES = 9 * 60 + 30;
|
|
5
|
+
const MORNING_CLOSE_MINUTES = 11 * 60 + 30;
|
|
6
|
+
const AFTERNOON_OPEN_MINUTES = 13 * 60;
|
|
7
|
+
const MARKET_CLOSE_MINUTES = 15 * 60;
|
|
8
|
+
const MARKET_SESSION_MINUTES = (MORNING_CLOSE_MINUTES - MARKET_OPEN_MINUTES) + (MARKET_CLOSE_MINUTES - AFTERNOON_OPEN_MINUTES);
|
|
4
9
|
export function renderAlertCardSvg(input) {
|
|
5
10
|
if (input.points.length < 2) {
|
|
6
11
|
throw new Error("alert image requires at least 2 points");
|
|
7
12
|
}
|
|
13
|
+
const chartPoints = normalizeChartPoints(input.points);
|
|
14
|
+
if (chartPoints.length < 2) {
|
|
15
|
+
throw new Error("alert image requires at least 2 chart points");
|
|
16
|
+
}
|
|
8
17
|
const theme = resolveTheme(input.tone);
|
|
9
18
|
const direction = resolveMarketDirection(input);
|
|
10
19
|
const directionTheme = resolveDirectionTheme(direction);
|
|
@@ -38,7 +47,7 @@ export function renderAlertCardSvg(input) {
|
|
|
38
47
|
const priceValues = [
|
|
39
48
|
input.currentPrice,
|
|
40
49
|
input.triggerPrice,
|
|
41
|
-
...
|
|
50
|
+
...chartPoints.map((point) => point.price),
|
|
42
51
|
input.levels.stopLoss ?? null,
|
|
43
52
|
input.levels.support ?? null,
|
|
44
53
|
input.levels.resistance ?? null,
|
|
@@ -51,19 +60,18 @@ export function renderAlertCardSvg(input) {
|
|
|
51
60
|
const scaledMin = minValue - padding;
|
|
52
61
|
const scaledMax = maxValue + padding;
|
|
53
62
|
const valueRange = Math.max(0.01, scaledMax - scaledMin);
|
|
54
|
-
const scaleX = (
|
|
55
|
-
if (input.points.length === 1) {
|
|
56
|
-
return chart.left;
|
|
57
|
-
}
|
|
58
|
-
return chart.left + (index / (input.points.length - 1)) * chart.width;
|
|
59
|
-
};
|
|
63
|
+
const scaleX = (timeLabel) => scaleTradingTime(timeLabel, chart.left, chart.width);
|
|
60
64
|
const scaleY = (value) => (chart.top + ((scaledMax - value) / valueRange) * chart.height);
|
|
61
|
-
const
|
|
62
|
-
|
|
65
|
+
const firstPoint = chartPoints[0];
|
|
66
|
+
const lastPoint = chartPoints[chartPoints.length - 1];
|
|
67
|
+
const firstX = scaleX(firstPoint.time);
|
|
68
|
+
const currentX = scaleX(lastPoint.time);
|
|
69
|
+
const currentY = scaleY(lastPoint.price);
|
|
70
|
+
const sessionJoinX = scaleX("11:30");
|
|
71
|
+
const linePath = chartPoints
|
|
72
|
+
.map((point, index) => `${index === 0 ? "M" : "L"} ${scaleX(point.time).toFixed(2)} ${scaleY(point.price).toFixed(2)}`)
|
|
63
73
|
.join(" ");
|
|
64
|
-
const areaPath = `${linePath} L ${
|
|
65
|
-
const currentX = scaleX(input.points.length - 1);
|
|
66
|
-
const currentY = scaleY(input.points[input.points.length - 1]?.price ?? input.currentPrice);
|
|
74
|
+
const areaPath = `${linePath} L ${currentX.toFixed(2)} ${(chart.top + chart.height).toFixed(2)} L ${firstX.toFixed(2)} ${(chart.top + chart.height).toFixed(2)} Z`;
|
|
67
75
|
const horizontalGrid = Array.from({ length: 5 }, (_, index) => {
|
|
68
76
|
const y = chart.top + (index / 4) * chart.height;
|
|
69
77
|
const value = scaledMax - (index / 4) * valueRange;
|
|
@@ -72,7 +80,7 @@ export function renderAlertCardSvg(input) {
|
|
|
72
80
|
value,
|
|
73
81
|
};
|
|
74
82
|
});
|
|
75
|
-
const timeMarkers = buildTimeMarkers(
|
|
83
|
+
const timeMarkers = buildTimeMarkers(chartPoints, scaleX);
|
|
76
84
|
const levelEntries = buildLevelEntries(input);
|
|
77
85
|
const levelPanelEntries = [...levelEntries].sort((left, right) => right.value - left.value);
|
|
78
86
|
const levelLines = buildLevelLines(levelEntries, scaleY);
|
|
@@ -123,6 +131,7 @@ export function renderAlertCardSvg(input) {
|
|
|
123
131
|
<text x="718" y="${180 + index * 18}" fill="#A8BED7" font-size="14" font-family="'Noto Sans CJK SC','Microsoft YaHei','PingFang SC',sans-serif">${escapeXml(line)}</text>`).join("")}
|
|
124
132
|
|
|
125
133
|
<rect x="${chart.left}" y="${chart.top}" width="${chart.width}" height="${chart.height}" rx="18" fill="${directionTheme.chartPanelFill}" stroke="${theme.panelBorder}" stroke-opacity="0.9"/>
|
|
134
|
+
<line x1="${sessionJoinX.toFixed(2)}" y1="${chart.top + 10}" x2="${sessionJoinX.toFixed(2)}" y2="${chart.top + chart.height - 10}" stroke="#3B4F68" stroke-opacity="0.8" stroke-dasharray="3 8"/>
|
|
126
135
|
${horizontalGrid.map((line) => `
|
|
127
136
|
<line x1="${chart.left}" y1="${line.y.toFixed(2)}" x2="${chart.left + chart.width}" y2="${line.y.toFixed(2)}" stroke="#213247" stroke-dasharray="4 8"/>
|
|
128
137
|
<text x="${chart.left + 12}" y="${(line.y - 8).toFixed(2)}" fill="#6E88A5" font-size="12" font-family="'JetBrains Mono','SFMono-Regular','Consolas',monospace">${line.value.toFixed(2)}</text>`).join("")}
|
|
@@ -323,26 +332,18 @@ function buildLevelEntry(label, value, stroke, fill, text, width, dasharray) {
|
|
|
323
332
|
label,
|
|
324
333
|
};
|
|
325
334
|
}
|
|
326
|
-
function buildTimeMarkers(
|
|
327
|
-
const preferred = [
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
}
|
|
338
|
-
if (markers.length >= 3) {
|
|
339
|
-
return filterNearbyMarkers(markers, 56);
|
|
340
|
-
}
|
|
341
|
-
return filterNearbyMarkers([
|
|
342
|
-
{ x: scaleX(0), label: points[0]?.time.slice(0, 5) ?? "" },
|
|
343
|
-
{ x: scaleX(Math.floor((points.length - 1) / 2)), label: points[Math.floor((points.length - 1) / 2)]?.time.slice(0, 5) ?? "" },
|
|
344
|
-
{ x: scaleX(points.length - 1), label: points[points.length - 1]?.time.slice(0, 5) ?? "" },
|
|
345
|
-
], 56);
|
|
335
|
+
function buildTimeMarkers(_points, scaleX) {
|
|
336
|
+
const preferred = [
|
|
337
|
+
{ timeLabel: "09:30", label: "09:30" },
|
|
338
|
+
{ timeLabel: "10:30", label: "10:30" },
|
|
339
|
+
{ timeLabel: "11:30", label: "11:30/13:00" },
|
|
340
|
+
{ timeLabel: "14:00", label: "14:00" },
|
|
341
|
+
{ timeLabel: "15:00", label: "15:00" },
|
|
342
|
+
];
|
|
343
|
+
return filterNearbyMarkers(preferred.map((marker) => ({
|
|
344
|
+
x: scaleX(marker.timeLabel),
|
|
345
|
+
label: marker.label,
|
|
346
|
+
})), 56);
|
|
346
347
|
}
|
|
347
348
|
function buildRailMarkers(input, left, width, top, minValue, maxValue) {
|
|
348
349
|
const range = Math.max(0.01, maxValue - minValue);
|
|
@@ -433,3 +434,55 @@ function filterNearbyMarkers(markers, minGap) {
|
|
|
433
434
|
}
|
|
434
435
|
return filtered;
|
|
435
436
|
}
|
|
437
|
+
function scaleTradingTime(timeLabel, left, width) {
|
|
438
|
+
const minutes = parseClockMinutes(timeLabel);
|
|
439
|
+
if (minutes == null) {
|
|
440
|
+
return left;
|
|
441
|
+
}
|
|
442
|
+
const clamped = clamp(minutes, MARKET_OPEN_MINUTES, MARKET_CLOSE_MINUTES);
|
|
443
|
+
return left + (toTradingSessionMinutes(clamped) / MARKET_SESSION_MINUTES) * width;
|
|
444
|
+
}
|
|
445
|
+
function normalizeChartPoints(points) {
|
|
446
|
+
const normalized = [];
|
|
447
|
+
for (const point of points) {
|
|
448
|
+
const previous = normalized[normalized.length - 1];
|
|
449
|
+
if (previous && isSessionJoinPair(previous.time, point.time)) {
|
|
450
|
+
normalized.push({
|
|
451
|
+
time: point.time,
|
|
452
|
+
price: previous.price,
|
|
453
|
+
});
|
|
454
|
+
continue;
|
|
455
|
+
}
|
|
456
|
+
normalized.push(point);
|
|
457
|
+
}
|
|
458
|
+
return normalized;
|
|
459
|
+
}
|
|
460
|
+
function parseClockMinutes(value) {
|
|
461
|
+
const match = value.trim().match(/^(\d{1,2}):(\d{2})/);
|
|
462
|
+
if (!match) {
|
|
463
|
+
return null;
|
|
464
|
+
}
|
|
465
|
+
const hour = Number(match[1]);
|
|
466
|
+
const minute = Number(match[2]);
|
|
467
|
+
if (!Number.isInteger(hour) || !Number.isInteger(minute) || minute < 0 || minute > 59) {
|
|
468
|
+
return null;
|
|
469
|
+
}
|
|
470
|
+
return hour * 60 + minute;
|
|
471
|
+
}
|
|
472
|
+
function clamp(value, min, max) {
|
|
473
|
+
return Math.max(min, Math.min(max, value));
|
|
474
|
+
}
|
|
475
|
+
function toTradingSessionMinutes(minutes) {
|
|
476
|
+
if (minutes <= MORNING_CLOSE_MINUTES) {
|
|
477
|
+
return minutes - MARKET_OPEN_MINUTES;
|
|
478
|
+
}
|
|
479
|
+
if (minutes < AFTERNOON_OPEN_MINUTES) {
|
|
480
|
+
return MORNING_CLOSE_MINUTES - MARKET_OPEN_MINUTES;
|
|
481
|
+
}
|
|
482
|
+
return ((MORNING_CLOSE_MINUTES - MARKET_OPEN_MINUTES)
|
|
483
|
+
+ (minutes - AFTERNOON_OPEN_MINUTES));
|
|
484
|
+
}
|
|
485
|
+
function isSessionJoinPair(previousTime, nextTime) {
|
|
486
|
+
return parseClockMinutes(previousTime) === MORNING_CLOSE_MINUTES
|
|
487
|
+
&& parseClockMinutes(nextTime) === AFTERNOON_OPEN_MINUTES;
|
|
488
|
+
}
|
|
@@ -5,9 +5,15 @@ export function startMonitorTool(monitorService, runtime) {
|
|
|
5
5
|
optional: true,
|
|
6
6
|
async run() {
|
|
7
7
|
if (runtime.pluginManagedServices) {
|
|
8
|
-
|
|
8
|
+
let result;
|
|
9
|
+
try {
|
|
10
|
+
result = await monitorService.enableManagedLoop();
|
|
11
|
+
}
|
|
12
|
+
catch (error) {
|
|
13
|
+
return formatManagedStartError(error);
|
|
14
|
+
}
|
|
9
15
|
if (!result.started) {
|
|
10
|
-
return await monitorService
|
|
16
|
+
return await buildManagedAlreadyRunningSummary(monitorService);
|
|
11
17
|
}
|
|
12
18
|
return [
|
|
13
19
|
"✅ TickFlow 实时监控已启动",
|
|
@@ -20,7 +26,12 @@ export function startMonitorTool(monitorService, runtime) {
|
|
|
20
26
|
if (currentState.running
|
|
21
27
|
&& currentState.workerPid != null
|
|
22
28
|
&& isPidAlive(currentState.workerPid)) {
|
|
23
|
-
return
|
|
29
|
+
return [
|
|
30
|
+
"✅ TickFlow 实时监控已在运行",
|
|
31
|
+
"运行方式: manual_loop",
|
|
32
|
+
`PID: ${currentState.workerPid}`,
|
|
33
|
+
"说明: 本地监控循环已存在,无需重复启动。",
|
|
34
|
+
].join("\n");
|
|
24
35
|
}
|
|
25
36
|
const summary = await monitorService.start();
|
|
26
37
|
return [
|
|
@@ -31,6 +42,29 @@ export function startMonitorTool(monitorService, runtime) {
|
|
|
31
42
|
},
|
|
32
43
|
};
|
|
33
44
|
}
|
|
45
|
+
async function buildManagedAlreadyRunningSummary(monitorService) {
|
|
46
|
+
const state = await monitorService.getState();
|
|
47
|
+
return [
|
|
48
|
+
"✅ TickFlow 实时监控已在运行",
|
|
49
|
+
"运行方式: plugin_service",
|
|
50
|
+
`最近心跳: ${state.lastHeartbeatAt ?? "暂无"}`,
|
|
51
|
+
"说明: 后台服务按配置间隔轮询,交易时段自动执行监控。",
|
|
52
|
+
].join("\n");
|
|
53
|
+
}
|
|
54
|
+
function formatManagedStartError(error) {
|
|
55
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
56
|
+
if (message.includes("关注列表为空")) {
|
|
57
|
+
return [
|
|
58
|
+
"⚠️ 无法启动实时监控",
|
|
59
|
+
"原因: 关注列表为空,请先添加至少一只自选股。",
|
|
60
|
+
"示例: /ta_addstock 000001 10.50",
|
|
61
|
+
].join("\n");
|
|
62
|
+
}
|
|
63
|
+
return [
|
|
64
|
+
"⚠️ 启动实时监控失败",
|
|
65
|
+
`原因: ${message}`,
|
|
66
|
+
].join("\n");
|
|
67
|
+
}
|
|
34
68
|
function isPidAlive(pid) {
|
|
35
69
|
try {
|
|
36
70
|
process.kill(pid, 0);
|
|
@@ -81,6 +81,7 @@ export function testAlertTool(alertService, alertMediaService, configSource = "o
|
|
|
81
81
|
};
|
|
82
82
|
}
|
|
83
83
|
function buildTestAlertImage(now) {
|
|
84
|
+
const previewTime = `${now.slice(0, 10)} 14:12`;
|
|
84
85
|
const triggerPrice = 12.18;
|
|
85
86
|
const currentPrice = 12.36;
|
|
86
87
|
return {
|
|
@@ -88,7 +89,7 @@ function buildTestAlertImage(now) {
|
|
|
88
89
|
alertLabel: "测试告警",
|
|
89
90
|
name: "平安银行",
|
|
90
91
|
symbol: "000001.SZ",
|
|
91
|
-
timestampLabel: `测试告警 | ${
|
|
92
|
+
timestampLabel: `测试告警 | ${previewTime}`,
|
|
92
93
|
currentPrice,
|
|
93
94
|
triggerPrice,
|
|
94
95
|
changePct: 2.15,
|
|
@@ -102,8 +103,9 @@ function buildTestAlertImage(now) {
|
|
|
102
103
|
{ time: "10:30", price: 12.12 },
|
|
103
104
|
{ time: "11:30", price: 12.15 },
|
|
104
105
|
{ time: "13:00", price: 12.19 },
|
|
106
|
+
{ time: "13:30", price: 12.23 },
|
|
105
107
|
{ time: "14:00", price: 12.27 },
|
|
106
|
-
{ time: "
|
|
108
|
+
{ time: "14:12", price: currentPrice },
|
|
107
109
|
],
|
|
108
110
|
levels: {
|
|
109
111
|
stopLoss: 11.86,
|
package/openclaw.plugin.json
CHANGED
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tickflow-assist",
|
|
3
|
-
"version": "0.2.
|
|
4
|
-
"description": "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.
|
|
3
|
+
"version": "0.2.18",
|
|
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",
|
|
7
7
|
"main": "dist/plugin.js",
|
|
@@ -63,5 +63,7 @@
|
|
|
63
63
|
"@types/node": "^22.13.11",
|
|
64
64
|
"openclaw": "^2026.3.31",
|
|
65
65
|
"typescript": "^5.8.2"
|
|
66
|
-
}
|
|
66
|
+
},
|
|
67
|
+
"readme": "# TickFlow Assist\n\n基于 [OpenClaw](https://openclaw.ai) 的 A 股监控与分析插件。它使用 [TickFlow](https://tickflow.org/auth/register?ref=BUJ54JEDGE) 获取行情与财务数据,结合 LLM 生成技术面、基本面、资讯面的综合判断,并把结果持久化到本地 LanceDB。\n\n最近更新:`v0.2.18` 调整 PNG 告警卡的日内时间轴与午间衔接逻辑,修复测试图与 demo 图的时间展示不一致;同时修正社区版配置字段说明,并补强 npm 打包脚本对包页 README 元数据的处理。\n\n当前主线按 OpenClaw `v2026.3.31+` 对齐。\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`\n\n其中,`mxSearchApiKey` 用于 `mx_search`、`mx_select_stock` 以及非 `Expert` 财务链路的 lite 补充;`alertTarget`、`alertAccount` 建议在准备启用 `test_alert`、实时监控告警和定时通知前一并配好,避免配置不完整导致功能缺失。\n\n## 功能\n\n- 自选股管理、日 K / 分钟 K 抓取与指标计算\n- 技术面、财务面、资讯面的综合分析\n- 实时监控、定时日更、收盘后复盘\n- 本地 LanceDB 数据留痕与分析结果查看\n\n## 运行说明\n\n- 插件会在本地 `databasePath` 下持久化 LanceDB 数据。\n- 后台服务会按配置执行定时日更与实时监控。\n- Python 子模块仅用于技术指标计算,不承担主业务流程。\n\n## 仓库\n\n- GitHub: <https://github.com/robinspt/tickflow-assist>\n",
|
|
68
|
+
"readmeFilename": "README.md"
|
|
67
69
|
}
|