tickflow-assist 0.3.2 → 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 +1 -1
- package/dist/background/daily-update.worker.d.ts +7 -1
- package/dist/background/daily-update.worker.js +127 -3
- package/dist/bootstrap.js +3 -1
- package/dist/prompts/analysis/index.d.ts +1 -0
- package/dist/prompts/analysis/index.js +1 -0
- package/dist/prompts/analysis/pre-market-brief-prompt.d.ts +14 -0
- package/dist/prompts/analysis/pre-market-brief-prompt.js +49 -0
- package/dist/services/alert-service.js +3 -0
- package/dist/services/jin10-flash-monitor-service.js +17 -2
- package/dist/services/jin10-mcp-service.d.ts +4 -0
- package/dist/services/jin10-mcp-service.js +57 -2
- package/dist/services/monitor-service.d.ts +10 -0
- package/dist/services/monitor-service.js +238 -57
- package/dist/services/pre-market-brief-service.d.ts +21 -0
- package/dist/services/pre-market-brief-service.js +289 -0
- package/dist/services/trading-calendar-service.d.ts +4 -0
- package/dist/services/trading-calendar-service.js +11 -0
- package/dist/storage/repositories/jin10-flash-repo.d.ts +1 -0
- package/dist/storage/repositories/jin10-flash-repo.js +23 -1
- package/dist/types/daily-update.d.ts +7 -0
- package/openclaw.plugin.json +1 -1
- package/package.json +3 -3
|
@@ -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,8 @@ const DEFAULT_STATE = {
|
|
|
20
20
|
sessionNotificationsSent: [],
|
|
21
21
|
};
|
|
22
22
|
const INTRADAY_PERIOD = "1m";
|
|
23
|
+
const MONITOR_RUN_LOCK_MIN_STALE_MS = 90_000;
|
|
24
|
+
const ALERT_CLAIM_MIN_STALE_MS = 90_000;
|
|
23
25
|
export class MonitorService {
|
|
24
26
|
baseDir;
|
|
25
27
|
requestInterval;
|
|
@@ -178,50 +180,60 @@ export class MonitorService {
|
|
|
178
180
|
return lines.join("\n");
|
|
179
181
|
}
|
|
180
182
|
async runMonitorOnce() {
|
|
181
|
-
await this.
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
if (phase !== "trading") {
|
|
185
|
-
return alertCount;
|
|
186
|
-
}
|
|
187
|
-
const watchlist = await this.watchlistService.list();
|
|
188
|
-
if (watchlist.length === 0) {
|
|
189
|
-
return alertCount;
|
|
183
|
+
const runLease = await this.tryAcquireRunLease();
|
|
184
|
+
if (!runLease) {
|
|
185
|
+
return 0;
|
|
190
186
|
}
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
if (
|
|
196
|
-
|
|
187
|
+
try {
|
|
188
|
+
await this.alertMediaService.maybeCleanupExpired();
|
|
189
|
+
const phase = await this.tradingCalendarService.getTradingPhase();
|
|
190
|
+
let alertCount = await this.maybeSendSessionNotification(phase);
|
|
191
|
+
if (phase !== "trading") {
|
|
192
|
+
return alertCount;
|
|
197
193
|
}
|
|
198
|
-
const
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
194
|
+
const watchlist = await this.watchlistService.list();
|
|
195
|
+
if (watchlist.length === 0) {
|
|
196
|
+
return alertCount;
|
|
197
|
+
}
|
|
198
|
+
const quotes = await this.quoteService.fetchQuotes(watchlist.map((item) => item.symbol));
|
|
199
|
+
const quoteMap = new Map(quotes.map((quote) => [quote.symbol, quote]));
|
|
200
|
+
for (const item of watchlist) {
|
|
201
|
+
const quote = quoteMap.get(item.symbol);
|
|
202
|
+
if (!quote || !(Number(quote.last_price) > 0)) {
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
const levels = await this.keyLevelsRepository.getBySymbol(item.symbol);
|
|
206
|
+
let intradayRowsPromise = null;
|
|
207
|
+
const getIntradayRows = () => {
|
|
208
|
+
intradayRowsPromise ??= this.loadIntradayRows(item.symbol);
|
|
209
|
+
return intradayRowsPromise;
|
|
210
|
+
};
|
|
211
|
+
const priceAlert = levels
|
|
212
|
+
? selectPrimaryAlertCandidate(buildPriceAlerts(item, quote, levels, this.alertService))
|
|
213
|
+
: null;
|
|
214
|
+
if (priceAlert) {
|
|
215
|
+
if (await this.trySendCandidate(item, quote, priceAlert, levels, getIntradayRows)) {
|
|
208
216
|
alertCount += 1;
|
|
209
217
|
}
|
|
218
|
+
continue;
|
|
210
219
|
}
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
220
|
+
const changeAlert = buildChangeAlert(item, quote, levels, this.alertService);
|
|
221
|
+
if (changeAlert) {
|
|
222
|
+
if (await this.trySendCandidate(item, quote, changeAlert, levels, getIntradayRows)) {
|
|
223
|
+
alertCount += 1;
|
|
224
|
+
}
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
const volumeAlert = await this.buildVolumeAlert(item, quote, levels);
|
|
228
|
+
if (volumeAlert && (await this.trySendAlert(item.symbol, volumeAlert.ruleName, volumeAlert.message))) {
|
|
216
229
|
alertCount += 1;
|
|
217
230
|
}
|
|
218
231
|
}
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
232
|
+
return alertCount;
|
|
233
|
+
}
|
|
234
|
+
finally {
|
|
235
|
+
await runLease.release();
|
|
223
236
|
}
|
|
224
|
-
return alertCount;
|
|
225
237
|
}
|
|
226
238
|
async maybeSendSessionNotification(phase) {
|
|
227
239
|
const now = formatChinaDateTime();
|
|
@@ -357,27 +369,135 @@ export class MonitorService {
|
|
|
357
369
|
lastLoopErrorAt: formatChinaDateTime(),
|
|
358
370
|
});
|
|
359
371
|
}
|
|
372
|
+
async tryAcquireRunLease() {
|
|
373
|
+
const lockPath = this.getRunLockFilePath();
|
|
374
|
+
await mkdir(path.dirname(lockPath), { recursive: true });
|
|
375
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
376
|
+
try {
|
|
377
|
+
await writeFile(lockPath, JSON.stringify({
|
|
378
|
+
pid: process.pid,
|
|
379
|
+
acquiredAt: formatChinaDateTime(),
|
|
380
|
+
}), { flag: "wx" });
|
|
381
|
+
return {
|
|
382
|
+
release: async () => {
|
|
383
|
+
await rm(lockPath, { force: true });
|
|
384
|
+
},
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
catch (error) {
|
|
388
|
+
const code = error.code;
|
|
389
|
+
if (code !== "EEXIST") {
|
|
390
|
+
throw error;
|
|
391
|
+
}
|
|
392
|
+
const cleared = await this.removeStaleRunLock(lockPath);
|
|
393
|
+
if (!cleared) {
|
|
394
|
+
return null;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
return null;
|
|
399
|
+
}
|
|
400
|
+
async removeStaleRunLock(lockPath) {
|
|
401
|
+
try {
|
|
402
|
+
const lockStat = await stat(lockPath);
|
|
403
|
+
const staleMs = Math.max(this.requestInterval * 4 * 1000, MONITOR_RUN_LOCK_MIN_STALE_MS);
|
|
404
|
+
if (Date.now() - lockStat.mtimeMs <= staleMs) {
|
|
405
|
+
return false;
|
|
406
|
+
}
|
|
407
|
+
await rm(lockPath, { force: true });
|
|
408
|
+
return true;
|
|
409
|
+
}
|
|
410
|
+
catch (error) {
|
|
411
|
+
if (error.code === "ENOENT") {
|
|
412
|
+
return true;
|
|
413
|
+
}
|
|
414
|
+
throw error;
|
|
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
|
+
}
|
|
360
465
|
async trySendAlert(symbol, ruleName, input) {
|
|
361
466
|
const sessionKey = getSessionKey();
|
|
362
|
-
|
|
467
|
+
const claim = await this.tryAcquireAlertClaim(symbol, ruleName, sessionKey);
|
|
468
|
+
if (!claim) {
|
|
469
|
+
await this.cleanupAlertMedia(input);
|
|
363
470
|
return false;
|
|
364
471
|
}
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
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;
|
|
368
490
|
}
|
|
369
|
-
|
|
370
|
-
await
|
|
491
|
+
finally {
|
|
492
|
+
await claim.release();
|
|
371
493
|
}
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
});
|
|
380
|
-
return true;
|
|
494
|
+
}
|
|
495
|
+
async trySendCandidate(item, quote, candidate, levels, getIntradayRows) {
|
|
496
|
+
if (await this.hasSentAlert(item.symbol, candidate.ruleName)) {
|
|
497
|
+
return false;
|
|
498
|
+
}
|
|
499
|
+
const delivery = await this.buildAlertDelivery(item, quote, candidate, levels, getIntradayRows);
|
|
500
|
+
return this.trySendAlert(item.symbol, candidate.ruleName, delivery);
|
|
381
501
|
}
|
|
382
502
|
async buildAlertDelivery(item, quote, candidate, levels, getIntradayRows) {
|
|
383
503
|
if (!candidate.image || !levels) {
|
|
@@ -473,6 +593,29 @@ export class MonitorService {
|
|
|
473
593
|
}),
|
|
474
594
|
};
|
|
475
595
|
}
|
|
596
|
+
async hasSentAlert(symbol, ruleName) {
|
|
597
|
+
return this.alertLogRepository.isSentThisSession(symbol, ruleName, getSessionKey());
|
|
598
|
+
}
|
|
599
|
+
async sendAlertAndCleanupMedia(input) {
|
|
600
|
+
try {
|
|
601
|
+
return await this.alertService.sendWithResult(input);
|
|
602
|
+
}
|
|
603
|
+
finally {
|
|
604
|
+
await this.cleanupAlertMedia(input);
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
async cleanupAlertMedia(input) {
|
|
608
|
+
if (typeof input === "string" || !input.mediaPath) {
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
611
|
+
await this.alertMediaService.removeFile(input.mediaPath).catch(() => { });
|
|
612
|
+
}
|
|
613
|
+
getRunLockFilePath() {
|
|
614
|
+
return path.join(this.baseDir, "monitor-run.lock");
|
|
615
|
+
}
|
|
616
|
+
getAlertClaimFilePath(symbol, ruleName, sessionKey) {
|
|
617
|
+
return path.join(this.baseDir, "alert-claims", `${sanitizeAlertClaimPart(sessionKey)}_${sanitizeAlertClaimPart(symbol)}_${sanitizeAlertClaimPart(ruleName)}.lock`);
|
|
618
|
+
}
|
|
476
619
|
}
|
|
477
620
|
function formatRunningState(state, requestInterval) {
|
|
478
621
|
const heartbeat = getHeartbeatStatus(state, requestInterval);
|
|
@@ -523,6 +666,9 @@ function parseChinaDateTime(value) {
|
|
|
523
666
|
const [, year, month, day, hour, minute, second] = match;
|
|
524
667
|
return Date.UTC(Number(year), Number(month) - 1, Number(day), Number(hour) - 8, Number(minute), Number(second));
|
|
525
668
|
}
|
|
669
|
+
function sanitizeAlertClaimPart(value) {
|
|
670
|
+
return value.replace(/[^a-zA-Z0-9._-]+/g, "_");
|
|
671
|
+
}
|
|
526
672
|
function formatRuntimeHost(state) {
|
|
527
673
|
return state.runtimeHost === "plugin_service"
|
|
528
674
|
? "plugin_service"
|
|
@@ -658,23 +804,58 @@ function buildPriceAlerts(item, quote, levels, alertService) {
|
|
|
658
804
|
if (levels.stop_loss && currentPrice <= levels.stop_loss) {
|
|
659
805
|
push("stop_loss_hit", "⛔ 触及止损", "价格已触及止损位,建议立即执行止损", levels.stop_loss);
|
|
660
806
|
}
|
|
661
|
-
else if (levels.stop_loss
|
|
807
|
+
else if (levels.stop_loss
|
|
808
|
+
&& currentPrice > levels.stop_loss
|
|
809
|
+
&& isWithinPriceBuffer(currentPrice, levels.stop_loss, buffer)) {
|
|
662
810
|
push("stop_loss_near", "⚠️ 接近止损", "价格接近止损位,请保持警惕", levels.stop_loss);
|
|
663
811
|
}
|
|
812
|
+
if (levels.take_profit && currentPrice >= levels.take_profit) {
|
|
813
|
+
push("take_profit_hit", "💰 触及止盈", "价格已达止盈位,建议分批止盈", levels.take_profit);
|
|
814
|
+
}
|
|
664
815
|
if (levels.breakthrough && currentPrice >= levels.breakthrough) {
|
|
665
816
|
push("breakthrough_hit", "🚀 突破", "价格已突破关键压力位,可能开启新行情", levels.breakthrough);
|
|
666
817
|
}
|
|
667
|
-
if (levels.support && currentPrice
|
|
818
|
+
if (levels.support && isWithinPriceBuffer(currentPrice, levels.support, buffer)) {
|
|
668
819
|
push("support_near", "📉 触及支撑", "价格接近支撑位,关注是否企稳", levels.support);
|
|
669
820
|
}
|
|
670
|
-
if (levels.resistance && currentPrice
|
|
821
|
+
if (levels.resistance && isWithinPriceBuffer(currentPrice, levels.resistance, buffer)) {
|
|
671
822
|
push("resistance_near", "📈 接近压力", "价格接近压力位,关注能否突破", levels.resistance);
|
|
672
823
|
}
|
|
673
|
-
if (levels.take_profit && currentPrice >= levels.take_profit) {
|
|
674
|
-
push("take_profit_hit", "💰 触及止盈", "价格已达止盈位,建议分批止盈", levels.take_profit);
|
|
675
|
-
}
|
|
676
824
|
return alerts;
|
|
677
825
|
}
|
|
826
|
+
function selectPrimaryAlertCandidate(candidates) {
|
|
827
|
+
let best = null;
|
|
828
|
+
let bestPriority = Number.NEGATIVE_INFINITY;
|
|
829
|
+
for (const candidate of candidates) {
|
|
830
|
+
const priority = getAlertPriority(candidate.ruleName);
|
|
831
|
+
if (priority > bestPriority) {
|
|
832
|
+
best = candidate;
|
|
833
|
+
bestPriority = priority;
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
return best;
|
|
837
|
+
}
|
|
838
|
+
function getAlertPriority(ruleName) {
|
|
839
|
+
switch (ruleName) {
|
|
840
|
+
case "stop_loss_hit":
|
|
841
|
+
return 600;
|
|
842
|
+
case "take_profit_hit":
|
|
843
|
+
return 500;
|
|
844
|
+
case "breakthrough_hit":
|
|
845
|
+
return 400;
|
|
846
|
+
case "stop_loss_near":
|
|
847
|
+
return 300;
|
|
848
|
+
case "support_near":
|
|
849
|
+
return 200;
|
|
850
|
+
case "resistance_near":
|
|
851
|
+
return 100;
|
|
852
|
+
default:
|
|
853
|
+
return 0;
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
function isWithinPriceBuffer(currentPrice, levelPrice, buffer) {
|
|
857
|
+
return currentPrice >= levelPrice * (1 - buffer) && currentPrice <= levelPrice * (1 + buffer);
|
|
858
|
+
}
|
|
678
859
|
function buildChangeAlert(item, quote, levels, alertService) {
|
|
679
860
|
const currentPrice = Number(quote.last_price);
|
|
680
861
|
const prevClose = Number(quote.prev_close ?? 0);
|
|
@@ -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
|
+
}
|