tickflow-assist 0.3.6 → 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 +7 -39
- package/dist/analysis/types/composite-analysis.d.ts +27 -0
- package/dist/bootstrap.js +15 -4
- 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/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.js +3 -17
- 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 +56 -19
- package/dist/types/tickflow.d.ts +12 -0
- package/dist/utils/tickflow-quote.d.ts +5 -0
- package/dist/utils/tickflow-quote.js +31 -0
- package/openclaw.plugin.json +79 -2
- package/package.json +5 -5
- package/skills/stock-analysis/SKILL.md +8 -18
|
@@ -3,6 +3,7 @@ import path from "node:path";
|
|
|
3
3
|
import { basenameOrUndefined, buildAlertMessageHash, truncateDiagnosticText, } from "../utils/alert-diagnostic-log.js";
|
|
4
4
|
import { formatChinaDateTime } from "../utils/china-time.js";
|
|
5
5
|
import { calculateProfitPct, formatCostPrice } from "../utils/cost-price.js";
|
|
6
|
+
import { resolveTickFlowQuoteChangePct } from "../utils/tickflow-quote.js";
|
|
6
7
|
const DEFAULT_STATE = {
|
|
7
8
|
running: false,
|
|
8
9
|
startedAt: null,
|
|
@@ -737,13 +738,7 @@ function formatTradingPhase(phase) {
|
|
|
737
738
|
}
|
|
738
739
|
function formatQuoteLine(item, quote) {
|
|
739
740
|
const lastPrice = Number(quote.last_price ?? 0);
|
|
740
|
-
const
|
|
741
|
-
const tickflowChangePct = quote.ext?.change_pct;
|
|
742
|
-
const changePct = tickflowChangePct != null
|
|
743
|
-
? Number(tickflowChangePct) * 100
|
|
744
|
-
: prevClose > 0
|
|
745
|
-
? ((lastPrice - prevClose) / prevClose) * 100
|
|
746
|
-
: null;
|
|
741
|
+
const changePct = resolveTickFlowQuoteChangePct(quote);
|
|
747
742
|
const quoteTime = formatQuoteTimestamp(quote.timestamp);
|
|
748
743
|
const profitPct = calculateProfitPct(lastPrice, item.costPrice);
|
|
749
744
|
let line = `• ${item.name}(${item.symbol}) ${lastPrice.toFixed(2)}`;
|
|
@@ -1007,14 +1002,5 @@ function resolveAlertImageLabel(ruleName, fallbackTitle) {
|
|
|
1007
1002
|
}
|
|
1008
1003
|
}
|
|
1009
1004
|
function getQuoteChangePct(quote) {
|
|
1010
|
-
|
|
1011
|
-
if (tickflowChangePct != null && Number.isFinite(Number(tickflowChangePct))) {
|
|
1012
|
-
return Number(tickflowChangePct) * 100;
|
|
1013
|
-
}
|
|
1014
|
-
const lastPrice = Number(quote.last_price ?? 0);
|
|
1015
|
-
const prevClose = Number(quote.prev_close ?? 0);
|
|
1016
|
-
if (!(prevClose > 0)) {
|
|
1017
|
-
return null;
|
|
1018
|
-
}
|
|
1019
|
-
return ((lastPrice - prevClose) / prevClose) * 100;
|
|
1005
|
+
return resolveTickFlowQuoteChangePct(quote);
|
|
1020
1006
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { WatchlistItem } from "../types/domain.js";
|
|
2
2
|
import { CompositeAnalysisOrchestrator } from "../analysis/orchestrators/composite-analysis.orchestrator.js";
|
|
3
|
-
import type { CompositeAnalysisResult, PostCloseReviewResult, PriorKeyLevelValidationContext } from "../analysis/types/composite-analysis.js";
|
|
3
|
+
import type { CompositeAnalysisResult, IndustryPeerContext, PostCloseReviewResult, PriorKeyLevelValidationContext } from "../analysis/types/composite-analysis.js";
|
|
4
4
|
import { PostCloseReviewTask } from "../analysis/tasks/post-close-review.task.js";
|
|
5
5
|
import { WatchlistService } from "./watchlist-service.js";
|
|
6
6
|
import { AnalysisService } from "./analysis-service.js";
|
|
@@ -10,6 +10,11 @@ import { KlinesRepository } from "../storage/repositories/klines-repo.js";
|
|
|
10
10
|
import { IntradayKlinesRepository } from "../storage/repositories/intraday-klines-repo.js";
|
|
11
11
|
import { Jin10FlashDeliveryRepository } from "../storage/repositories/jin10-flash-delivery-repo.js";
|
|
12
12
|
import { Jin10FlashRepository } from "../storage/repositories/jin10-flash-repo.js";
|
|
13
|
+
import { IndustryPeerService } from "./industry-peer-service.js";
|
|
14
|
+
interface ReviewMarketSummary {
|
|
15
|
+
latestClose: number | null;
|
|
16
|
+
dailyChangePct: number | null;
|
|
17
|
+
}
|
|
13
18
|
export interface PostCloseReviewRunResult {
|
|
14
19
|
overviewMessage: string;
|
|
15
20
|
detailMessages: string[];
|
|
@@ -26,7 +31,8 @@ export declare class PostCloseReviewService {
|
|
|
26
31
|
private readonly intradayKlinesRepository;
|
|
27
32
|
private readonly flashDeliveryRepository;
|
|
28
33
|
private readonly flashRepository;
|
|
29
|
-
|
|
34
|
+
private readonly industryPeerService;
|
|
35
|
+
constructor(watchlistService: WatchlistService, compositeAnalysisOrchestrator: CompositeAnalysisOrchestrator, analysisService: AnalysisService, postCloseReviewTask: PostCloseReviewTask, keyLevelsRepository: KeyLevelsRepository, keyLevelsHistoryRepository: KeyLevelsHistoryRepository, klinesRepository: KlinesRepository, intradayKlinesRepository: IntradayKlinesRepository, flashDeliveryRepository: Jin10FlashDeliveryRepository, flashRepository: Jin10FlashRepository, industryPeerService: IndustryPeerService);
|
|
30
36
|
run(): Promise<PostCloseReviewRunResult>;
|
|
31
37
|
private persistReview;
|
|
32
38
|
private persistFallbackCompositeReview;
|
|
@@ -36,5 +42,6 @@ export declare class PostCloseReviewService {
|
|
|
36
42
|
private formatDetailMessage;
|
|
37
43
|
private formatFailureMessage;
|
|
38
44
|
}
|
|
39
|
-
export declare function formatPostCloseReviewDetailMessage(item: WatchlistItem, validation: PriorKeyLevelValidationContext, review: PostCloseReviewResult): string;
|
|
40
|
-
export declare function formatPostCloseReviewFailureMessage(item: WatchlistItem, errorMessage: string, compositeResult: CompositeAnalysisResult | null): string;
|
|
45
|
+
export declare function formatPostCloseReviewDetailMessage(item: WatchlistItem, validation: PriorKeyLevelValidationContext, review: PostCloseReviewResult, marketSummary?: ReviewMarketSummary | null, peerContext?: IndustryPeerContext | null): string;
|
|
46
|
+
export declare function formatPostCloseReviewFailureMessage(item: WatchlistItem, errorMessage: string, compositeResult: CompositeAnalysisResult | null, marketSummary?: ReviewMarketSummary | null): string;
|
|
47
|
+
export {};
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { formatChinaDateTime } from "../utils/china-time.js";
|
|
2
|
+
import { formatCostPrice } from "../utils/cost-price.js";
|
|
3
|
+
import { normalizeTickFlowChangePct, resolveTickFlowKlineChangePct } from "../utils/tickflow-quote.js";
|
|
2
4
|
const LEVEL_BUFFER = 0.005;
|
|
3
5
|
const INTRADAY_PERIOD = "1m";
|
|
4
6
|
const MARKET_OVERVIEW_FLASH_KEYWORDS = [
|
|
@@ -17,7 +19,8 @@ export class PostCloseReviewService {
|
|
|
17
19
|
intradayKlinesRepository;
|
|
18
20
|
flashDeliveryRepository;
|
|
19
21
|
flashRepository;
|
|
20
|
-
|
|
22
|
+
industryPeerService;
|
|
23
|
+
constructor(watchlistService, compositeAnalysisOrchestrator, analysisService, postCloseReviewTask, keyLevelsRepository, keyLevelsHistoryRepository, klinesRepository, intradayKlinesRepository, flashDeliveryRepository, flashRepository, industryPeerService) {
|
|
21
24
|
this.watchlistService = watchlistService;
|
|
22
25
|
this.compositeAnalysisOrchestrator = compositeAnalysisOrchestrator;
|
|
23
26
|
this.analysisService = analysisService;
|
|
@@ -28,6 +31,7 @@ export class PostCloseReviewService {
|
|
|
28
31
|
this.intradayKlinesRepository = intradayKlinesRepository;
|
|
29
32
|
this.flashDeliveryRepository = flashDeliveryRepository;
|
|
30
33
|
this.flashRepository = flashRepository;
|
|
34
|
+
this.industryPeerService = industryPeerService;
|
|
31
35
|
}
|
|
32
36
|
async run() {
|
|
33
37
|
const watchlist = await this.watchlistService.list();
|
|
@@ -44,11 +48,35 @@ export class PostCloseReviewService {
|
|
|
44
48
|
let marketOverview = null;
|
|
45
49
|
for (const item of watchlist) {
|
|
46
50
|
let compositeResult = null;
|
|
51
|
+
let marketSummary = null;
|
|
47
52
|
try {
|
|
48
53
|
const input = await this.compositeAnalysisOrchestrator.buildInput(item.symbol);
|
|
54
|
+
marketSummary = buildReviewMarketSummary(input.market.klines, input.market.realtimeQuote);
|
|
49
55
|
marketOverview ??= input.market.marketOverview;
|
|
50
56
|
const tradeDate = input.market.klines[input.market.klines.length - 1]?.trade_date ?? formatChinaDateTime().slice(0, 10);
|
|
51
57
|
const validation = await this.buildValidationContext(item.symbol, tradeDate);
|
|
58
|
+
const peerContext = await this.industryPeerService.buildContext(item.symbol)
|
|
59
|
+
.catch((error) => ({
|
|
60
|
+
available: false,
|
|
61
|
+
summary: "未获取到申万三级同业表现。",
|
|
62
|
+
sw1Name: null,
|
|
63
|
+
sw2Name: null,
|
|
64
|
+
sw3Name: null,
|
|
65
|
+
sw3UniverseId: null,
|
|
66
|
+
peerCount: 0,
|
|
67
|
+
otherStockCount: 0,
|
|
68
|
+
advanceCount: 0,
|
|
69
|
+
declineCount: 0,
|
|
70
|
+
flatCount: 0,
|
|
71
|
+
averageChangePct: null,
|
|
72
|
+
medianChangePct: null,
|
|
73
|
+
targetChangePct: null,
|
|
74
|
+
targetRank: null,
|
|
75
|
+
targetPercentile: null,
|
|
76
|
+
leaders: [],
|
|
77
|
+
laggards: [],
|
|
78
|
+
note: error instanceof Error ? error.message : String(error),
|
|
79
|
+
}));
|
|
52
80
|
compositeResult = await this.compositeAnalysisOrchestrator.analyzeInput(input);
|
|
53
81
|
const flashContext = await this.buildFlashContext(item.symbol, tradeDate);
|
|
54
82
|
const review = await this.analysisService.runTask(this.postCloseReviewTask, {
|
|
@@ -56,8 +84,9 @@ export class PostCloseReviewService {
|
|
|
56
84
|
compositeResult,
|
|
57
85
|
validation,
|
|
58
86
|
flashContext,
|
|
87
|
+
peerContext,
|
|
59
88
|
});
|
|
60
|
-
const message = this.formatDetailMessage(item, validation, review);
|
|
89
|
+
const message = this.formatDetailMessage(item, validation, review, marketSummary, peerContext);
|
|
61
90
|
await this.persistReview(item.symbol, message, review);
|
|
62
91
|
entries.push({
|
|
63
92
|
ok: true,
|
|
@@ -73,7 +102,7 @@ export class PostCloseReviewService {
|
|
|
73
102
|
await this.persistFallbackCompositeReview(item.symbol, compositeResult);
|
|
74
103
|
}
|
|
75
104
|
entries.push({ ok: false, item, errorMessage: message });
|
|
76
|
-
detailMessages.push(this.formatFailureMessage(item, message, compositeResult));
|
|
105
|
+
detailMessages.push(this.formatFailureMessage(item, message, compositeResult, marketSummary));
|
|
77
106
|
}
|
|
78
107
|
}
|
|
79
108
|
const overviewMessage = this.formatOverviewMessage(marketOverview, entries);
|
|
@@ -207,17 +236,20 @@ export class PostCloseReviewService {
|
|
|
207
236
|
];
|
|
208
237
|
return `**🧭 收盘复盘总览**\n\n${lines.join("\n")}`.trim();
|
|
209
238
|
}
|
|
210
|
-
formatDetailMessage(item, validation, review) {
|
|
211
|
-
return formatPostCloseReviewDetailMessage(item, validation, review);
|
|
239
|
+
formatDetailMessage(item, validation, review, marketSummary, peerContext = null) {
|
|
240
|
+
return formatPostCloseReviewDetailMessage(item, validation, review, marketSummary, peerContext);
|
|
212
241
|
}
|
|
213
|
-
formatFailureMessage(item, errorMessage, compositeResult) {
|
|
214
|
-
return formatPostCloseReviewFailureMessage(item, errorMessage, compositeResult);
|
|
242
|
+
formatFailureMessage(item, errorMessage, compositeResult, marketSummary) {
|
|
243
|
+
return formatPostCloseReviewFailureMessage(item, errorMessage, compositeResult, marketSummary);
|
|
215
244
|
}
|
|
216
245
|
}
|
|
217
|
-
export function formatPostCloseReviewDetailMessage(item, validation, review) {
|
|
246
|
+
export function formatPostCloseReviewDetailMessage(item, validation, review, marketSummary = null, peerContext = null) {
|
|
247
|
+
const marketMeta = formatReviewMarketMeta(item, marketSummary);
|
|
248
|
+
const industryPosition = formatIndustryPosition(peerContext);
|
|
218
249
|
const lines = [
|
|
219
250
|
`**📘 收盘复盘|${item.name}(${item.symbol})**`,
|
|
220
251
|
`${formatValidationVerdictBadge(validation.verdict)} 昨日验证:${formatValidationVerdictLabel(validation.verdict)} | ${formatDecisionBadge(review.decision)} 明日处理:${formatDecisionLabel(review.decision)}`,
|
|
252
|
+
...(marketMeta ? [marketMeta] : []),
|
|
221
253
|
"",
|
|
222
254
|
formatSectionTitle("📍", "昨日关键位验证"),
|
|
223
255
|
`• 结论:${validation.summary}`,
|
|
@@ -227,7 +259,11 @@ export function formatPostCloseReviewDetailMessage(item, validation, review) {
|
|
|
227
259
|
review.sessionSummary || "未生成盘面一句话总结。",
|
|
228
260
|
"",
|
|
229
261
|
formatSectionTitle("🌐", "大盘与板块"),
|
|
230
|
-
|
|
262
|
+
[
|
|
263
|
+
`• 风向:大盘 ${formatMarketBiasBadge(review.marketBias)}${formatMarketBiasLabel(review.marketBias)}`,
|
|
264
|
+
`板块 ${formatMarketBiasBadge(review.sectorBias)}${formatMarketBiasLabel(review.sectorBias)}`,
|
|
265
|
+
industryPosition ? `同业 ${industryPosition}` : null,
|
|
266
|
+
].filter(Boolean).join(" | "),
|
|
231
267
|
review.marketSectorSummary || "未生成大盘/板块总结。",
|
|
232
268
|
"",
|
|
233
269
|
formatSectionTitle("📰", "新闻与公告"),
|
|
@@ -255,12 +291,14 @@ export function formatPostCloseReviewDetailMessage(item, validation, review) {
|
|
|
255
291
|
lines.push(`• 支撑 ${formatMaybePrice(review.levels.support)} | 压力 ${formatMaybePrice(review.levels.resistance)} | 突破 ${formatMaybePrice(review.levels.breakthrough)}`, `• 止损 ${formatMaybePrice(review.levels.stop_loss)} | 止盈 ${formatMaybePrice(review.levels.take_profit)} | 评分 ${review.levels.score}/10`, ...(levelRail ? [`• 价位框架:${levelRail}`] : []), "", formatSectionTitle("✅", "操作建议"), review.actionAdvice || "按关键位和次日量价配合再决定是否执行。");
|
|
256
292
|
return lines.join("\n");
|
|
257
293
|
}
|
|
258
|
-
export function formatPostCloseReviewFailureMessage(item, errorMessage, compositeResult) {
|
|
294
|
+
export function formatPostCloseReviewFailureMessage(item, errorMessage, compositeResult, marketSummary = null) {
|
|
259
295
|
const fallback = compositeResult?.levels
|
|
260
296
|
? "已保留综合分析生成的关键位,可稍后用 view_analysis 或 analyze 复核。"
|
|
261
297
|
: "本轮未生成可用关键位。";
|
|
298
|
+
const marketMeta = formatReviewMarketMeta(item, marketSummary);
|
|
262
299
|
return [
|
|
263
300
|
`**⚠️ 收盘复盘|${item.name}(${item.symbol})**`,
|
|
301
|
+
...(marketMeta ? ["", marketMeta] : []),
|
|
264
302
|
"",
|
|
265
303
|
formatSectionTitle("❌", "失败原因"),
|
|
266
304
|
errorMessage,
|
|
@@ -289,6 +327,71 @@ function toHistoryEntry(symbol, analysisText, levels) {
|
|
|
289
327
|
score: levels.score,
|
|
290
328
|
};
|
|
291
329
|
}
|
|
330
|
+
function buildReviewMarketSummary(klines, realtimeQuote) {
|
|
331
|
+
const latestKline = klines[klines.length - 1] ?? null;
|
|
332
|
+
if (!latestKline && !realtimeQuote) {
|
|
333
|
+
return null;
|
|
334
|
+
}
|
|
335
|
+
const latestClose = latestKline?.close ?? realtimeQuote?.last_price ?? null;
|
|
336
|
+
const dailyChangePct = normalizeTickFlowChangePct(realtimeQuote?.ext?.change_pct)
|
|
337
|
+
?? resolveTickFlowKlineChangePct(latestKline);
|
|
338
|
+
return {
|
|
339
|
+
latestClose,
|
|
340
|
+
dailyChangePct,
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
function formatReviewMarketMeta(item, marketSummary) {
|
|
344
|
+
const parts = [];
|
|
345
|
+
if (marketSummary?.latestClose != null && Number.isFinite(marketSummary.latestClose)) {
|
|
346
|
+
parts.push(`• 收盘 ${marketSummary.latestClose.toFixed(2)}`);
|
|
347
|
+
}
|
|
348
|
+
if (marketSummary?.dailyChangePct != null && Number.isFinite(marketSummary.dailyChangePct)) {
|
|
349
|
+
parts.push(`当日 ${formatSignedPct(marketSummary.dailyChangePct)}`);
|
|
350
|
+
}
|
|
351
|
+
if (item.costPrice != null && Number.isFinite(item.costPrice) && item.costPrice > 0) {
|
|
352
|
+
parts.push(`成本 ${formatCostPrice(item.costPrice)}`);
|
|
353
|
+
}
|
|
354
|
+
return parts.length > 0 ? parts.join(" | ") : null;
|
|
355
|
+
}
|
|
356
|
+
function formatSignedPct(value) {
|
|
357
|
+
return `${value >= 0 ? "+" : ""}${value.toFixed(2)}%`;
|
|
358
|
+
}
|
|
359
|
+
function formatIndustryPosition(context) {
|
|
360
|
+
if (!context?.available || !context.targetRank || !(context.peerCount > 0)) {
|
|
361
|
+
return null;
|
|
362
|
+
}
|
|
363
|
+
return `${classifyIndustryPosition(context)}(${context.targetRank}/${context.peerCount})`;
|
|
364
|
+
}
|
|
365
|
+
function classifyIndustryPosition(context) {
|
|
366
|
+
const { targetRank, peerCount } = context;
|
|
367
|
+
if (!targetRank || !(peerCount > 0)) {
|
|
368
|
+
return "位置未知";
|
|
369
|
+
}
|
|
370
|
+
if (targetRank === 1) {
|
|
371
|
+
return "领涨";
|
|
372
|
+
}
|
|
373
|
+
if (targetRank === peerCount) {
|
|
374
|
+
return "领跌";
|
|
375
|
+
}
|
|
376
|
+
if (peerCount <= 3) {
|
|
377
|
+
return "中游";
|
|
378
|
+
}
|
|
379
|
+
const percentile = context.targetPercentile
|
|
380
|
+
?? (peerCount > 1 ? 1 - ((targetRank - 1) / (peerCount - 1)) : 1);
|
|
381
|
+
if (percentile >= 0.8) {
|
|
382
|
+
return "领涨区";
|
|
383
|
+
}
|
|
384
|
+
if (percentile >= 0.6) {
|
|
385
|
+
return "偏强";
|
|
386
|
+
}
|
|
387
|
+
if (percentile > 0.4) {
|
|
388
|
+
return "中游";
|
|
389
|
+
}
|
|
390
|
+
if (percentile > 0.2) {
|
|
391
|
+
return "偏弱";
|
|
392
|
+
}
|
|
393
|
+
return "领跌区";
|
|
394
|
+
}
|
|
292
395
|
function evaluateSupport(snapshot, row) {
|
|
293
396
|
if (!(snapshot.support != null && snapshot.support > 0)) {
|
|
294
397
|
return "支撑: 昨日未设置支撑位。";
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { PRE_MARKET_BRIEF_SYSTEM_PROMPT, buildPreMarketBriefUserPrompt, } from "../prompts/analysis/index.js";
|
|
3
3
|
import { formatChinaDateTime } from "../utils/china-time.js";
|
|
4
|
+
import { extractSectorKeywords } from "./watchlist-profile-service.js";
|
|
4
5
|
const PRE_MARKET_BRIEF_KEYWORD = "金十数据整理";
|
|
5
6
|
const PRE_MARKET_READY_TIME = "09:20:00";
|
|
6
7
|
const PRE_MARKET_SYNC_MAX_PAGES = 12;
|
|
@@ -80,10 +81,7 @@ export class PreMarketBriefService {
|
|
|
80
81
|
matchedWatchlistCount: 0,
|
|
81
82
|
};
|
|
82
83
|
}
|
|
83
|
-
const matchContexts = flashes.map((flash) => (
|
|
84
|
-
flash,
|
|
85
|
-
matchedItems: findMatchedItems(flash, watchlist),
|
|
86
|
-
}));
|
|
84
|
+
const matchContexts = flashes.map((flash) => buildFlashMatchContext(flash, watchlist));
|
|
87
85
|
const matchedWatchlistCount = new Set(matchContexts.flatMap((context) => context.matchedItems.map((item) => item.symbol))).size;
|
|
88
86
|
const summary = await this.buildSummary(window, watchlist, matchContexts);
|
|
89
87
|
return {
|
|
@@ -128,7 +126,9 @@ export class PreMarketBriefService {
|
|
|
128
126
|
watchlist,
|
|
129
127
|
flashes: matchContexts.map((context) => ({
|
|
130
128
|
publishedAt: context.flash.published_at,
|
|
131
|
-
headline:
|
|
129
|
+
headline: context.headline,
|
|
130
|
+
summary: context.summary,
|
|
131
|
+
keyPoints: context.keyPoints,
|
|
132
132
|
content: context.flash.content,
|
|
133
133
|
url: context.flash.url,
|
|
134
134
|
matchedSymbols: context.matchedItems.map((item) => item.symbol),
|
|
@@ -136,10 +136,13 @@ export class PreMarketBriefService {
|
|
|
136
136
|
};
|
|
137
137
|
if (this.analysisService.isConfigured()) {
|
|
138
138
|
try {
|
|
139
|
-
|
|
139
|
+
const generated = await this.analysisService.generateText(PRE_MARKET_BRIEF_SYSTEM_PROMPT, buildPreMarketBriefUserPrompt(promptInput), {
|
|
140
140
|
maxTokens: 1600,
|
|
141
141
|
temperature: 0.2,
|
|
142
142
|
});
|
|
143
|
+
if (!isLowSignalSummary(generated, matchContexts)) {
|
|
144
|
+
return generated;
|
|
145
|
+
}
|
|
143
146
|
}
|
|
144
147
|
catch {
|
|
145
148
|
// Fall through to deterministic fallback so the scheduled push still lands.
|
|
@@ -183,7 +186,7 @@ function findMatchedItems(flash, watchlist) {
|
|
|
183
186
|
const normalizedContent = normalizeText(flash.content);
|
|
184
187
|
return watchlist.filter((item) => {
|
|
185
188
|
const directKeywords = [item.symbol, item.symbol.slice(0, 6), item.name];
|
|
186
|
-
const boardKeywords = [item.sector
|
|
189
|
+
const boardKeywords = [...extractSectorKeywords(item.sector), ...item.themes]
|
|
187
190
|
.map((keyword) => keyword.replace(/\s+/g, "").trim())
|
|
188
191
|
.filter((keyword) => keyword.length >= 2);
|
|
189
192
|
return [...directKeywords, ...boardKeywords]
|
|
@@ -191,6 +194,16 @@ function findMatchedItems(flash, watchlist) {
|
|
|
191
194
|
.some((keyword) => keyword && normalizedContent.includes(keyword));
|
|
192
195
|
});
|
|
193
196
|
}
|
|
197
|
+
function buildFlashMatchContext(flash, watchlist) {
|
|
198
|
+
const insight = extractFlashInsight(flash.content);
|
|
199
|
+
return {
|
|
200
|
+
flash,
|
|
201
|
+
matchedItems: findMatchedItems(flash, watchlist),
|
|
202
|
+
headline: insight.headline,
|
|
203
|
+
summary: insight.summary,
|
|
204
|
+
keyPoints: insight.keyPoints,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
194
207
|
function buildFallbackSummary(matchContexts) {
|
|
195
208
|
const opportunityContexts = matchContexts.filter((context) => containsAnyKeyword(context.flash.content, OPPORTUNITY_KEYWORDS));
|
|
196
209
|
const riskContexts = matchContexts.filter((context) => containsAnyKeyword(context.flash.content, RISK_KEYWORDS));
|
|
@@ -220,7 +233,7 @@ function formatFlashBullets(contexts, limit) {
|
|
|
220
233
|
.slice(0, limit)
|
|
221
234
|
.map((context) => {
|
|
222
235
|
const time = context.flash.published_at.slice(11, 16);
|
|
223
|
-
return `• [${time}] ${
|
|
236
|
+
return `• [${time}] ${formatContextSummary(context)}`;
|
|
224
237
|
})
|
|
225
238
|
.join("\n");
|
|
226
239
|
}
|
|
@@ -231,7 +244,7 @@ function formatMatchedBullets(contexts, limit) {
|
|
|
231
244
|
}
|
|
232
245
|
return matched.map((context) => {
|
|
233
246
|
const labels = context.matchedItems.map((item) => `${item.name}(${item.symbol})`).join("、");
|
|
234
|
-
return `• ${labels}: ${
|
|
247
|
+
return `• ${labels}: ${formatContextSummary(context)}`;
|
|
235
248
|
}).join("\n");
|
|
236
249
|
}
|
|
237
250
|
function buildFocusBullets(contexts) {
|
|
@@ -239,11 +252,11 @@ function buildFocusBullets(contexts) {
|
|
|
239
252
|
const matchedContexts = contexts.filter((context) => context.matchedItems.length > 0);
|
|
240
253
|
for (const context of matchedContexts.slice(0, 3)) {
|
|
241
254
|
const labels = context.matchedItems.map((item) => item.name).join("、");
|
|
242
|
-
bullets.push(`• 关注 ${labels}
|
|
255
|
+
bullets.push(`• 关注 ${labels} 开盘后的量价反馈,重点核实“${formatFocusCue(context)}”是否继续发酵。`);
|
|
243
256
|
}
|
|
244
257
|
if (bullets.length < 3) {
|
|
245
258
|
for (const context of contexts.slice(0, 3 - bullets.length)) {
|
|
246
|
-
bullets.push(`• 关注“${
|
|
259
|
+
bullets.push(`• 关注“${formatFocusCue(context)}”对应板块是否出现竞价强化或高开分歧。`);
|
|
247
260
|
}
|
|
248
261
|
}
|
|
249
262
|
return bullets.slice(0, 5).join("\n");
|
|
@@ -264,6 +277,147 @@ function extractHeadlineFromContent(content) {
|
|
|
264
277
|
function extractHeadlineText(content) {
|
|
265
278
|
return content.split(/[\n。!!]/)[0]?.trim() ?? "";
|
|
266
279
|
}
|
|
280
|
+
function extractFlashInsight(content) {
|
|
281
|
+
const headline = extractHeadlineFromContent(content);
|
|
282
|
+
const keyPoints = extractFlashKeyPoints(content, headline);
|
|
283
|
+
if (keyPoints.length === 0) {
|
|
284
|
+
return {
|
|
285
|
+
headline,
|
|
286
|
+
summary: buildTitleOnlySummary(headline),
|
|
287
|
+
keyPoints: [],
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
return {
|
|
291
|
+
headline,
|
|
292
|
+
summary: keyPoints.slice(0, 2).join(";"),
|
|
293
|
+
keyPoints,
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
function extractFlashKeyPoints(content, headline) {
|
|
297
|
+
const body = stripHeadline(content, headline);
|
|
298
|
+
if (!body) {
|
|
299
|
+
return [];
|
|
300
|
+
}
|
|
301
|
+
const lineCandidates = body
|
|
302
|
+
.replace(/\r/g, "\n")
|
|
303
|
+
.replace(/\n{2,}/g, "\n")
|
|
304
|
+
.replace(/([。;!?!?])\s*(?=\d+\s*[、..))])/g, "$1\n")
|
|
305
|
+
.split(/\n+/)
|
|
306
|
+
.map((segment) => cleanFlashSegment(segment))
|
|
307
|
+
.filter(Boolean);
|
|
308
|
+
const candidates = lineCandidates.length > 1
|
|
309
|
+
? lineCandidates
|
|
310
|
+
: body
|
|
311
|
+
.split(/[。;!?!?]/)
|
|
312
|
+
.map((segment) => cleanFlashSegment(segment))
|
|
313
|
+
.filter(Boolean);
|
|
314
|
+
const normalizedHeadline = normalizeText(headline);
|
|
315
|
+
const deduped = new Set();
|
|
316
|
+
const keyPoints = [];
|
|
317
|
+
for (const segment of candidates) {
|
|
318
|
+
const normalizedSegment = normalizeText(segment);
|
|
319
|
+
if (!normalizedSegment || normalizedSegment === normalizedHeadline) {
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
if (normalizedHeadline && normalizedHeadline.includes(normalizedSegment) && segment.length < Math.max(12, headline.length)) {
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
if (segment.length < 8) {
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
if (deduped.has(normalizedSegment)) {
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
deduped.add(normalizedSegment);
|
|
332
|
+
keyPoints.push(truncateText(segment, 88));
|
|
333
|
+
if (keyPoints.length >= 3) {
|
|
334
|
+
break;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
return keyPoints;
|
|
338
|
+
}
|
|
339
|
+
function stripHeadline(content, headline) {
|
|
340
|
+
const trimmed = content.trim();
|
|
341
|
+
if (!headline) {
|
|
342
|
+
return trimmed;
|
|
343
|
+
}
|
|
344
|
+
if (!trimmed.startsWith(headline)) {
|
|
345
|
+
return trimmed;
|
|
346
|
+
}
|
|
347
|
+
return trimmed.slice(headline.length).replace(/^[::。;,、\s-]+/, "").trim();
|
|
348
|
+
}
|
|
349
|
+
function cleanFlashSegment(segment) {
|
|
350
|
+
return segment
|
|
351
|
+
.trim()
|
|
352
|
+
.replace(/^[-•●▪◦]\s*/, "")
|
|
353
|
+
.replace(/^\d+\s*[、..))]\s*/, "")
|
|
354
|
+
.replace(/^[((]?\d+[))]\s*/, "")
|
|
355
|
+
.replace(/^[::;,。、\s]+/, "")
|
|
356
|
+
.replace(/[::;,。、\s]+$/, "")
|
|
357
|
+
.replace(/\s+/g, " ");
|
|
358
|
+
}
|
|
359
|
+
function buildTitleOnlySummary(headline) {
|
|
360
|
+
const coreHeadline = headline.replace(/^【?金十数据整理[::]\s*/, "").replace(/】$/, "").trim();
|
|
361
|
+
if (!coreHeadline) {
|
|
362
|
+
return "该整理快讯未提取到可用细节,暂只能作为标题级线索参考。";
|
|
363
|
+
}
|
|
364
|
+
return `${coreHeadline},但正文未提取到更具体细节,暂只能作为标题级线索参考。`;
|
|
365
|
+
}
|
|
366
|
+
function formatContextSummary(context) {
|
|
367
|
+
return context.summary || buildTitleOnlySummary(context.headline);
|
|
368
|
+
}
|
|
369
|
+
function formatFocusCue(context) {
|
|
370
|
+
return context.keyPoints[0] ?? context.summary ?? context.headline;
|
|
371
|
+
}
|
|
372
|
+
function truncateText(value, maxLength) {
|
|
373
|
+
return value.length <= maxLength ? value : `${value.slice(0, maxLength)}...`;
|
|
374
|
+
}
|
|
375
|
+
function isLowSignalSummary(summary, contexts) {
|
|
376
|
+
const bulletLines = summary
|
|
377
|
+
.split(/\n+/)
|
|
378
|
+
.map((line) => line.trim())
|
|
379
|
+
.filter((line) => /^(?:•|-|\d+\.)/.test(line));
|
|
380
|
+
if (bulletLines.length === 0) {
|
|
381
|
+
return true;
|
|
382
|
+
}
|
|
383
|
+
const titleOnlyCount = bulletLines.filter((line) => isTitleOnlyBullet(line, contexts)).length;
|
|
384
|
+
return titleOnlyCount >= Math.max(2, Math.ceil(bulletLines.length / 3));
|
|
385
|
+
}
|
|
386
|
+
function isTitleOnlyBullet(line, contexts) {
|
|
387
|
+
const candidates = normalizeBulletForComparison(line);
|
|
388
|
+
if (candidates.length === 0) {
|
|
389
|
+
return false;
|
|
390
|
+
}
|
|
391
|
+
return contexts.some((context) => {
|
|
392
|
+
const headlineForms = [
|
|
393
|
+
normalizeDigestText(context.headline),
|
|
394
|
+
normalizeDigestText(stripDigestPrefix(context.headline)),
|
|
395
|
+
].filter(Boolean);
|
|
396
|
+
return headlineForms.some((headline) => candidates.some((candidate) => candidate.includes(headline) && candidate.length <= headline.length + 6));
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
function normalizeBulletForComparison(line) {
|
|
400
|
+
const cleaned = line
|
|
401
|
+
.replace(/^(?:•|-|\d+\.)\s*/, "")
|
|
402
|
+
.replace(/^\[[0-9:]+\]\s*/, "");
|
|
403
|
+
const normalizedVariants = [
|
|
404
|
+
cleaned,
|
|
405
|
+
cleaned.split(/[::]/).at(-1) ?? cleaned,
|
|
406
|
+
]
|
|
407
|
+
.map((item) => normalizeDigestText(item))
|
|
408
|
+
.filter(Boolean);
|
|
409
|
+
return [...new Set(normalizedVariants)];
|
|
410
|
+
}
|
|
411
|
+
function stripDigestPrefix(value) {
|
|
412
|
+
return value.replace(/^【?金十数据整理[::]\s*/, "").replace(/】$/, "").trim();
|
|
413
|
+
}
|
|
414
|
+
function normalizeDigestText(value) {
|
|
415
|
+
return value
|
|
416
|
+
.toLowerCase()
|
|
417
|
+
.replace(/[【】[\]()()"'“”‘’]/g, "")
|
|
418
|
+
.replace(/[::]/g, "")
|
|
419
|
+
.replace(/\s+/g, "");
|
|
420
|
+
}
|
|
267
421
|
function toFlashRecord(item) {
|
|
268
422
|
const published = new Date(item.time);
|
|
269
423
|
if (Number.isNaN(published.getTime())) {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { TickFlowBalanceSheetRecord, TickFlowCashFlowRecord, TickFlowFinancialMetricsRecord, TickFlowFinancialQueryOptions, TickFlowIncomeRecord, TickFlowInstrument, TickFlowQuote } from "../types/tickflow.js";
|
|
1
|
+
import type { TickFlowBalanceSheetRecord, TickFlowCashFlowRecord, TickFlowFinancialMetricsRecord, TickFlowFinancialQueryOptions, TickFlowIncomeRecord, TickFlowInstrument, TickFlowQuote, TickFlowUniverseDetail, TickFlowUniverseSummary } from "../types/tickflow.js";
|
|
2
2
|
export declare class TickFlowClientError extends Error {
|
|
3
3
|
constructor(message: string);
|
|
4
4
|
}
|
|
@@ -10,6 +10,9 @@ export declare class TickFlowClient {
|
|
|
10
10
|
getApiKey(): string;
|
|
11
11
|
fetchInstruments(symbols: string[]): Promise<TickFlowInstrument[]>;
|
|
12
12
|
fetchQuotes(symbols: string[]): Promise<TickFlowQuote[]>;
|
|
13
|
+
listUniverses(): Promise<TickFlowUniverseSummary[]>;
|
|
14
|
+
fetchUniverse(id: string): Promise<TickFlowUniverseDetail | null>;
|
|
15
|
+
fetchUniverseBatch(ids: string[]): Promise<Record<string, TickFlowUniverseDetail>>;
|
|
13
16
|
fetchKlinesBatch<T = unknown>(symbols: string[], params?: {
|
|
14
17
|
period?: string;
|
|
15
18
|
count?: number;
|
|
@@ -39,6 +39,38 @@ export class TickFlowClient {
|
|
|
39
39
|
});
|
|
40
40
|
return response.data ?? [];
|
|
41
41
|
}
|
|
42
|
+
async listUniverses() {
|
|
43
|
+
const url = new URL("/v1/universes", this.baseUrl);
|
|
44
|
+
const response = await this.requestJson(url.toString(), {
|
|
45
|
+
method: "GET",
|
|
46
|
+
});
|
|
47
|
+
return response.data ?? [];
|
|
48
|
+
}
|
|
49
|
+
async fetchUniverse(id) {
|
|
50
|
+
const normalizedId = String(id ?? "").trim();
|
|
51
|
+
if (!normalizedId) {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
const url = new URL(`/v1/universes/${encodeURIComponent(normalizedId)}`, this.baseUrl);
|
|
55
|
+
const response = await this.requestJson(url.toString(), {
|
|
56
|
+
method: "GET",
|
|
57
|
+
});
|
|
58
|
+
return response.data ?? null;
|
|
59
|
+
}
|
|
60
|
+
async fetchUniverseBatch(ids) {
|
|
61
|
+
const normalizedIds = ids
|
|
62
|
+
.map((id) => String(id ?? "").trim())
|
|
63
|
+
.filter(Boolean);
|
|
64
|
+
if (normalizedIds.length === 0) {
|
|
65
|
+
return {};
|
|
66
|
+
}
|
|
67
|
+
const url = new URL("/v1/universes/batch", this.baseUrl);
|
|
68
|
+
const response = await this.requestJson(url.toString(), {
|
|
69
|
+
method: "POST",
|
|
70
|
+
body: JSON.stringify({ ids: normalizedIds }),
|
|
71
|
+
});
|
|
72
|
+
return response.data ?? {};
|
|
73
|
+
}
|
|
42
74
|
async fetchKlinesBatch(symbols, params = {}) {
|
|
43
75
|
if (symbols.length === 0) {
|
|
44
76
|
return { data: {} };
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { TickFlowClient } from "./tickflow-client.js";
|
|
2
|
+
import { UniverseMembershipRepository } from "../storage/repositories/universe-membership-repo.js";
|
|
3
|
+
import { UniverseRepository } from "../storage/repositories/universe-repo.js";
|
|
4
|
+
export interface TickFlowIndustryProfile {
|
|
5
|
+
sectorPath: string | null;
|
|
6
|
+
sw1Name: string | null;
|
|
7
|
+
sw2Name: string | null;
|
|
8
|
+
sw3Name: string | null;
|
|
9
|
+
sw1UniverseId: string | null;
|
|
10
|
+
sw2UniverseId: string | null;
|
|
11
|
+
sw3UniverseId: string | null;
|
|
12
|
+
industryCode: string | null;
|
|
13
|
+
}
|
|
14
|
+
export declare class TickFlowUniverseService {
|
|
15
|
+
private readonly client;
|
|
16
|
+
private readonly universeRepository;
|
|
17
|
+
private readonly membershipRepository;
|
|
18
|
+
private catalog;
|
|
19
|
+
constructor(client: TickFlowClient, universeRepository: UniverseRepository, membershipRepository: UniverseMembershipRepository);
|
|
20
|
+
resolveIndustryProfile(symbol: string): Promise<TickFlowIndustryProfile | null>;
|
|
21
|
+
listUniverseSymbols(universeId: string): Promise<string[]>;
|
|
22
|
+
private ensureCatalog;
|
|
23
|
+
private loadCatalogFromRepositories;
|
|
24
|
+
private syncCatalogFromTickFlow;
|
|
25
|
+
private fetchUniverseDetails;
|
|
26
|
+
}
|