tickflow-assist 0.3.5 → 0.3.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -40
- package/dist/analysis/types/composite-analysis.d.ts +27 -0
- package/dist/bootstrap.js +19 -5
- package/dist/config/tickflow-access.d.ts +2 -1
- package/dist/config/tickflow-access.js +10 -3
- package/dist/dev/tickflow-assist-cli.js +4 -3
- package/dist/dev/validate-mx-search.js +10 -2
- package/dist/plugin.js +4 -6
- package/dist/prompts/analysis/kline-analysis-user-prompt.js +2 -1
- package/dist/prompts/analysis/post-close-review-user-prompt.js +40 -1
- package/dist/prompts/analysis/pre-market-brief-prompt.d.ts +3 -1
- package/dist/prompts/analysis/pre-market-brief-prompt.js +5 -1
- package/dist/services/alert-service.d.ts +8 -0
- package/dist/services/alert-service.js +327 -45
- package/dist/services/industry-peer-service.d.ts +9 -0
- package/dist/services/industry-peer-service.js +152 -0
- package/dist/services/jin10-flash-monitor-service.js +2 -1
- package/dist/services/monitor-service.d.ts +4 -1
- package/dist/services/monitor-service.js +51 -20
- package/dist/services/post-close-review-service.d.ts +11 -4
- package/dist/services/post-close-review-service.js +113 -10
- package/dist/services/pre-market-brief-service.js +165 -11
- package/dist/services/tickflow-client.d.ts +4 -1
- package/dist/services/tickflow-client.js +32 -0
- package/dist/services/tickflow-universe-service.d.ts +26 -0
- package/dist/services/tickflow-universe-service.js +213 -0
- package/dist/services/watchlist-profile-service.d.ts +4 -1
- package/dist/services/watchlist-profile-service.js +58 -29
- package/dist/services/watchlist-service.js +1 -1
- package/dist/storage/repositories/universe-membership-repo.d.ts +11 -0
- package/dist/storage/repositories/universe-membership-repo.js +38 -0
- package/dist/storage/repositories/universe-repo.d.ts +17 -0
- package/dist/storage/repositories/universe-repo.js +62 -0
- package/dist/storage/schemas.d.ts +2 -0
- package/dist/storage/schemas.js +13 -0
- package/dist/tools/add-stock.tool.d.ts +2 -1
- package/dist/tools/add-stock.tool.js +10 -1
- package/dist/tools/query-database.tool.js +6 -0
- package/dist/tools/refresh-watchlist-profiles.tool.d.ts +2 -1
- package/dist/tools/refresh-watchlist-profiles.tool.js +11 -1
- package/dist/tools/test-alert.tool.js +58 -16
- package/dist/types/tickflow.d.ts +12 -0
- package/dist/utils/alert-diagnostic-log.d.ts +12 -0
- package/dist/utils/alert-diagnostic-log.js +60 -0
- package/dist/utils/tickflow-quote.d.ts +5 -0
- package/dist/utils/tickflow-quote.js +31 -0
- package/openclaw.plugin.json +108 -2
- package/package.json +10 -5
- package/skills/stock-analysis/SKILL.md +9 -20
|
@@ -1,4 +1,9 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { copyFile, mkdir, unlink } from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
1
5
|
import { createCommandRunner } from "../runtime/command-runner.js";
|
|
6
|
+
import { basenameOrUndefined, buildAlertMessageHash, buildAlertSendId, truncateDiagnosticText, } from "../utils/alert-diagnostic-log.js";
|
|
2
7
|
import { calculateProfitPct, formatCostPrice } from "../utils/cost-price.js";
|
|
3
8
|
export class AlertService {
|
|
4
9
|
options;
|
|
@@ -16,55 +21,84 @@ export class AlertService {
|
|
|
16
21
|
async sendWithResult(input) {
|
|
17
22
|
this.lastError = null;
|
|
18
23
|
const payload = normalizeSendInput(input);
|
|
24
|
+
const sendId = buildAlertSendId(payload.message);
|
|
25
|
+
const messageHash = buildAlertMessageHash(payload.message);
|
|
26
|
+
const diagnosticContext = {
|
|
27
|
+
sendId,
|
|
28
|
+
step: "primary",
|
|
29
|
+
messageHash,
|
|
30
|
+
};
|
|
19
31
|
const mediaAttempted = Boolean(payload.mediaPath);
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
32
|
+
await this.logDiagnostic("send_started", {
|
|
33
|
+
sendId,
|
|
34
|
+
step: diagnosticContext.step,
|
|
35
|
+
channel: this.channel,
|
|
36
|
+
messageHash,
|
|
37
|
+
messageLength: payload.message.length,
|
|
38
|
+
hasMedia: mediaAttempted,
|
|
39
|
+
mediaFile: basenameOrUndefined(payload.mediaPath),
|
|
40
|
+
filename: payload.filename,
|
|
41
|
+
accountConfigured: Boolean(this.options.account.trim()),
|
|
42
|
+
targetConfigured: Boolean(this.options.target.trim()),
|
|
43
|
+
runtimeAvailable: Boolean(this.options.runtime),
|
|
44
|
+
messagePreview: truncateDiagnosticText(payload.message.split("\n")[0] ?? ""),
|
|
45
|
+
});
|
|
46
|
+
const primaryFailure = await this.trySendPayload(payload, diagnosticContext);
|
|
47
|
+
if (primaryFailure === null) {
|
|
48
|
+
const result = {
|
|
23
49
|
ok: true,
|
|
24
50
|
mediaAttempted,
|
|
25
51
|
mediaDelivered: mediaAttempted,
|
|
26
52
|
error: null,
|
|
27
53
|
};
|
|
54
|
+
await this.logCompletion(sendId, messageHash, payload, result);
|
|
55
|
+
return result;
|
|
28
56
|
}
|
|
29
57
|
if (payload.mediaPath) {
|
|
30
|
-
if (
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
mediaDelivered: true,
|
|
41
|
-
error: textFollowupError,
|
|
42
|
-
};
|
|
43
|
-
}
|
|
58
|
+
if (primaryFailure.ambiguous) {
|
|
59
|
+
const result = {
|
|
60
|
+
ok: false,
|
|
61
|
+
mediaAttempted: true,
|
|
62
|
+
mediaDelivered: false,
|
|
63
|
+
error: primaryFailure.error,
|
|
64
|
+
deliveryUncertain: true,
|
|
65
|
+
};
|
|
66
|
+
await this.logCompletion(sendId, messageHash, payload, result);
|
|
67
|
+
return result;
|
|
44
68
|
}
|
|
45
69
|
const textFallback = normalizeSendInput(payload.message);
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
|
|
70
|
+
const textFallbackFailure = await this.trySendPayload(textFallback, {
|
|
71
|
+
sendId,
|
|
72
|
+
step: "text_fallback",
|
|
73
|
+
messageHash,
|
|
74
|
+
});
|
|
75
|
+
if (textFallbackFailure === null) {
|
|
76
|
+
const result = {
|
|
49
77
|
ok: true,
|
|
50
78
|
mediaAttempted: true,
|
|
51
79
|
mediaDelivered: false,
|
|
52
|
-
error:
|
|
80
|
+
error: primaryFailure.error,
|
|
53
81
|
};
|
|
82
|
+
await this.logCompletion(sendId, messageHash, payload, result);
|
|
83
|
+
return result;
|
|
54
84
|
}
|
|
55
|
-
|
|
85
|
+
const result = {
|
|
56
86
|
ok: false,
|
|
57
87
|
mediaAttempted: true,
|
|
58
88
|
mediaDelivered: false,
|
|
59
|
-
error: this.combineErrors(
|
|
89
|
+
error: this.combineErrors(primaryFailure.error, textFallbackFailure.error),
|
|
60
90
|
};
|
|
91
|
+
await this.logCompletion(sendId, messageHash, payload, result);
|
|
92
|
+
return result;
|
|
61
93
|
}
|
|
62
|
-
|
|
94
|
+
const result = {
|
|
63
95
|
ok: false,
|
|
64
96
|
mediaAttempted: false,
|
|
65
97
|
mediaDelivered: false,
|
|
66
|
-
error:
|
|
98
|
+
error: primaryFailure.error,
|
|
67
99
|
};
|
|
100
|
+
await this.logCompletion(sendId, messageHash, payload, result);
|
|
101
|
+
return result;
|
|
68
102
|
}
|
|
69
103
|
getLastError() {
|
|
70
104
|
return this.lastError;
|
|
@@ -132,17 +166,35 @@ export class AlertService {
|
|
|
132
166
|
}
|
|
133
167
|
return `${runtimeError}; ${fallbackError}`;
|
|
134
168
|
}
|
|
135
|
-
async trySendPayload(payload) {
|
|
136
|
-
|
|
137
|
-
|
|
169
|
+
async trySendPayload(payload, context) {
|
|
170
|
+
// OpenClaw documents `api.runtime.channel` as channel-plugin-specific helper
|
|
171
|
+
// surface. For a regular tool/service plugin like tickflow-assist, Telegram
|
|
172
|
+
// and QQ Bot delivery are more reliable via the shared
|
|
173
|
+
// `openclaw message send` CLI path.
|
|
174
|
+
if (this.channel === "telegram" || this.channel === "qqbot") {
|
|
175
|
+
return await this.trySendViaCommand(payload, context);
|
|
176
|
+
}
|
|
177
|
+
const runtimeFailure = await this.trySendViaRuntime(payload, context);
|
|
178
|
+
if (runtimeFailure === null) {
|
|
138
179
|
return null;
|
|
139
180
|
}
|
|
140
|
-
|
|
181
|
+
// Only image/media sends are risky to replay after an ambiguous transport error.
|
|
182
|
+
// Text-only notifications (for example session boundary notifications) should
|
|
183
|
+
// still fall back to the CLI path so transient runtime failures do not drop them.
|
|
184
|
+
if (runtimeFailure.ambiguous && payload.mediaPath) {
|
|
185
|
+
return runtimeFailure;
|
|
186
|
+
}
|
|
187
|
+
return await this.trySendViaCommand(payload, context);
|
|
141
188
|
}
|
|
142
|
-
async trySendViaRuntime(payload) {
|
|
189
|
+
async trySendViaRuntime(payload, context) {
|
|
143
190
|
const runtimeContext = this.options.runtime;
|
|
144
191
|
if (!runtimeContext || !this.options.target.trim()) {
|
|
145
|
-
|
|
192
|
+
const failure = {
|
|
193
|
+
error: "runtime delivery unavailable",
|
|
194
|
+
ambiguous: false,
|
|
195
|
+
};
|
|
196
|
+
await this.logTransportFailure("runtime_unavailable", context, payload, failure);
|
|
197
|
+
return failure;
|
|
146
198
|
}
|
|
147
199
|
const baseOptions = {
|
|
148
200
|
accountId: this.options.account || undefined,
|
|
@@ -152,33 +204,51 @@ export class AlertService {
|
|
|
152
204
|
};
|
|
153
205
|
try {
|
|
154
206
|
switch (this.channel) {
|
|
155
|
-
case "telegram":
|
|
156
|
-
await this.invokeRuntimeChannelSend(runtimeContext.runtime.channel, "telegram", "sendMessageTelegram", this.options.target, payload.message, baseOptions);
|
|
157
|
-
return null;
|
|
158
207
|
case "discord":
|
|
159
208
|
await this.invokeRuntimeChannelSend(runtimeContext.runtime.channel, "discord", "sendMessageDiscord", this.options.target, payload.message, {
|
|
160
209
|
...baseOptions,
|
|
161
210
|
filename: payload.filename,
|
|
162
211
|
});
|
|
163
|
-
|
|
212
|
+
break;
|
|
164
213
|
case "slack":
|
|
165
214
|
await this.invokeRuntimeChannelSend(runtimeContext.runtime.channel, "slack", "sendMessageSlack", this.options.target, payload.message, {
|
|
166
215
|
...baseOptions,
|
|
167
216
|
uploadFileName: payload.filename,
|
|
168
217
|
uploadTitle: payload.filename,
|
|
169
218
|
});
|
|
170
|
-
|
|
219
|
+
break;
|
|
171
220
|
case "signal":
|
|
172
221
|
await this.invokeRuntimeChannelSend(runtimeContext.runtime.channel, "signal", "sendMessageSignal", this.options.target, payload.message, baseOptions);
|
|
173
|
-
|
|
222
|
+
break;
|
|
174
223
|
default:
|
|
175
224
|
// OpenClaw 2026.3.31 narrows the typed runtime channel surface.
|
|
176
225
|
// Fall back to `openclaw message send` for channels not exposed here.
|
|
177
|
-
|
|
226
|
+
const failure = {
|
|
227
|
+
error: `runtime delivery not supported for channel: ${this.channel}`,
|
|
228
|
+
ambiguous: false,
|
|
229
|
+
};
|
|
230
|
+
await this.logTransportFailure("runtime_unsupported", context, payload, failure);
|
|
231
|
+
return failure;
|
|
178
232
|
}
|
|
233
|
+
await this.logDiagnostic("transport_success", {
|
|
234
|
+
sendId: context.sendId,
|
|
235
|
+
step: context.step,
|
|
236
|
+
transport: "runtime",
|
|
237
|
+
channel: this.channel,
|
|
238
|
+
messageHash: context.messageHash,
|
|
239
|
+
hasMedia: Boolean(payload.mediaPath),
|
|
240
|
+
mediaFile: basenameOrUndefined(payload.mediaPath),
|
|
241
|
+
});
|
|
242
|
+
return null;
|
|
179
243
|
}
|
|
180
244
|
catch (error) {
|
|
181
|
-
|
|
245
|
+
const detail = formatErrorMessage(error);
|
|
246
|
+
const failure = {
|
|
247
|
+
error: `runtime delivery failed: ${detail}`,
|
|
248
|
+
ambiguous: !isRuntimeCapabilityUnavailableError(detail),
|
|
249
|
+
};
|
|
250
|
+
await this.logTransportFailure("runtime_failed", context, payload, failure);
|
|
251
|
+
return failure;
|
|
182
252
|
}
|
|
183
253
|
}
|
|
184
254
|
async invokeRuntimeChannelSend(runtimeChannel, channelName, methodName, target, message, options) {
|
|
@@ -189,19 +259,94 @@ export class AlertService {
|
|
|
189
259
|
}
|
|
190
260
|
await method.call(channelApi, target, message, options);
|
|
191
261
|
}
|
|
192
|
-
async trySendViaCommand(payload) {
|
|
262
|
+
async trySendViaCommand(payload, context) {
|
|
263
|
+
const prepared = await this.prepareCommandPayload(payload);
|
|
264
|
+
const commandOptions = this.getCommandRunOptions(prepared.payload);
|
|
193
265
|
try {
|
|
194
|
-
const result = await this.runCommandWithTimeout(this.buildCliArgs(payload),
|
|
266
|
+
const result = await this.runCommandWithTimeout(this.buildCliArgs(prepared.payload), commandOptions);
|
|
195
267
|
if (result.code === 0) {
|
|
268
|
+
const commandOutcome = inspectCommandDeliveryResult(result.stdout);
|
|
269
|
+
if (commandOutcome?.error) {
|
|
270
|
+
const failure = {
|
|
271
|
+
error: commandOutcome.error,
|
|
272
|
+
ambiguous: false,
|
|
273
|
+
};
|
|
274
|
+
await this.logTransportFailure("command_reported_error", context, payload, failure, {
|
|
275
|
+
timeoutMs: commandOptions.timeoutMs,
|
|
276
|
+
termination: result.termination,
|
|
277
|
+
messageId: commandOutcome.messageId,
|
|
278
|
+
stdout: truncateDiagnosticText(result.stdout.trim()),
|
|
279
|
+
});
|
|
280
|
+
return failure;
|
|
281
|
+
}
|
|
282
|
+
await this.logDiagnostic("transport_success", {
|
|
283
|
+
sendId: context.sendId,
|
|
284
|
+
step: context.step,
|
|
285
|
+
transport: "command",
|
|
286
|
+
channel: this.channel,
|
|
287
|
+
messageHash: context.messageHash,
|
|
288
|
+
hasMedia: Boolean(payload.mediaPath),
|
|
289
|
+
mediaFile: basenameOrUndefined(payload.mediaPath),
|
|
290
|
+
timeoutMs: commandOptions.timeoutMs,
|
|
291
|
+
termination: result.termination,
|
|
292
|
+
messageId: commandOutcome?.messageId,
|
|
293
|
+
});
|
|
196
294
|
return null;
|
|
197
295
|
}
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
296
|
+
const failure = {
|
|
297
|
+
error: result.stderr.trim()
|
|
298
|
+
|| result.stdout.trim()
|
|
299
|
+
|| `command exited with ${result.code ?? "unknown"}`,
|
|
300
|
+
ambiguous: true,
|
|
301
|
+
};
|
|
302
|
+
await this.logTransportFailure("command_failed", context, payload, failure, {
|
|
303
|
+
code: result.code,
|
|
304
|
+
timeoutMs: commandOptions.timeoutMs,
|
|
305
|
+
termination: result.termination,
|
|
306
|
+
stderr: truncateDiagnosticText(result.stderr.trim()),
|
|
307
|
+
stdout: truncateDiagnosticText(result.stdout.trim()),
|
|
308
|
+
});
|
|
309
|
+
return failure;
|
|
201
310
|
}
|
|
202
311
|
catch (error) {
|
|
203
|
-
|
|
312
|
+
const failure = {
|
|
313
|
+
error: `command delivery failed: ${formatErrorMessage(error)}`,
|
|
314
|
+
ambiguous: false,
|
|
315
|
+
};
|
|
316
|
+
await this.logTransportFailure("command_error", context, payload, failure);
|
|
317
|
+
return failure;
|
|
318
|
+
}
|
|
319
|
+
finally {
|
|
320
|
+
await prepared.cleanup();
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
async prepareCommandPayload(payload) {
|
|
324
|
+
if (!shouldStageQQBotMedia(this.channel, payload.mediaPath)) {
|
|
325
|
+
return {
|
|
326
|
+
payload,
|
|
327
|
+
cleanup: async () => { },
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
const stagedMediaPath = await stageQQBotMediaFile(payload.mediaPath);
|
|
331
|
+
return {
|
|
332
|
+
payload: {
|
|
333
|
+
...payload,
|
|
334
|
+
mediaPath: stagedMediaPath,
|
|
335
|
+
},
|
|
336
|
+
cleanup: async () => {
|
|
337
|
+
await unlink(stagedMediaPath).catch((error) => {
|
|
338
|
+
if (error.code !== "ENOENT") {
|
|
339
|
+
throw error;
|
|
340
|
+
}
|
|
341
|
+
});
|
|
342
|
+
},
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
getCommandRunOptions(payload) {
|
|
346
|
+
if (payload.mediaPath) {
|
|
347
|
+
return { timeoutMs: 45_000 };
|
|
204
348
|
}
|
|
349
|
+
return { timeoutMs: 15_000 };
|
|
205
350
|
}
|
|
206
351
|
buildCliArgs(payload) {
|
|
207
352
|
const args = [
|
|
@@ -223,8 +368,40 @@ export class AlertService {
|
|
|
223
368
|
if (this.options.account) {
|
|
224
369
|
args.push("--account", this.options.account);
|
|
225
370
|
}
|
|
371
|
+
args.push("--json");
|
|
226
372
|
return args;
|
|
227
373
|
}
|
|
374
|
+
async logCompletion(sendId, messageHash, payload, result) {
|
|
375
|
+
await this.logDiagnostic("send_completed", {
|
|
376
|
+
sendId,
|
|
377
|
+
channel: this.channel,
|
|
378
|
+
messageHash,
|
|
379
|
+
hasMedia: Boolean(payload.mediaPath),
|
|
380
|
+
mediaFile: basenameOrUndefined(payload.mediaPath),
|
|
381
|
+
ok: result.ok,
|
|
382
|
+
mediaAttempted: result.mediaAttempted,
|
|
383
|
+
mediaDelivered: result.mediaDelivered,
|
|
384
|
+
deliveryUncertain: result.deliveryUncertain === true,
|
|
385
|
+
error: result.error ? truncateDiagnosticText(result.error) : null,
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
async logTransportFailure(event, context, payload, failure, extra = {}) {
|
|
389
|
+
await this.logDiagnostic(event, {
|
|
390
|
+
sendId: context.sendId,
|
|
391
|
+
step: context.step,
|
|
392
|
+
transport: event.startsWith("command") ? "command" : "runtime",
|
|
393
|
+
channel: this.channel,
|
|
394
|
+
messageHash: context.messageHash,
|
|
395
|
+
hasMedia: Boolean(payload.mediaPath),
|
|
396
|
+
mediaFile: basenameOrUndefined(payload.mediaPath),
|
|
397
|
+
ambiguous: failure.ambiguous,
|
|
398
|
+
error: truncateDiagnosticText(failure.error),
|
|
399
|
+
...extra,
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
async logDiagnostic(event, details) {
|
|
403
|
+
await this.options.diagnosticLogger?.append("alert_service", event, details);
|
|
404
|
+
}
|
|
228
405
|
}
|
|
229
406
|
function formatErrorMessage(error) {
|
|
230
407
|
if (error instanceof Error) {
|
|
@@ -240,11 +417,62 @@ function formatErrorMessage(error) {
|
|
|
240
417
|
return String(error);
|
|
241
418
|
}
|
|
242
419
|
}
|
|
420
|
+
function isRuntimeCapabilityUnavailableError(detail) {
|
|
421
|
+
return /runtime channel .* unavailable/i.test(detail);
|
|
422
|
+
}
|
|
243
423
|
function normalizeSendInput(input) {
|
|
244
424
|
return typeof input === "string"
|
|
245
425
|
? { message: input }
|
|
246
426
|
: input;
|
|
247
427
|
}
|
|
428
|
+
function inspectCommandDeliveryResult(stdout) {
|
|
429
|
+
const payload = extractCommandJsonPayload(stdout);
|
|
430
|
+
if (!payload) {
|
|
431
|
+
return null;
|
|
432
|
+
}
|
|
433
|
+
const directResult = isRecord(payload.result) ? payload.result : null;
|
|
434
|
+
const directResultMeta = isRecord(directResult?.meta) ? directResult.meta : null;
|
|
435
|
+
const error = getNonEmptyString(directResult?.error)
|
|
436
|
+
?? getNonEmptyString(directResultMeta?.error)
|
|
437
|
+
?? getNonEmptyString(payload.error);
|
|
438
|
+
const messageId = getNonEmptyString(directResult?.messageId)
|
|
439
|
+
?? getNonEmptyString(directResultMeta?.messageId)
|
|
440
|
+
?? getNonEmptyString(payload.messageId);
|
|
441
|
+
if (!error && !messageId) {
|
|
442
|
+
return null;
|
|
443
|
+
}
|
|
444
|
+
return {
|
|
445
|
+
...(error ? { error } : {}),
|
|
446
|
+
...(messageId ? { messageId } : {}),
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
function extractCommandJsonPayload(stdout) {
|
|
450
|
+
const root = extractTrailingJsonObject(stdout);
|
|
451
|
+
if (!root) {
|
|
452
|
+
return null;
|
|
453
|
+
}
|
|
454
|
+
return isRecord(root.payload) ? root.payload : root;
|
|
455
|
+
}
|
|
456
|
+
function extractTrailingJsonObject(stdout) {
|
|
457
|
+
const trimmed = stdout.trim();
|
|
458
|
+
if (!trimmed) {
|
|
459
|
+
return null;
|
|
460
|
+
}
|
|
461
|
+
for (let start = trimmed.lastIndexOf("{"); start >= 0; start = trimmed.lastIndexOf("{", start - 1)) {
|
|
462
|
+
const candidate = trimmed.slice(start);
|
|
463
|
+
try {
|
|
464
|
+
const parsed = JSON.parse(candidate);
|
|
465
|
+
const record = isRecord(parsed) ? parsed : null;
|
|
466
|
+
if (record) {
|
|
467
|
+
return record;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
catch {
|
|
471
|
+
// Keep scanning backward until the trailing JSON object is found.
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
return null;
|
|
475
|
+
}
|
|
248
476
|
function getRuntimeChannelApi(runtimeChannel, channelName) {
|
|
249
477
|
if (!isRecord(runtimeChannel)) {
|
|
250
478
|
return null;
|
|
@@ -255,6 +483,60 @@ function getRuntimeChannelApi(runtimeChannel, channelName) {
|
|
|
255
483
|
function isRecord(value) {
|
|
256
484
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
257
485
|
}
|
|
486
|
+
function getNonEmptyString(value) {
|
|
487
|
+
if (typeof value !== "string") {
|
|
488
|
+
return undefined;
|
|
489
|
+
}
|
|
490
|
+
const trimmed = value.trim();
|
|
491
|
+
return trimmed.length > 0 ? trimmed : undefined;
|
|
492
|
+
}
|
|
493
|
+
function shouldStageQQBotMedia(channel, mediaPath) {
|
|
494
|
+
return channel === "qqbot"
|
|
495
|
+
&& typeof mediaPath === "string"
|
|
496
|
+
&& mediaPath.length > 0
|
|
497
|
+
&& !isRemoteMediaPath(mediaPath)
|
|
498
|
+
&& !isUnderQQBotManagedMediaRoot(mediaPath);
|
|
499
|
+
}
|
|
500
|
+
function isRemoteMediaPath(mediaPath) {
|
|
501
|
+
return /^(?:https?:|data:)/i.test(mediaPath.trim());
|
|
502
|
+
}
|
|
503
|
+
function isUnderQQBotManagedMediaRoot(mediaPath) {
|
|
504
|
+
const candidate = path.resolve(mediaPath);
|
|
505
|
+
const mediaRoot = path.resolve(getQQBotManagedMediaRoot());
|
|
506
|
+
const relative = path.relative(mediaRoot, candidate);
|
|
507
|
+
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
|
508
|
+
}
|
|
509
|
+
async function stageQQBotMediaFile(mediaPath) {
|
|
510
|
+
const destinationDir = path.join(getQQBotManagedMediaRoot(), "tickflow-assist");
|
|
511
|
+
await mkdir(destinationDir, { recursive: true });
|
|
512
|
+
const sourceExt = path.extname(mediaPath);
|
|
513
|
+
const sourceBase = path.basename(mediaPath, sourceExt);
|
|
514
|
+
const safeBase = sanitizeFileNamePart(sourceBase) || "alert-media";
|
|
515
|
+
const safeExt = sanitizeFileExtension(sourceExt);
|
|
516
|
+
const stagedName = `${safeBase}-${Date.now()}-${randomBytes(3).toString("hex")}${safeExt}`;
|
|
517
|
+
const stagedPath = path.join(destinationDir, stagedName);
|
|
518
|
+
await copyFile(mediaPath, stagedPath);
|
|
519
|
+
return stagedPath;
|
|
520
|
+
}
|
|
521
|
+
function getQQBotManagedMediaRoot() {
|
|
522
|
+
return path.join(resolveHomeDir(), ".openclaw", "media", "qqbot");
|
|
523
|
+
}
|
|
524
|
+
function resolveHomeDir() {
|
|
525
|
+
const fromOs = os.homedir();
|
|
526
|
+
if (fromOs) {
|
|
527
|
+
return fromOs;
|
|
528
|
+
}
|
|
529
|
+
return process.env.HOME || process.env.USERPROFILE || ".";
|
|
530
|
+
}
|
|
531
|
+
function sanitizeFileNamePart(value) {
|
|
532
|
+
return value.replace(/[^a-zA-Z0-9._-]+/g, "_");
|
|
533
|
+
}
|
|
534
|
+
function sanitizeFileExtension(value) {
|
|
535
|
+
if (!value) {
|
|
536
|
+
return "";
|
|
537
|
+
}
|
|
538
|
+
return /^\.[a-zA-Z0-9]+$/.test(value) ? value.toLowerCase() : "";
|
|
539
|
+
}
|
|
258
540
|
function getAlertStyle(ruleCode, fallbackTitle) {
|
|
259
541
|
switch (ruleCode) {
|
|
260
542
|
case "stop_loss_hit":
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { IndustryPeerContext } from "../analysis/types/composite-analysis.js";
|
|
2
|
+
import { QuoteService } from "./quote-service.js";
|
|
3
|
+
import { TickFlowUniverseService } from "./tickflow-universe-service.js";
|
|
4
|
+
export declare class IndustryPeerService {
|
|
5
|
+
private readonly universeService;
|
|
6
|
+
private readonly quoteService;
|
|
7
|
+
constructor(universeService: TickFlowUniverseService | null, quoteService: QuoteService);
|
|
8
|
+
buildContext(symbol: string): Promise<IndustryPeerContext>;
|
|
9
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
const MAX_PEER_MOVERS = 3;
|
|
2
|
+
export class IndustryPeerService {
|
|
3
|
+
universeService;
|
|
4
|
+
quoteService;
|
|
5
|
+
constructor(universeService, quoteService) {
|
|
6
|
+
this.universeService = universeService;
|
|
7
|
+
this.quoteService = quoteService;
|
|
8
|
+
}
|
|
9
|
+
async buildContext(symbol) {
|
|
10
|
+
if (!this.universeService) {
|
|
11
|
+
return buildUnavailableContext("当前 TickFlow API Key Level 不支持标的池,已跳过申万三级同业表现。");
|
|
12
|
+
}
|
|
13
|
+
const industryProfile = await this.universeService.resolveIndustryProfile(symbol);
|
|
14
|
+
if (!industryProfile?.sw3UniverseId || !industryProfile.sw3Name) {
|
|
15
|
+
return buildUnavailableContext("未获取到可用的申万3级行业映射。");
|
|
16
|
+
}
|
|
17
|
+
const peerSymbols = await this.universeService.listUniverseSymbols(industryProfile.sw3UniverseId);
|
|
18
|
+
if (peerSymbols.length === 0) {
|
|
19
|
+
return buildUnavailableContext(`申万3级 ${industryProfile.sw3Name} 暂无可用成分股。`, industryProfile);
|
|
20
|
+
}
|
|
21
|
+
const quotes = await this.quoteService.fetchQuotes(peerSymbols);
|
|
22
|
+
const snapshots = quotes
|
|
23
|
+
.map(toPeerQuoteSnapshot)
|
|
24
|
+
.filter((item) => item != null)
|
|
25
|
+
.sort((left, right) => right.changePct - left.changePct || left.symbol.localeCompare(right.symbol));
|
|
26
|
+
if (snapshots.length === 0) {
|
|
27
|
+
return buildUnavailableContext(`申万3级 ${industryProfile.sw3Name} 暂未返回有效行情。`, industryProfile);
|
|
28
|
+
}
|
|
29
|
+
const targetIndex = snapshots.findIndex((item) => item.symbol === symbol);
|
|
30
|
+
const target = targetIndex >= 0 ? snapshots[targetIndex] : null;
|
|
31
|
+
const others = snapshots.filter((item) => item.symbol !== symbol);
|
|
32
|
+
const advanceCount = others.filter((item) => item.changePct > 0.0001).length;
|
|
33
|
+
const declineCount = others.filter((item) => item.changePct < -0.0001).length;
|
|
34
|
+
const flatCount = Math.max(0, others.length - advanceCount - declineCount);
|
|
35
|
+
const changeValues = others.map((item) => item.changePct);
|
|
36
|
+
const averageChangePct = changeValues.length > 0 ? average(changeValues) : null;
|
|
37
|
+
const medianChangePct = changeValues.length > 0 ? median(changeValues) : null;
|
|
38
|
+
const leaders = others.slice(0, MAX_PEER_MOVERS).map(toPeerMover);
|
|
39
|
+
const laggards = [...others]
|
|
40
|
+
.sort((left, right) => left.changePct - right.changePct || left.symbol.localeCompare(right.symbol))
|
|
41
|
+
.slice(0, MAX_PEER_MOVERS)
|
|
42
|
+
.map(toPeerMover);
|
|
43
|
+
const targetRank = targetIndex >= 0 ? targetIndex + 1 : null;
|
|
44
|
+
const targetPercentile = targetRank != null && snapshots.length > 1
|
|
45
|
+
? 1 - ((targetRank - 1) / (snapshots.length - 1))
|
|
46
|
+
: targetRank != null ? 1 : null;
|
|
47
|
+
return {
|
|
48
|
+
available: true,
|
|
49
|
+
summary: buildSummary({
|
|
50
|
+
industryName: industryProfile.sw3Name,
|
|
51
|
+
peerCount: snapshots.length,
|
|
52
|
+
otherStockCount: others.length,
|
|
53
|
+
advanceCount,
|
|
54
|
+
declineCount,
|
|
55
|
+
flatCount,
|
|
56
|
+
averageChangePct,
|
|
57
|
+
medianChangePct,
|
|
58
|
+
target,
|
|
59
|
+
targetRank,
|
|
60
|
+
}),
|
|
61
|
+
sw1Name: industryProfile.sw1Name,
|
|
62
|
+
sw2Name: industryProfile.sw2Name,
|
|
63
|
+
sw3Name: industryProfile.sw3Name,
|
|
64
|
+
sw3UniverseId: industryProfile.sw3UniverseId,
|
|
65
|
+
peerCount: snapshots.length,
|
|
66
|
+
otherStockCount: others.length,
|
|
67
|
+
advanceCount,
|
|
68
|
+
declineCount,
|
|
69
|
+
flatCount,
|
|
70
|
+
averageChangePct,
|
|
71
|
+
medianChangePct,
|
|
72
|
+
targetChangePct: target?.changePct ?? null,
|
|
73
|
+
targetRank,
|
|
74
|
+
targetPercentile,
|
|
75
|
+
leaders,
|
|
76
|
+
laggards,
|
|
77
|
+
note: null,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
function toPeerQuoteSnapshot(quote) {
|
|
82
|
+
const prevClose = Number(quote.prev_close ?? 0);
|
|
83
|
+
const lastPrice = Number(quote.last_price ?? 0);
|
|
84
|
+
if (!Number.isFinite(prevClose) || !Number.isFinite(lastPrice) || prevClose <= 0) {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
return {
|
|
88
|
+
symbol: String(quote.symbol ?? "").trim(),
|
|
89
|
+
name: String(quote.name ?? quote.ext?.name ?? quote.symbol ?? "").trim(),
|
|
90
|
+
changePct: ((lastPrice - prevClose) / prevClose) * 100,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
function toPeerMover(item) {
|
|
94
|
+
return {
|
|
95
|
+
symbol: item.symbol,
|
|
96
|
+
name: item.name || item.symbol,
|
|
97
|
+
changePct: item.changePct,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
function buildSummary(input) {
|
|
101
|
+
const parts = [
|
|
102
|
+
`申万3级 ${input.industryName} 共 ${input.peerCount} 只`,
|
|
103
|
+
`除本股外上涨 ${input.advanceCount} / 下跌 ${input.declineCount} / 平 ${input.flatCount}`,
|
|
104
|
+
];
|
|
105
|
+
if (input.averageChangePct != null) {
|
|
106
|
+
parts.push(`均值 ${formatSignedPct(input.averageChangePct)}`);
|
|
107
|
+
}
|
|
108
|
+
if (input.medianChangePct != null) {
|
|
109
|
+
parts.push(`中位数 ${formatSignedPct(input.medianChangePct)}`);
|
|
110
|
+
}
|
|
111
|
+
if (input.target && input.targetRank != null) {
|
|
112
|
+
parts.push(`本股 ${formatSignedPct(input.target.changePct)},位列 ${input.targetRank}/${input.peerCount}`);
|
|
113
|
+
}
|
|
114
|
+
return parts.join(";");
|
|
115
|
+
}
|
|
116
|
+
function buildUnavailableContext(note, profile) {
|
|
117
|
+
return {
|
|
118
|
+
available: false,
|
|
119
|
+
summary: note,
|
|
120
|
+
sw1Name: profile?.sw1Name ?? null,
|
|
121
|
+
sw2Name: profile?.sw2Name ?? null,
|
|
122
|
+
sw3Name: profile?.sw3Name ?? null,
|
|
123
|
+
sw3UniverseId: profile?.sw3UniverseId ?? null,
|
|
124
|
+
peerCount: 0,
|
|
125
|
+
otherStockCount: 0,
|
|
126
|
+
advanceCount: 0,
|
|
127
|
+
declineCount: 0,
|
|
128
|
+
flatCount: 0,
|
|
129
|
+
averageChangePct: null,
|
|
130
|
+
medianChangePct: null,
|
|
131
|
+
targetChangePct: null,
|
|
132
|
+
targetRank: null,
|
|
133
|
+
targetPercentile: null,
|
|
134
|
+
leaders: [],
|
|
135
|
+
laggards: [],
|
|
136
|
+
note,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
function average(values) {
|
|
140
|
+
return values.reduce((sum, value) => sum + value, 0) / values.length;
|
|
141
|
+
}
|
|
142
|
+
function median(values) {
|
|
143
|
+
const sorted = [...values].sort((left, right) => left - right);
|
|
144
|
+
const middle = Math.floor(sorted.length / 2);
|
|
145
|
+
if (sorted.length % 2 === 0) {
|
|
146
|
+
return (sorted[middle - 1] + sorted[middle]) / 2;
|
|
147
|
+
}
|
|
148
|
+
return sorted[middle] ?? 0;
|
|
149
|
+
}
|
|
150
|
+
function formatSignedPct(value) {
|
|
151
|
+
return `${value >= 0 ? "+" : ""}${value.toFixed(2)}%`;
|
|
152
|
+
}
|
|
@@ -4,6 +4,7 @@ import path from "node:path";
|
|
|
4
4
|
import { parseFlashAlertDecision } from "../analysis/parsers/flash-alert-decision.parser.js";
|
|
5
5
|
import { FLASH_MONITOR_ALERT_SYSTEM_PROMPT, buildFlashMonitorAlertUserPrompt, } from "../prompts/analysis/index.js";
|
|
6
6
|
import { chinaHour, chinaToday, formatChinaDateTime } from "../utils/china-time.js";
|
|
7
|
+
import { extractSectorKeywords } from "./watchlist-profile-service.js";
|
|
7
8
|
const DEFAULT_STATE = {
|
|
8
9
|
initialized: false,
|
|
9
10
|
lastSeenKey: null,
|
|
@@ -446,7 +447,7 @@ function buildDirectKeywords(item) {
|
|
|
446
447
|
}
|
|
447
448
|
function buildBoardKeywords(item) {
|
|
448
449
|
return uniqueStrings([
|
|
449
|
-
item.sector
|
|
450
|
+
...extractSectorKeywords(item.sector),
|
|
450
451
|
...item.themes,
|
|
451
452
|
]).filter((keyword) => isUsefulBoardKeyword(keyword));
|
|
452
453
|
}
|